Building a Basic HTTP Server with Node.js

December 31, 2024 (2w ago)

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/";
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);
});

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.

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!!! 🎉