Home
🌐

Let’s make a web server

The net module, which ships with Node.js, can be used to create TCP servers and clients. Today we’ll make a server, use it, and then upgrade that server to a web server which can serve websites and web applications to your browser.
We can import the net module and use createServer to create a server, then listen to attach it to a port.
const net = require("net");
const server = net.createServer();
server.listen(8080, () => {
  console.log(
    "Started server on",
    server.address()
  );
});
We can run the server from the command line.
$ node server.js
Started server on { address: '::', family: 'IPv6', port: 8080 }
We can attempt to visit the server by visiting localhost:8080, but we’ll find nothing happens. In fact, our server doesn’t even output anything to the console.
A loading indicator for http://localhost:8080 in the Arc web browser
A loading indicator for http://localhost:8080 in the Arc web browser
Instead of a web browser, we can attempt the nc(1) “netcat” command from another terminal.
$ nc localhost 8080
But sadly we’ll see nothing but an empty line in the client (netcat) and nothing on the server (node server.js).
Let’s fix this. createServer accepts a callback which it will call with a “socket” on new connections. We can log some info on connection, and listen for an “end” event from the socket.
const net = require("net");
const server = net.createServer(
  (socket) => {
    console.log("Client connected");
    socket.on("end", () => {
      console.log(
        "Client disconnected"
      );
    });
  }
);
server.listen(8080, () => {
  console.log(
    "Started server on",
    server.address()
  );
});
We can start our server back up and access it from nc(1) again. We’ll find there’s no output, so we can kill the command with Control-C.
$ nc localhost 8080
^C
$
In the other terminal, we notice our server logged some information. Still not doing a whole lot, but it’s something.
$ node server.js
Started server on { address: '::', family: 'IPv6', port: 8080 }
Client connected
Client disconnected
On connection, we can use socket.write to send data to clients. We can then start our server back up, connect with nc(1), and see some output.
$ nc localhost 8080
Hello, world!
^C
$
If we attempt to access our server from a web browser, we no longer load forever, but instead error out quickly.
Chromium error page with ERR_INVALID_HTTP_RESPONSE
Chromium error page with ERR_INVALID_HTTP_RESPONSE
We can receive data from clients using the “data” event. Instead of “Hello, world!” we can prompt the client for some information.
const net = require("net");
const server = net.createServer(
  (socket) => {
    socket.write("Enter a number> ");

    /* Listen for data */
    socket.on("data", (data) => {
      console.log(data.toString());
    });
  }
);
server.listen(8080, () => {
  console.log(
    "Started server on",
    server.address()
  );
});
We can start our server back up and connect with nc(1), seeing our prompt.
$ nc localhost 8080
Enter a number> 15
Upon pressing enter, the client moves down a line and server prints the number.
$ node server.js
Started server on { address: '::', family: 'IPv6', port: 8080 }
15
We can send data back to our client with subsequent socket.write() calls.
const net = require("net");
const server = net.createServer(
  (socket) => {
    socket.write("Enter a number> ");

    /* Listen for data */
    socket.on("data", (data) => {
      console.log(data.toString());
      
      /* Use the data */
      const number = parseInt(
        data.toString()
      );
      if (number % 2 === 0) {
        socket.write(
          `${number} is even\n`
        );
      } else {
        socket.write(
          `${number} is odd\n`
        );
      }
    });
  }
);
server.listen(8080, () => {
  console.log(
    "Started server on",
    server.address()
  );
});
We can start our server back up and connect with nc(1). Upon pressing enter, we now receive feedback. It’s not much but it’s something.
$ nc localhost 8080
Enter a number> 15
15 is odd
We’ve now made an interactive TCP server. You can extend this to all sorts of single-player experiences, or storing data in memory that can be accessed from several clients (think: chat rooms).
However, if we attempt to access our odd/even server from a web browser, we still get an error.
But upon navigating back to our server, we see our trusty console.log(data.toString()) discovered gold.
$ node server.js
Started server on { address: '::', family: 'IPv6', port: 8080 }
GET / HTTP/1.1
Host: localhost:8080
Connection: keep-alive
sec-ch-ua: "Not(A:Brand";v="24", "Chromium";v="122"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
DNT: 1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
... more output ...
(empty line)
Just as the client sent “15” upon typing 15 and pressing enter, the web browser entered in… a bunch of stuff. We’ll need to learn to speak its language.
We can remove our number game and replace it with a special sequence of characters: HTTP/1.1 200 OK.
const net = require("net");
const server = net.createServer(
  (socket) => {
    /* Listen for data */
    socket.on("data", (data) => {
      console.log(data.toString());

      /* Let's speak HTTP */
      socket.write(
        "HTTP/1.1 200 OK\r\n"
      );
      socket.end();
    });

    socket.on("error", (error) => {
      console.error(error);
    });
  }
);
server.listen(8080, () => {
  console.log(
    "Started server on",
    server.address()
  );
});
Spinning up our server and accessing it from localhost:8080, we’re greeted with the following:
Chromium’s (no content) state.
Chromium’s (no content) state.
An empty page with no error. It’s not much but it’s something.
In addition to HTTP/1.1 200 OK, we can send some more information.
socket.write(
  "HTTP/1.1 200 OK\r\n"
);
socket.write(
  "Content-Type: text/plain\r\n"
);
socket.write("\r\n");
socket.write("Hello, world!");
socket.end();
Now we are greeted with the following:
Chromium’s plaintext state.
Chromium’s plaintext state.
We’ve now made an interactive HTTP server.
We can spruce up our data just a tiny bit more to send ✨ HTML ✨
/* Let's speak HTML */
socket.write(
  "HTTP/1.1 200 OK\r\n"
);
socket.write(
  "Content-Type: text/html\r\n"
);
socket.write("\r\n");
socket.write(
  "<html><body>" +
    "<h1>Welcome to my webpage!</h1>" +
    "</body></html>"
);
socket.end();
Upon which we are greeted with … 🥁
Chromium displaying “Welcome to my webpage!” in a heading level 1 tag.
Chromium displaying “Welcome to my webpage!” in a heading level 1 tag.
We’ve now made an interactive web server. You can extend this to returning any information from your server like the current time, weather, filesystem, etc. In this HTML string you can also include style and script tags.
For our last exercise, let’s talk briefly about URLs, using the address bar.
The URI spec is quite long and has a storied history, but today we’ll focus on what’s commonly referred to as a “path” on a website – a sequence of slashes and strings.
If we visit localhost:8080/hello/world.html, we’ll be greeted with the same output.
The address bar reads http://localhost:8080/hello/world.html but the output remains the same
The address bar reads http://localhost:8080/hello/world.html but the output remains the same
Our server output, however, contains some new information.
$ node server.js
Started server on { address: '::', family: 'IPv6', port: 8080 }
GET /hello/world.html HTTP/1.1
Host: localhost:8080
Connection: keep-alive
sec-ch-ua: "Not(A:Brand";v="24", "Chromium";v="122"
Instead of GET / HTTP/1.1, we are greeted with GET /hello/world.html HTTP/1.1. We can write some JavaScript to make use of this string.
socket.on("data", (data) => {
  const request = data.toString();
  console.log(request);

  const [header, body] =
    request.split("\r\n\r\n");
  const headers =
    header.split("\r\n");
  const [method, path, version] =
    headers[0].split(" ");
  ...
});
For debugging purposes, we can include “path” in our output.
socket.write(
  "HTTP/1.1 200 OK\r\n"
);
socket.write(
  "Content-Type: text/html\r\n"
);
socket.write("\r\n");
socket.write(`
  <html><body>
    <h1>${method} ${path}</h1>
  </body></html>
`);
socket.end();
Upon which we are greeted with:
Evidence that our web server can extract the path requested by the browser as it outputs GET /hello/world.html
Evidence that our web server can extract the path requested by the browser as it outputs GET /hello/world.html
We’ve stood on the shoulders of Node.js’s net module to build a webserver. Going deeper, we can use this path for all sorts of things:
I hope this offers a small glimpse of what your browser is doing after you enter a URL, and a way to get the browser to display the content you want.