Step-by-Step Guide to Setting Up Your Own HTTP Server with JavaScript

Ankit MishraAnkit Mishra
12 min read

So I was completing this challenge from CODECRAFTERS, and thought why not Document whole journey and I think It will help me understanding everything in a very better manner.

This is Step 1: Binding to a Port

Before Starting anything, You might ask, hey Ankit, What is this Bind to a PORT? What Does this even me?

So let's understand it this way

Imagine you're running a business that provides some service (like a restaurant or a store). The first step in setting up your business is to find a physical location or address where customers can come and access your service.

In the context of creating an HTTP server, binding to a port is like finding that physical address for your business.

When you create an HTTP server using Node.js (or any other programming language), you need to tell the server which "address" (combination of IP address and port number) it should listen on for incoming requests from clients (web browsers, mobile apps, etc.).

The IP address is like the street address or the city where your business is located. It identifies the specific machine (computer or server) where your HTTP server is running.

The port number is like the suite number or the floor number within that building or address. It helps distinguish your service (the HTTP server) from other services that might be running on the same machine.

So, when you bind your HTTP server to a specific IP address and port number, you're essentially saying, "My server will be located at this address (IP) and will operate on this floor (port number)."

For example, if you bind your HTTP server to localhost (IP address 127.0.0.1) and port 3000, you're telling it to listen for incoming requests at the address 127.0.0.1:3000.

Now, The Code for Binding to a Port

const net = require("net");


console.log("Logs from your program will appear here!");

// Btw This code below was pre-written on codecrafters repo which i cloned
const server = net.createServer((socket) => {
  socket.on("close", () => {
    socket.end();
    server.close();
  });
});

server.listen(4221, "localhost");

uffffffffff!!!! what is this all written Ankit?? Can you explain it please?

Yes I am there to help you, don't worry...

Now let's break the code line by line

const net = require("net");
๐Ÿ’ก
The Above code is very simple and basic, we are importing a module from Node.js core modules. net module is used to create TCP servers and clients
const server = net.createServer((socket) => {
  socket.on("close", () => {
    socket.end();
    server.close();
  });
});
๐Ÿ’ก
In The Above code we are creating a server , using net module and it's method createServer which takes a callback function and it allows you to establish a listening endpoint on your system that can accept incoming connections from clients (other applications or devices). For now, we're just setting up a listener for the "close" event on the client socket, which will end the connection and close the server when the client disconnects.
server.listen(4221, "localhost");
// This line is self explainatory, we are setting our server to listen on 
// localhost:4221 (address)

Step 2: Respond with 200

In this step, we'll respond to a HTTP request with a 200 OK response.

The Task is to:

  • Accept a TCP connection

  • Read data from the connection (we'll get to parsing it in later stages)

  • Respond with HTTP/1.1 200 OK\r\n\r\n

So how to do that? Now we will go into it step-by-step

  1. We created a TCP connection in Step 1(check above), now the two remaining steps are reading a data and then responding according to it.

  2. so how to that?

     socket.on("data" , () => {
         socket.write("HTTP/1.1 200 OK\r\n\r\n");
       })
    
  • socket.on("data" , ()=>{}) this method here, receives the data from the connection and then calls a callback function , socket.write() which sends this specific response which was asked in the step, which specifies the following thing:

    • HTTP/1.1 is protocol version

    • 200 is the status code

    • OK is(according to MDN): A status text. A brief, purely informational, textual description of the status code to help a human understand the HTTP message.

    • \r\n : The MDN defines it as

      CR and LF are control characters or bytecode thatcan be used to mark a line break in a text file.

      • CR = Carriage Return (\r, 0x0D in hexadecimal, 13in decimal) โ€” moves the cursor to the beginning of the line without advancing to the next line.

      • LF = Line Feed (\n, 0x0A in hexadecimal, 10 in decimal) โ€” moves the cursor down to the next line without returning to the beginning of the line.

      A CR immediately followed by a LF (CRLF, \r\n, or0x0D0A) moves the cursor to the beginning of the line and then down to the next line.

      ๐Ÿ’ก
      The first \r\n signifies the end of the status line.
      ๐Ÿ’ก
      The second \r\n signifies the end of the response headers section (which is empty in this case).

Now that We have completed our second step, Let's move forward to Step 3

Step 3 : Respond with 404

To be honest, this step took me a while to pass all the test cases.

  • What are the tasks?

    • In this stage, your program will need to extract the path from the HTTP request.
  • Now, on the site it was given about how a HTTP request looks like:

    •     GET /index.html HTTP/1.1
          Host: localhost:4221
          User-Agent: curl/7.64.1
      

      and all of these lines are separated by \r\n.

    • First we will convert the above req into string and then split the above request in an array(because we need only Line 1 i.e. GET /index.html HTTP/1.1

    • we will use JS method to do it, and will store it in a variable:

      •      const request = data.toString().split("\r\n");
            // The Above request will look like 
            [
              "GET /index.html HTTP/1.1",
              "Host: localhost:4221",
              "User-Agent: curl/7.64.1"
            ]
        
      • now we need only line 1, so we will use another JS method and store it in a variable:

        •     const path = request[0].split(" ")[1]; 
              // This will give us 
              -   /index.html
          

And this is what we need, the path.

  • Now we will check if the path is / or not and give the response accordingly

    •     if (path === "/") {
                 socket.write("HTTP/1.1 200 OK\r\n\r\n");
               } else {
                 socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
               }
      
  • The error I was getting and I got lost into that, I wasn't ending the current socket connection, so in the last of server we need to specify socket.end() .

    • At this moment, everything was correct, but, I was mistaking "data", which I was giving as the buffer to the server.on("data", callback). I need to pass the data as parameter in the callback function.

    • Now the complete code looks like this:

    ```javascript const { error } = require("console"); const net = require("net");

    // You can use print statements as follows for debugging, they'll be visible when running tests. console.log("Logs from your program will appear here!");

// Uncomment this to pass the first stage const server = net.createServer((socket) => { socket.on("close", () => { socket.end(); server.close(); }); socket.on("error", console.error); socket.on("data" , (data) => {

const request = data.toString().split("\r\n"); const path = request[0].split(" ")[1];

if (path === "/") { socket.write("HTTP/1.1 200 OK\r\n\r\n"); } else { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); } socket.end(); })

});

server.listen(4221, "localhost");



## Step 4: **Respond with content**

In this step, your program will need to respond with a body. In the previous stages we were only sending a status code, no body.

The task here is to parse the path from the HTTP request. We will send a random string in the url path you will need to parse that string and then respond with the parsed string (only) in the response body.

The tester will send you a request of the form `GET /echo/<a-random-string>`.

Your program will need to respond with a 200 OK response. The response should have a content type of `text/plain`, and it should contain the random string as the body.

As an example, here's a request you might receive:

```javascript
GET /echo/abc HTTP/1.1
Host: localhost:4221
User-Agent: curl/7.64.1

And here's the response you're expected to send back:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 3

abc

So from Step 3, we had the complete path.

Now we will split that path using split() method, and we will split it

 const stringpassed = path.split("/echo/")[1]
// It splits the path variable using the string "/echo/" as a separator.
// It then tries to grab the second element (index 1) of the resulting array.
// We will get the string that is after "/echo/{string}"

After this we will send the response that was expected, but before sending we will check if the "/echo/" exist or not.

 else if (path.startsWith("/echo/")) {
       let ans = "";
       ans += "HTTP/1.1 200 OK\r\n";
       ans += "Content-Type:text/plain\r\n";
       ans += `content-Length:${stringpassed.length}\r\n\r\n`;
       ans += stringpassed;
       socket.write(ans);
     }

And now our Step 4 is done and dusted.

Step 5: Parse Headers

This is somehow similar to Step 4, we need to create a new variable to store the header value

const header = request[2].split("User-Agent: ")[1];

and then pass the

else if(path === "/user-agent"){
         let ans = "";
         ans += "HTTP/1.1 200 OK\r\n";
         ans += "Content-Type:text/plain\r\n";
         ans += `content-Length:${header.length}\r\n\r\n`;
         ans += header;
         socket.write(ans);
     }

and that's it, we are done with Step-5.

Up until now the complete code looks like

const { error } = require("console");
const net = require("net");

// You can use print statements as follows for debugging, they'll be visible when running tests.
console.log("Logs from your program will appear here!");


// Uncomment this to pass the first stage
const server = net.createServer((socket) => {
  socket.on("close", () => {
    socket.end();
    server.close();
  });
   socket.on("error", console.error);
  socket.on("data" , (data) => {

    const request = data.toString().split("\r\n");
    const path = request[0].split(" ")[1];
    const header = request[2].split("User-Agent: ")[1];
    const stringpassed = path.split("/echo/")[1]

     if (path === "/") {
       socket.write("HTTP/1.1 200 OK\r\n\r\n");
     }
     else if (path.startsWith("/echo/")) {
       let ans = "";
       ans += "HTTP/1.1 200 OK\r\n";
       ans += "Content-Type:text/plain\r\n";
       ans += `content-Length:${stringpassed.length}\r\n\r\n`;
       ans += stringpassed;
       socket.write(ans);
     }
     else if(path === "/user-agent"){
         let ans = "";
         ans += "HTTP/1.1 200 OK\r\n";
         ans += "Content-Type:text/plain\r\n";
         ans += `content-Length:${header.length}\r\n\r\n`;
         ans += header;
         socket.write(ans);
     }

     else {
       socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
     }
     socket.end();
  })

});

server.listen(4221, "localhost");

Step-6: Concurrent connections

So this step was very easy, till now we were closing after calling server.close(), because server.close() to stops the server from accepting new connections.

So remove it from the code.

 socket.on("close", () => {
    socket.end();
    server.close();
  });
// it should be now
 socket.on("close", () => {
    socket.end();
    // no socket.close()
  });

Step-7 : Get A File and Post a File

Sure, here's the final part of the blog explaining the problem statement and the code implementation:

Problem Statement Revisited

In the previous sections, we discussed the requirements for implementing a simple file server that can handle GET requests for retrieving files and POST requests for uploading files. Specifically, the file server program needs to accept a --directory flag when executed, which specifies the directory where files should be served from or uploaded to.

For GET requests of the format GET /files/<filename>, the server should respond with the contents of the requested file if it exists in the specified directory. The response should have a 200 OK status code, the Content-Type header set to application/octet-stream, and the file contents as the response body. If the requested file doesn't exist, the server should respond with a 404 Not Found status code.

For POST requests to the /files route, the server should handle file uploads. The uploaded file should be saved in the specified directory, and the server should respond with a 201 Created status code upon successful upload.

Code Implementation

Here's the final code implementation that addresses the problem statement:

const { error } = require("console");
const net = require("net");
const fs = require("fs");
const path = require("path");
const filePath = process.argv[3];

const server = net.createServer((socket) => {
  socket.on("close", () => {
    socket.end();
  });

  socket.on("error", console.error);

  socket.on("data", async (data) => {
    const request = data.toString().split("\r\n");
    const pathRequest = request[0].split(" ");
    const path = request[0].split(" ")[1];
    const header = request[2].split("User-Agent: ")[1];
    const stringpassed = path.split("/echo/")[1];

    if (path === "/") {
      socket.write("HTTP/1.1 200 OK\r\n\r\n");
    } else if (path.startsWith("/echo/")) {
      let ans = "";
      ans += "HTTP/1.1 200 OK\r\n";
      ans += "Content-Type:text/plain\r\n";
      ans += `content-Length:${stringpassed.length}\r\n\r\n`;
      ans += stringpassed;
      socket.write(ans);
    } else if (path === "/user-agent") {
      let ans = "";
      ans += "HTTP/1.1 200 OK\r\n";
      ans += "Content-Type:text/plain\r\n";
      ans += `content-Length:${header.length}\r\n\r\n`;
      ans += header;
      socket.write(ans);
    } else if (pathRequest[1].includes("/files/")) {
      if (request[0].includes("POST")) {
        const fileName = pathRequest[1].replace("/files/", "");
        const file = path.join(filePath, fileName);
        const content = request[request.length - 1];
        await fs.promises.writeFile(file, content);
        socket.write("HTTP/1.1 201 Created\r\n\r\n");
      }

      const fileName = pathRequest[1].replace("/files/", "");
      const file = path.join(filePath, fileName);
      if (fs.existsSync(file)) {
        const content = await fs.promises.readFile(file);
        socket.write(
          `HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ${content.length}\r\n\r\n${content}`
        );
      } else {
        socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
        socket.end();
      }
    } else {
      socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
    }
    socket.end();
  });
});

server.listen(4221, "localhost");

But what is written in the above code?

  1. We import the necessary modules: console for logging errors, net for creating the server, fs for file system operations, and path for working with file paths.

  2. We get the directory path from the command-line arguments (process.argv[3]).

  3. We create a new TCP server using net.createServer.

  4. In the server callback function, we handle various events:

    • close: We end the socket when the client closes the connection.

    • error: We log any errors that occur.

    • data: We handle incoming data from the client.

  5. For incoming data, we parse the request and extract the requested path, header, and any string passed in the request body.

  6. We check the requested path and handle different cases:

    • /: We respond with a 200 OK status code and an empty body.

    • /echo/...: We respond with a 200 OK status code, the Content-Type set to text/plain, the Content-Length set to the length of the string passed in the request body, and the string itself as the response body.

    • /user-agent: We respond with a 200 OK status code, the Content-Type set to text/plain, the Content-Length set to the length of the user agent string, and the user agent string as the response body.

    • /files/...:

      • If it's a POST request, we extract the file name from the path, construct the file path by joining the provided directory and the file name, and write the request body to the file using fs.promises.writeFile. We respond with a 201 Created status code upon successful file upload.

      • If it's a GET request, we extract the file name from the path, construct the file path by joining the provided directory and the file name, and check if the file exists using fs.existsSync. If the file exists, we read its contents using fs.promises.readFile and respond with a 200 OK status code, the Content-Type set to application/octet-stream, the Content-Length set to the length of the file contents, and the file contents as the response body. If the file doesn't exist, we respond with a 404 Not Found status code.

    • For any other path, we respond with a 404 Not Found status code.

  7. After sending the response, we end the socket connection.

  8. Finally, we start the server and listen on port 4221 and localhost.

By following this code and the explanations provided throughout the blog, you should be able to create a fully functional file server that meets the specified requirements. Do checkout the Challenge by CodeCrafters

Here's the complete code on github:

0
Subscribe to my newsletter

Read articles from Ankit Mishra directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ankit Mishra
Ankit Mishra

๐Ÿค” Exploring new technologies and developing software solutions and quick hacks. ๐ŸŽ“ Studying Electronics and Communication Engineering at Chandigarh Engineering College. ๐ŸŒฑ Learning more about Full stack web development, Data Structure and Algorithm. โœ๏ธ Pursuing Blog Writing as hobby.