Building a Basic HTTP Server with Node.js
Table of contents
- It's BUILD Time!
- Step 1: Prerequisites
- Step 2: Set Up Your Project
- 1. Importing the required modules.
- 2. Creating the TCP Server
- 3. Handling Server Events
- 4. Writing the handleNewConnection function
- 1. New Connection
- 2. Listening for Data
- 3. Handling Request
- 4. Sending the Response
- 5. End Connection listener
- 6. Starting the Server
- Demo
- Conclusion
Hey anon ๐ Welcome to my blog!
In the last blog, we dived into Reverse Proxies and also created one ourselves! So, in this blog, we'll be building something much simpler than that. We will be building a very basic HTTP Server with Node.js, specifically a HTTP/0.9 Server.
In this blog, we'll step back to 1991 and build something simple yet foundational โ a very basic HTTP/0.9 server with Node.js. HTTP/0.9 was the first version of the HTTP Protocol released back in 1991. It was an extremely simple protocol, with very basic functionality. Unlike modern HTTP versions, there were no headers, no status codes like 404 Not Found, and no complex request methods like POST, PUT OR DELETE.
A typical HTTP/0.9 request would simply be:
GET /index.html
And the server would respond with the raw HTML file, without any extra metadata. Something like this:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Greetings</title>
</head>
<body>
<h1>Hello!</h1>
</body>
</html>
It's BUILD Time!
Step 1: Prerequisites
First, ensure you have Node.js and npm installed on your system. If not, you can download them from nodejs.org.
Step 2: Set Up Your Project
Next, create a directory, cd
into it and initialize a node project:
mkdir http-server
cd http-server
npm init -y
For this project, we don't need any external dependencies since we will be utilizing the built-in net and fs modules that come with Node.js.
1. Importing the required modules.
We are importing the net module for creating a TCP server and the fs/promises module to interact with the file system asynchronously.
import * as net from "net";
import * as fs from "fs/promises";
2. Creating the TCP Server
const server = net.createServer();
const htmlFilePath = "./html/";
- The first line creates a TCP server, which listens for incoming connections.
- The second line sets the path to the directory where our server will look for HTML files. By restricting the file access to the specified directory (via htmlFilePath), we prevent path traversal attacks. These attacks attempt to access sensitive files outside the intended directory, like /etc/passwd in UNIX-based systems. By ensuring the server only reads files within the designated directory, we mitigate this risk. For example, a request like:
GET ../../documents
would be blocked, as the server is restricted to the defined HTML file path.
3. Handling Server Events
server.on("connection", handleNewConnection);
server.on("error", (err: Error) => {
console.log(err);
});
- The first line listens for new client connections. Whenever a client connects to the server, the
handleNewConnection
function is triggered to handle the interaction, such as processing requests and sending responses. - The second line listens for any errors that occur during the server's operation. If an error is thrown, it will be caught here, and logged.
4. Writing the handleNewConnection
function
The function takes the socket
object as a parameter, which is automatically passed to it whenever a new client connection is established, triggering the function. This socket
object represents the communication channel between the server and the connected client, allowing us to read data from and send responses to the client.
function handleNewConnection(socket: net.Socket) {
console.log("New Connection!");
socket.on("data", async (data: Buffer) => {
const request = new TextDecoder().decode(data);
const splitRequest = request.split(" ");
let htmlFile;
if (splitRequest.length !== 2) {
socket.write("Error: Incomplete Request");
socket.end();
return;
}
const requestType = splitRequest[0];
if (splitRequest[1].trim() === "/") {
htmlFile = "index.html";
} else {
htmlFile = splitRequest[1].slice(1);
}
if (requestType !== "GET") {
socket.write("Error: Only GET Requests are supported!");
socket.end();
return;
}
try {
const filePath = htmlFilePath + htmlFile;
const fileData = await fs.readFile(filePath);
socket.write(fileData);
} catch (err) {
socket.write("Error: Requested resource does not exist!");
}
socket.end();
});
socket.on("end", () => {
console.log("Connection closed!");
});
}
Let's go through the function step-by-step.
1. New Connection
logs "New Connection!", whenever a new client connects to the server.
console.log("New Connection!");
2. Listening for Data
This adds a listener to the socket object. So, whenever the clients sends data to the server we can handle it properly using a callback function that takes data as a parameter, which is the actual data in the form of raw bytes sent by the client.
socket.on("data", async (data: Buffer) => {});
Now, inside the callback...
const request = new TextDecoder().decode(data);
const splitRequest = request.split(" ");
let htmlFile;
if (splitRequest.length !== 2) {
socket.write("Error: Incomplete Request");
socket.end();
return;
}
Here, we convert data from raw bytes to string and split the request with space
as a separator. As you might remember, an HTTP/0.9 Request is just a single line with Request Type and a Path separated by space.
Then, we check if the length of the array is 2, this is necessary. Because if someone made an invalid request such as this:
GET / index.html
OR
GET/index.html
The server will return an error indicating it's an Incomplete Request.
3. Handling Request
const requestType = splitRequest[0];
if (splitRequest[1].trim() === "/") {
htmlFile = "index.html";
} else {
htmlFile = splitRequest[1].slice(1);
}
if (requestType !== "GET") {
socket.write("Error: Only GET Requests are supported!");
socket.end();
return;
}
In the first condition, we check if the path is / and if it is, we serve the index.html file else we server the requested file based on the path.
In the second condition, we check whether the Request Type is GET or not, and if not we return an Error. Because in HTTP/0.9, the protocol only supports GET requests, so if we receive anything else, the server will respond with an error indicating that only GET requests are supported.
4. Sending the Response
try {
const filePath = htmlFilePath + htmlFile;
const fileData = await fs.readFile(filePath);
socket.write(fileData);
} catch (err) {
socket.write("Error: Requested resource does not exist!");
}
socket.end();
Remember, we set a default path for html files? This is where we use it. So, if htmlFilePath is "./html" and htmlFile (the requested file path) is "/index.html", then the final path will be "./html/index.html".
Then we read the file from the system, and send it back to the client. If the requested file does not exist, we send and error. Though, the protocol originally ends the connection without any error messages.
Finally, we end the connection. As in HTTP/0.9, a new connection has to be made for every request. So, every connection will be closed after the response is sent.
5. End Connection listener
socket.on("end", () => {
console.log("Connection closed!");
});
This is listener for when the connection is ended. It simply logs "Connection closed!" after a connection is closed.
6. Starting the Server
server.listen({ host: "127.0.0.1", port: 3000 }, () => {
console.log("Server started...");
});
The listen method is used to bind the server to a specific network interface and port. It ensures that the server starts accepting incoming client connections.
- host - This binds the server to the loopback address (127.0.0.1), restricting access to local clients only.
- port - This is the port number on which the server listens for incoming connections.
Demo
Connecting with the server using the netcat nc tool and send a simple request.
Request
nc 127.0.0.1 3000
GET /
Response
<!DOCTYPE html>
<html lang="en">
<head>
<title>Greetings</title>
</head>
<body>
<h1>Hello!</h1>
</body>
</html>
Conclusion
And, that's it! The HTTP Server is done. Of course, there's so much you can do with it. you could extend this server to support HTTP/1.0 or even add logging to track requests.
The full codebase is available on my GitHub Repo.
Stay tuned for the next blog!
๐ HAPPY NEW YEAR!!! ๐