Chapter 06
Understanding HTTP by Hand
Parse raw HTTP from a TCP stream, build responses manually, serve HTML and JSON - then see how Node's built-in http module does the same thing for us.
The Anatomy of HTTP
HTTP is a text protocol layered on TCP. Every request and response follows the same structure:
Request:
METHOD PATH HTTP/1.1\r\n
Header-Name: Header-Value\r\n
\r\n
optional body
Response:
HTTP/1.1 STATUS REASON\r\n
Header-Name: Header-Value\r\n
\r\n
bodyLines end with \r\n (CRLF). Headers end with a blank line (\r\n\r\n). The body follows.
Parsing Raw HTTP from TCP
We build on Chapter 5's TCP server. Instead of echoing text, we parse HTTP requests and send proper HTTP responses:
interface HttpRequest {
method: string;
path: string;
headers: Map<string, string>;
body: string | undefined;
}
function parseRequest(raw: string): HttpRequest | null {
const lines = raw.split("\r\n");
const [method, path] = (lines[0] ?? "").split(" ");
if (!method || !path) return null;
const headers = new Map<string, string>();
let i = 1;
for (; i < lines.length; i++) {
if (lines[i] === "") break; // blank line = end of headers
const colonIdx = lines[i].indexOf(":");
if (colonIdx > 0) {
const key = lines[i].slice(0, colonIdx).trim().toLowerCase();
const value = lines[i].slice(colonIdx + 1).trim();
headers.set(key, value);
}
}
const body = lines.slice(i + 1).join("\r\n") || undefined;
return { method, path, headers, body };
}parseRequest assumes the whole request arrived in one piece. It won't have. TCP is a byte stream, not a message queue: one data event may carry half the headers, or the headers and only part of the body, or two requests at once. A parser that reads whatever happened to turn up will work on localhost and fail in the real world. The fix is to buffer. Keep the received bytes per connection, wait until you have seen \r\n\r\n (end of headers), read Content-Length to learn how many body bytes to expect, and only parse once they have all arrived. The full listing below does exactly that - which is also why the buffer is a Buffer and not a string: Content-Length is counted in bytes, and slicing a string would cut multi-byte characters in half.Building HTTP Responses
interface HttpResponse {
status: number;
reason: string;
headers: Record<string, string>;
body: string;
}
function serializeResponse(res: HttpResponse): string {
let output = `HTTP/1.1 ${res.status} ${res.reason}\r\n`;
output += `Content-Length: ${Buffer.byteLength(res.body)}\r\n`;
output += "Connection: close\r\n";
for (const [key, value] of Object.entries(res.headers)) {
output += `${key}: ${value}\r\n`;
}
output += "\r\n";
output += res.body;
return output;
}Buffer.byteLength(body) is important - it counts bytes, not characters. A UTF-8 emoji like 🎉 is 4 bytes but 1 character. Content-Length must be in bytes.
Serving HTML and JSON
function handleRequest(req: HttpRequest): HttpResponse {
switch (req.path) {
case "/":
return {
status: 200, reason: "OK",
headers: { "Content-Type": "text/html" },
body: "<h1>Hello from TypeScript!</h1>",
};
case "/api/status":
return {
status: 200, reason: "OK",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "running", uptime: process.uptime() }),
};
default:
return {
status: 404, reason: "Not Found",
headers: { "Content-Type": "text/html" },
body: `<h1>404</h1><p>${req.path} not found</p>`,
};
}
}Comparing with Node's http Module
Node.js has a built-in http module that does all the parsing and serialization for you:
import http from "node:http";
const server = http.createServer((req, res) => {
// req.method, req.url, req.headers - already parsed
// res.writeHead(), res.end() - builds the response
if (req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end("<h1>Hello!</h1>");
} else {
res.writeHead(404, { "Content-Type": "text/html" });
res.end("<h1>404</h1>");
}
});
server.listen(8080);The http module parses the request line, headers, and body for you. res.writeHead() and res.end() serialize the response. In production, you'd use this (or a framework like Express/Hono) instead of raw TCP parsing.
We built it by hand to understand the protocol. The rest of this guide uses WebSockets (Chapter 7+), which sit on top of HTTP for the initial handshake.
ws library for WebSockets (Chapter 7), which handles the HTTP upgrade handshake internally. But understanding raw HTTP helps when debugging, reading headers, and building the REST API alongside WebSocket (Chapter 22).Putting It Together
The whole chapter comes down to one decision: when a socket connects, is the peer speaking HTTP or chat? We answer it by looking at the first line. The complete file is src/index.ts on the chapter6 branch; here is the part that multiplexes one port between two protocols.
sniff peeks at the first complete line and matches it against the shape of an HTTP request line. Anything else is a chat client:
// Look at the first complete line. Undefined means it has not arrived yet.
function sniff(conn: Connection): Protocol | undefined {
const newline = conn.pending.indexOf(0x0a);
if (newline === -1) {
return undefined;
}
const firstLine = conn.pending.subarray(0, newline).toString("utf8").replace(/\r$/, "");
return HTTP_REQUEST_LINE.test(firstLine) ? "http" : "chat";
}And the connection handler uses it. A browser or curl sends its request at once; a person at a terminal sends nothing until they type - so a short timeout assumes a human and greets them:
const server = net.createServer((socket) => {
const conn = new Connection(socket, ++sequence);
emit({ type: "system", text: `${conn.id} connected from ${conn.address}`, at: Date.now() });
// A browser or curl sends its request immediately, so we can read it and
// know. A person at a terminal sends nothing until they type - so if the
// line never comes, assume a human and greet them.
const greeting = setTimeout(() => {
if (conn.mode === "unknown") {
startChat(conn);
}
}, GREETING_DELAY_MS);
socket.on("data", (chunk: Buffer) => {
conn.append(chunk);
if (conn.mode === "unknown") {
const detected = sniff(conn);
if (detected === undefined) {
return; // not even one line yet
}
clearTimeout(greeting);
if (detected === "http") {
conn.becomes("http");
} else {
startChat(conn);
}
}
if (conn.mode === "http") {
drainHttp(conn);
return;
}
for (const line of conn.takeLines()) {
const text = parseInput(line);
if (text.length > 0) {
handleLine(conn, text);
}src/index.ts on the chapter6 branch. You are not meant to paste it wholesale - build your own as you follow along, and use the reference to check yourself.Exercise
- Run the server and test with
curl http://127.0.0.1:8080/. Check that you see HTML. Trycurl -ito see headers. - Hit
/api/statusand verify you get JSON with the server uptime. - Add a
/api/roomsendpoint that returns a JSON array of room names. - Add a
POST /api/echoendpoint that reads the request body and echoes it back as JSON. - Open in a browser - you'll see the HTML rendered. Check the network tab to see the full HTTP exchange.
What's Next
You now understand HTTP at the protocol level - parsing requests, building responses, Content-Length, Content-Type, and status codes.
In the next chapter, we move to WebSockets - the protocol that enables real-time bidirectional communication. HTTP is request-response; WebSocket is a persistent connection where either side can send messages at any time.