http is a set of rules that computers use to communicate over the internet. when a client wants something, like a webpage, it sends an http request to the server and the server processes the request, finds the resource, and sends an http response back over tcp.
it all boils down to two things: http requests and http responses.
these are the messages sent back and forth and formatted in a specific way so both sides understand each other.
an http request has three main parts:
-
request line: says what the client wants. it includes the method, the path, and the http version.
-
headers: extra info, like the browser type or what formats the client accepts.
-
body (optional): data sent with the request.
example of a valid http request:
GET /echo/hello HTTP/1.1
Host: localhost:4221
User-Agent: curl/7.81.0
Accept: text/plain
this is a GET request asking for the /echo/hello
path. the headers say it’s coming from localhost and curl. there’s no body
here since it’s a GET request.
server reads this and decide what to send back.
an http response is what the server sends back to answer the client’s request. it also has three parts:
-
status line: tells the client if the request worked, with a status code (like 200 for success or 404 for not found) and a short description.
-
headers: extra info, like the type of content or its size.
-
body (optional): the actual content, like a webpage’s text or a file’s data.
example of an http response:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 5
hello
this response says the request was successful, the content is plain text, and it’s 5 characters long. the body contains the word hello.
when i set out to build the http server, i needed to figure out the bare minimum to make it work.
i decided to create an HttpHelper
class because parsing http requests and crafting responses felt like repetitive tasks that could get messy fast.
every request needed to be broken down into parts like the method or path, and every response needed proper formatting with status codes and headers.
the HttpHelper
class handles two main jobs:
-
it takes the raw data from a client and splits it into useful pieces that then i can work with, like the url path or headers.
-
it creates properly formatted responses with status codes, headers and content.
for example when a browser sends a request like GET /echo/hello
, HttpHelper
figures out the path and lets my server decide what to send back.
then it packages the response (like 200 OK with the word hello) in a way the browser understands.
the goal is to create a server that could handle basic web requests, like fetching a webpage or saving a file without relying on third party frameworks.
here’s the key requirements:
listening for connections
the http server needs to listen for incoming client requests over the internet. since http runs on tcp, i used nodejs’s net
module to create a tcp server that listens on a port, mine runs on 4221:
const server = net.createServer((socket) => {
// handle incoming connections
});
server.listen(4221, "localhost");
now i have a server that listens for connections on localhost:4221. each connection creates a socket, which i use to process requests.
parsing http requests
clients send http requests with details like the method, url path, headers and sometimes a body. i needed to parse these requests to understand what the client wanted.
parseBuffer(str: string): Request {
const [_, body] = str.split(CRLF + CRLF);
const [reqLine, ...rest] = _.split(CRLF);
const [method, pathname, protocol] = reqLine.split(" ");
const headers = new Map<string, string>();
for (const line of rest) {
const index = line.indexOf(":");
if (index !== -1) {
const key = line.slice(0, index).trim();
const value = line.slice(index + 1).trim();
headers.set(key, value);
} else {
headers.set("Request-Line", line);
}
}
return {
method,
path: pathname.split("/").filter(Boolean),
protocol,
headers: headers,
body,
};
}
my parseBuffer
method splits the raw request string into its parts (method, path, headers, body). For example, a request like GET /echo/hello HTTP/1.1
gets broken down:
{
"method": "GET",
"path": ["echo", "hello"],
"protocol": "HTTP/1.1",
"headers": {
"Host": "localhost:4221",
"User-Agent": "curl/7.81.0",
"Accept": "text/plain"
},
"body": null
}
so I can process the /echo/hello path.
sending http responses
once i parsed the request, i needed to send back a proper http response with a status code, headers, and a body.
generateResponse(options: ResponseOptions): {
headers: string;
body: Uint8Array | string;
} {
const { status, contentType, contentLength, body = "", headers } = options;
let _headers = `HTTP/1.1 ${status} ${statusMessages[status]}${CRLF}`;
let _body: Uint8Array | string = body;
let shouldCompress = false;
if (contentType) _headers += `Content-Type: ${contentType}${CRLF}`;
if (headers) {
Array.from(headers, ([header, value]) => ({ header, value })).forEach(
(item) => {
switch (item.header) {
case "Accept-Encoding": {
const encodings = item.value
.split(",")
.filter((i) => supportedEncodings.includes(i.trim()))
.join(", ")
.trim();
if (encodings.length) {
shouldCompress = true;
_headers += `Content-Encoding: ${encodings}${CRLF}`;
}
break;
}
default: {
_headers += `${item.header}: ${item.value}${CRLF}`;
break;
}
}
},
);
}
if (shouldCompress) {
_body = new Uint8Array(gzipSync(new TextEncoder().encode(body)));
_headers += `Content-Length: ${_body.length}${CRLF}`;
} else if (contentLength !== undefined)
_headers += `Content-Length: ${_body.length}${CRLF}`;
_headers += CRLF;
return {
headers: _headers,
body: _body,
};
}
my generateResponse
function builds a response with the right status and headers. for example, a 200 OK response with text content might look like:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 5
hello.
handling routes
the server needs to respond differently based on the url path, like /
for a homepage or /echo
to echo back data. i used a simple switch statement to route requests to the right logic.
const base = req.path[0];
const param = req.path[1];
switch (base) {
case undefined: {
httpHelper.socket.write({ status: 200, headers: req.headers });
break;
}
case "echo": {
httpHelper.socket.write({
status: 200,
contentType: "text/plain",
contentLength: param.length,
body: param,
});
break;
}
}
i checked the first part of the path like echo
in /echo/abc
and sent back the appropriate response, like echoing the string abc
for /echo/abc
.
supporting file operations
i wanted my server to handle file requests, like serving a file’s contents for GET /files/filename
or saving data for POST
.
I used nodejs’s fs
module to read files for GET requests, checking if the file exists and sending a 404 if it doesn’t.
this is basically it! it’s not a fancy server but it handle real requests. if you want to play with it yourself, you can use some curl commands to test what it can do.
try these curl commands (assuming the server is running on localhost:4221 and a directory like /tmp is set with --directory):
root path
this hits the /
path, and the server sends a simple 200 OK response with no body
, just confirming it’s alive.
curl -v http://localhost:4221/
Response:
HTTP/1.1 200 OK
echo
The server echoes back “hello” from the /echo/hello path as plain text.
curl -v http://localhost:4221/echo/hello
Response:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 5
hello
user agent
this returns the User-Agent
header you sent.
curl -v -A "curl/7.81.0" http://localhost:4221/user-agent
Response:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 11
curl/7.81.0
file request (GET):
The server serves the file’s contents, or a 404 if the file doesn’t exist.
curl -v http://localhost:4221/files/test.txt
Response (assuming test.txt exists in /tmp):
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: <file-size>
<file-content>
if you want the server to close the connection after a response, send a request with a Connection: close
header.