DEV Community

Cover image for Building a HTTP Server from scratch: Understanding Request & Response
Sebastien Filion
Sebastien Filion

Posted on

Building a HTTP Server from scratch: Understanding Request & Response

Oh, hey there!

I'm glad you made it to this second post of the "Build the System: HTTP server" series.
This post is dedicated to decoding HTTP requests and encoding the response. I will also, offer a reliable way to test
our code for a more resilient project.
If you haven't read the first post of the series yet, I think you might want to. Just click here to read it.
I'll wait patiently for your return.

This article is a transcript of a Youtube video I made.

Alright, now that I know we're all on the same page, let's write some code.
For this project, I will use JavaScript and Deno, but the concepts don't change no matter what language or runtime you
are using.
Also one last disclaimer: this project first aim is to educate it will in no way be complete or the most performant!
I will discuss specifically the improvements we can bring to make it more performant and I will go through various
iteration with that in mind. At the end of the project, if there are part worth salvaging, I will replace the essential
parts.
All that to say, just enjoy the ride.

The first thing that I need to do is to announce listening on a port.
The incoming connection will be represented by a Readable/Writable resource.
First, I will need to read from the resource a specific amount of bytes. For this example, I will read around a KB.
The variable xs is a Uint8Array. I already wrote an article about this but long story short, a Typed Array is an array
that can only hold a specific amount of bit per item. In this case we need 8 bits (or one byte) array because you need 8 bits
to encode a single UTF-8 character.

šŸ™ You will find the code for this post here: https://github.com/i-y-land/HTTP/tree/episode/02

As a convenience, I will decode the bytes to a string and log the result to the console.
Finally, I will encode a response and write it to the resource.

// scratch.js
for await (const connection of Deno.listen({ port: 8080 })) {
  const xs = new Uint8Array(1024);
  await Deno.read(connection.rid, xs);

  console.log(new TextDecoder().decode(xs));

  await Deno.write(
    connection.rid,
    new TextEncoder().encode(
      `HTTP/1.1 200 OK\r\nContent-Length: 12\r\nContent-Type: text/plain\r\n\r\nHello, World`
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, I will run the code:

deno run --allow-net="0.0.0.0:8080" scratch.js
Enter fullscreen mode Exit fullscreen mode

On a different terminal session I can use curl to send an HTTP request.

curl localhost:8080
Enter fullscreen mode Exit fullscreen mode

On the server's terminal, we can see the request, and on the client's terminal we can see the response's body:
"Hello, World"

Great!

To get this started on the right foot, I will refactor the code into a function named serve in a file called
server.js. This function will take a listener and a function that takes a Uint8Array and returns a Promise of a
Uint8Array!

// library/server.js
export const serve = async (listener, f) => {
  for await (const connection of listener) {
    const xs = new Uint8Array(1024);
    const n = await Deno.read(connection.rid, xs);

    const ys = await f(xs.subarray(0, n));
    await Deno.write(connection.rid, ys);
  }
};
Enter fullscreen mode Exit fullscreen mode

Notice that the read function returns the number of byte that was read. So we can use the subarray method to pass
a lense on the appropriate sequence to the function.

// cli.js
import { serve } from "./server.js";

const $decoder = new TextDecoder();
const decode = $decoder.decode.bind($decoder);
const $encoder = new TextEncoder();
const encode = $encoder.encode.bind($encoder);

if (import.meta.main) {
  const port = Number(Deno.args[0]) || 8080;
  serve(
    Deno.listen({ port }),
    (xs) => {
      const request = decode(xs);
      const [requestLine, ...lines] = request.split("\r\n");
      const [method, path] = requestLine.split(" ");
      const separatorIndex = lines.findIndex((l) => l === "");
      const headers = lines
        .slice(0, separatorIndex)
        .map((l) => l.split(": "))
        .reduce(
          (hs, [key, value]) =>
            Object.defineProperty(
              hs,
              key.toLowerCase(),
              { enumerable: true, value, writable: false },
            ),
          {},
        );

      if (method === "GET" && path === "/") {
        if (
          headers.accept.includes("*/*") ||
          headers.accept.includes("plain/text")
        ) {
          return encode(
            `HTTP/1.1 200 OK\r\nContent-Length: 12\r\nContent-Type: text/plain\r\n\r\nHello, World`,
          );
        } else {
          return encode(
            `HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n`,
          );
        }
      }

      return encode(
        `HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n`,
      );
    },
  )
    .catch((e) => console.error(e));
}
Enter fullscreen mode Exit fullscreen mode

Now that I have an way to parse the headers, I think it's a good opportunity to officialize all of this and write a new
utility function and the appropriate tests.

// library/utilities.js

export const parseRequest = (xs) => {
  const request = decode(xs);
  const [h, body] = request.split("\r\n\r\n");
  const [requestLine, ...ls] = h.split("\r\n");
  const [method, path] = requestLine.split(" ");
  const headers = ls
    .map((l) => l.split(": "))
    .reduce(
      (hs, [key, value]) =>
        Object.defineProperty(
          hs,
          key.toLowerCase(),
          { enumerable: true, value, writable: false },
        ),
      {},
    );

  return { method, path, headers, body };
};
Enter fullscreen mode Exit fullscreen mode
// library/utilities_test.js

Deno.test(
  "parseRequest",
  () => {
    const request = parseRequest(
      encode(`GET / HTTP/1.1\r\nHost: localhost:8080\r\nAccept: */*\r\n\r\n`),
    );

    assertEquals(request.method, "GET");
    assertEquals(request.path, "/");
    assertEquals(request.headers.host, "localhost:8080");
    assertEquals(request.headers.accept, "*/*");
  },
);

Deno.test(
  "parseRequest: with body",
  () => {
    const request = parseRequest(
      encode(
        `POST /users HTTP/1.1\r\nHost: localhost:8080\r\nAccept: */*\r\n\r\n{"fullName":"John Doe"}`,
      ),
    );

    assertEquals(request.method, "POST");
    assertEquals(request.path, "/users");
    assertEquals(request.headers.host, "localhost:8080");
    assertEquals(request.headers.accept, "*/*");
    assertEquals(request.body, `{"fullName":"John Doe"}`);
  },
);
Enter fullscreen mode Exit fullscreen mode

Now that I have a parseRequest function, logically I need a new function to stringify the response...

// library/utilities.js

import { statusCodes } from "./status-codes.js";

export const normalizeHeaderKey = (key) =>
  key.replaceAll(/(?<=^|-)[a-z]/g, (x) => x.toUpperCase());

export const stringifyHeaders = (headers = {}) =>
  Object.entries(headers)
    .reduce(
      (hs, [key, value]) => `${hs}\r\n${normalizeHeaderKey(key)}: ${value}`,
      "",
    );

export const stringifyResponse = (response) =>
  `HTTP/1.1 ${statusCodes[response.statusCode]}${
    stringifyHeaders(response.headers)
  }\r\n\r\n${response.body || ""}`;
Enter fullscreen mode Exit fullscreen mode
// library/utilities_test.js

Deno.test(
  "normalizeHeaderKey",
  () => {
    assertEquals(normalizeHeaderKey("link"), "Link");
    assertEquals(normalizeHeaderKey("Location"), "Location");
    assertEquals(normalizeHeaderKey("content-type"), "Content-Type");
    assertEquals(normalizeHeaderKey("cache-Control"), "Cache-Control");
  },
);

Deno.test(
  "stringifyResponse",
  () => {
    const body = JSON.stringify({ fullName: "John Doe" });
    const response = {
      body,
      headers: {
        ["content-type"]: "application/json",
        ["content-length"]: body.length,
      },
      statusCode: 200,
    };
    const r = stringifyResponse(response);

    assertEquals(
      r,
      `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 23\r\n\r\n{"fullName":"John Doe"}`,
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

So now, we have everything we need to refactor our handler function and make it more concise and declarative.

import { serve } from "./library/server.js";
import {
  encode,
  parseRequest,
  stringifyResponse,
} from "./library/utilities.js";

if (import.meta.main) {
  const port = Number(Deno.args[0]) || 8080;
  serve(
    Deno.listen({ port }),
    (xs) => {
      const request = parseRequest(xs);

      if (request.method === "GET" && request.path === "/") {
        if (
          request.headers.accept.includes("*/*") ||
          request.headers.accept.includes("plain/text")
        ) {
          return Promise.resolve(
            encode(
              stringifyResponse({
                body: "Hello, World",
                headers: {
                  "content-length": 12,
                  "content-type": "text/plain",
                },
                statusCode: 200,
              }),
            ),
          );
        } else {
          return Promise.resolve(
            encode(stringifyResponse({ statusCode: 204 })),
          );
        }
      }

      return Promise.resolve(
        encode(
          stringifyResponse({
            headers: {
              "content-length": 0,
            },
            statusCode: 404,
          }),
        ),
      );
    },
  )
    .catch((e) => console.error(e));
}
Enter fullscreen mode Exit fullscreen mode

So at this we can deal with any simple request effectively. To wrap this up and prepare the project for future iteration,
I will add a test for the serve function. Obviously, this function is impossible to keep pure and to test without
complex integration tests -- which I keep for later.
An actual connection is a bit figety so I thought I could mock it using a file as the resource since files are
readable/wriatable.
The first thing I did is to write a function to factorize an async iterator and purposely make it break after the first
iteration.
After that, I create a file with read/write permissions. With that, I can write the HTTP request, then move the cursor
back to the beginning of the file for the serve function to read back. Within the handler function, I make some
assertions on the request for sanity's sake, then flush the content and move the cursor back to the beginning before
writing a response.
Finally, I can move the cursor back to the beginning one last time, to read the response, make one last assertion then
cleanup to complete the test.

// library/server_test.js

import { assertEquals } from "https://deno.land/std@0.97.0/testing/asserts.ts";

import { decode, encode, parseRequest } from "./utilities.js";
import { serve } from "./server.js";

const factorizeConnectionMock = (p) => {
  let i = 0;

  return {
    p,
    rid: p.rid,
    [Symbol.asyncIterator]() {
      return {
        next() {
          if (i > 0) {
            return Promise.resolve({ done: true });
          }
          i++;
          return Promise.resolve({ value: p, done: false });
        },
        values: null,
      };
    },
  };
};

Deno.test(
  "serve",
  async () => {
    const r = await Deno.open(`${Deno.cwd()}/.buffer`, {
      create: true,
      read: true,
      write: true,
    });

    const xs = encode(`GET /users/1 HTTP/1.1\r\nAccept: */*\r\n\r\n`);

    await Deno.write(r.rid, xs);

    await Deno.seek(r.rid, 0, Deno.SeekMode.Start);

    const connectionMock = await factorizeConnectionMock(r);

    await serve(
      connectionMock,
      async (ys) => {
        const request = parseRequest(ys);

        assertEquals(
          request.method,
          "GET",
          `The request method was expected to be \`GET\`. Got \`${request.method}\``,
        );
        assertEquals(
          request.path,
          "/users/1",
          `The request path was expected to be \`/users/1\`. Got \`${request.path}\``,
        );
        assertEquals(
          request.headers.accept,
          "*/*",
        );

        await Deno.ftruncate(r.rid, 0);
        await Deno.seek(r.rid, 0, Deno.SeekMode.Start);

        const body = JSON.stringify({ "fullName": "John Doe" });

        return encode(
          `HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: ${body.length}\r\n\r\n${body}`,
        );
      },
    );

    await Deno.seek(r.rid, 0, Deno.SeekMode.Start);

    const zs = new Uint8Array(1024);
    const n = await Deno.read(r.rid, zs);

    assertEquals(
      decode(zs.subarray(0, n)),
      `HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 23\r\n\r\n{"fullName":"John Doe"}`,
    );

    Deno.remove(`${Deno.cwd()}/.buffer`);
    Deno.close(r.rid);
  },
);
Enter fullscreen mode Exit fullscreen mode

At this point we have a good base to work from. Unfortunately our server is a bit limitted, for example, if a request
is larger than a KB, we'd be missing part of the message, that means no upload or download of medium size files.
That's what I plan to cover on the next post. This will force us to be a little bit more familiar with
manipulation of binary bytes.

At any rate, if this article was useful to you, hit the like button, leave a comment to let me know or best of all,
follow if you haven't already!

Ok bye now...

Top comments (0)