07. Code A Basic HTTP Server
Our HTTP server is based on the message echo server from the previous chapter, with the “message” replaced by the HTTP message.
7.1 Start Coding
The code is broken into small steps and follows a top-down approach.
Step 1: Types and Structures
Our first step is to define the structure for HTTP messages based on our understanding of HTTP semantics.
// a parsed HTTP request header
type HTTPReq = {
: string,
method: Buffer,
uri: string,
version: Buffer[],
headers;
}
// an HTTP response
type HTTPRes = {
: number,
code: Buffer[],
headers: BodyReader,
body; }
We use Buffer
instead of string
for the URI
and header fields. Although HTTP is mostly plaintext, there is no
guarantee that URI and header fields must be ASCII or UTF-8 strings. So
we just leave them as bytes until we need to parse them.
The BodyReader
type is the interface for reading data
from the body payload.
// an interface for reading/writing data from/to the HTTP body.
type BodyReader = {
// the "Content-Length", -1 if unknown.
: number,
length// read data. returns an empty buffer after EOF.
: () => Promise<Buffer>,
read; }
The payload body can be arbitrarily long, it may not even fit in
memory, thus we have to use the read()
function to read
from it instead of a simple Buffer
. The read()
function follows the convention of the soRead()
function —
the end of data is signaled by an empty Buffer
.
And when using chunked encoding, the length of the body is not known, which is another reason why this interface is needed.
Step 2: The Server Loop
The server loop follows the pattern from the previous chapter. Except
that the cutMessage()
function only parses the HTTP header;
the payload body is expected to be read while handling the request, or
discarded after handling the request. In this way, we don’t store the
entire payload body in memory.
async function serveClient(conn: TCPConn): Promise<void> {
const buf: DynBuf = {data: Buffer.alloc(0), length: 0};
while (true) {
// try to get 1 request header from the buffer
const msg: null|HTTPReq = cutMessage(buf);
if (!msg) {
// need more data
const data = await soRead(conn);
bufPush(buf, data);
// EOF?
if (data.length === 0 && buf.length === 0) {
return; // no more requests
}if (data.length === 0) {
throw new HTTPError(400, 'Unexpected EOF.');
}// got some data, try it again.
continue;
}
// process the message and send the response
const reqBody: BodyReader = readerFromReq(conn, buf, msg);
const res: HTTPRes = await handleReq(msg, reqBody);
await writeHTTPResp(conn, res);
// close the connection for HTTP/1.0
if (msg.version === '1.0') {
return;
}// make sure that the request body is consumed completely
while ((await reqBody.read()).length > 0) { /* empty */ }
// loop for IO
} }
The HTTPError
is a custom exception type defined by us.
It is used to generate an error response and close the connection. Note
that this thing exists only to make our code simpler by deferring the
unhappy case of error handling. You probably don’t want to throw
exceptions around like this in production code.
async function newConn(socket: net.Socket): Promise<void> {
const conn: TCPConn = soInit(socket);
try {
await serveClient(conn);
catch (exc) {
} console.error('exception:', exc);
if (exc instanceof HTTPError) {
// intended to send an error response
const resp: HTTPRes = {
: exc.code,
code: [],
headers: readerFromMemory(Buffer.from(exc.message + '\n')),
body;
}try {
await writeHTTPResp(conn, resp);
catch (exc) { /* ignore */ }
}
}finally {
} .destroy();
socket
} }
Step 3: Split the Header
The HTTP header ends with '\r\n\r\n'
, which is how we
determine its length.
In theory, there is no limit to the size of the header, but in practice there is. Because we are going to parse and store the header in memory, and memory is finite.
// the maximum length of an HTTP header
const kMaxHeaderLen = 1024 * 8;
// parse & remove a header from the beginning of the buffer if possible
function cutMessage(buf: DynBuf): null|HTTPReq {
// the end of the header is marked by '\r\n\r\n'
const idx = buf.data.subarray(0, buf.length).indexOf('\r\n\r\n');
if (idx < 0) {
if (buf.length >= kMaxHeaderLen) {
throw new HTTPError(413, 'header is too large');
}return null; // need more data
}// parse & remove the header
const msg = parseHTTPReq(buf.data.subarray(0, idx + 4));
bufPop(buf, idx + 4);
return msg;
}
Parsing is also easier when we have the complete data. That’s another reason why we waited for the full HTTP header before parsing anything.
Step 4: Parse the Header
To parse an HTTP header, we can first split the data into lines by CRLF since we have the complete header in the buffer. Then we can process each line individually.
// parse an HTTP request header
function parseHTTPReq(data: Buffer): HTTPReq {
// split the data into lines
const lines: Buffer[] = splitLines(data);
// the first line is `METHOD URI VERSION`
const [method, uri, version] = parseRequestLine(lines[0]);
// followed by header fields in the format of `Name: value`
const headers: Buffer[] = [];
for (let i = 1; i < lines.length - 1; i++) {
const h = Buffer.from(lines[i]); // copy
if (!validateHeader(h)) {
throw new HTTPError(400, 'bad field');
}.push(h);
headers
}// the header ends by an empty line
console.assert(lines[lines.length - 1].length === 0);
return {
: method, uri: uri, version: version, headers: headers,
method;
} }
The first line is simply 3 pieces separated by space. The rest of the lines are header fields. Although we’re not trying to parse the header fields here, it’s still a good idea to do some validations on them.
The splitLines()
, parseRequestLine()
, and
validateHeader()
functions are not very interesting, so we
will not show them here. You can easily code them yourself according to
RFCs.
Step 5: Read the Body
Before handling the request, we must first construct the
BodyReader
object which will be passed to the handler
function. There are 3 ways to read the payload body, as we mentioned
earlier.
// BodyReader from an HTTP request
function readerFromReq(
: TCPConn, buf: DynBuf, req: HTTPReq): BodyReader
conn
{let bodyLen = -1;
const contentLen = fieldGet(req.headers, 'Content-Length');
if (contentLen) {
= parseDec(contentLen.toString('latin1'));
bodyLen if (isNaN(bodyLen)) {
throw new HTTPError(400, 'bad Content-Length.');
}
}const bodyAllowed = !(req.method === 'GET' || req.method === 'HEAD');
const chunked = fieldGet(req.headers, 'Transfer-Encoding')
?.equals(Buffer.from('chunked')) || false;
if (!bodyAllowed && (bodyLen > 0 || chunked)) {
throw new HTTPError(400, 'HTTP body not allowed.');
}if (!bodyAllowed) {
= 0;
bodyLen
}
if (bodyLen >= 0) {
// "Content-Length" is present
return readerFromConnLength(conn, buf, bodyLen);
else if (chunked) {
} // chunked encoding
throw new HTTPError(501, 'TODO');
else {
} // read the rest of the connection
throw new HTTPError(501, 'TODO');
} }
Here we need to look at the Content-Length
field and the
Transfer-Encoding
field. The fieldGet()
function is for looking up the field value by name. Note that field
names are case-insensitive. The implementation is left to the
reader.
function fieldGet(headers: Buffer[], key: string): null|Buffer;
We will only implement the case where the Content-Length
field is present, the other cases are left for later chapters.
// BodyReader from a socket with a known length
function readerFromConnLength(
: TCPConn, buf: DynBuf, remain: number): BodyReader
conn
{return {
: remain,
length: async (): Promise<Buffer> => {
readif (remain === 0) {
return Buffer.from(''); // done
}if (buf.length === 0) {
// try to get some data if there is none
const data = await soRead(conn);
bufPush(buf, data);
if (data.length === 0) {
// expect more data!
throw new Error('Unexpected EOF from HTTP body');
}
}// consume data from the buffer
const consume = Math.min(buf.length, remain);
-= consume;
remain const data = Buffer.from(buf.data.subarray(0, consume));
bufPop(buf, consume);
return data;
};
} }
The readerFromConnLength()
function returns a
BodyReader
that reads exactly the number of bytes specified
in the Content-Length
field. Note that the data from the
socket goes into the buffer first, then we drain data from the buffer.
This is because:
- There may be extra data in the buffer before we read from the socket.
- The last read may return more data than we need, so we need to put the extra data back into the buffer.
The remain
variable is a state captured by the
read()
function to keep track of the remaining body
length.
Step 6: The Request Handler
We can now handle the request according to its URI and method. Here we will show you 2 sample responses.
// a sample request handler
async function handleReq(req: HTTPReq, body: BodyReader): Promise<HTTPRes> {
// act on the request URI
let resp: BodyReader;
switch (req.uri.toString('latin1')) {
case '/echo':
// http echo server
= body;
resp break;
default:
= readerFromMemory(Buffer.from('hello world.\n'));
resp break;
}
return {
: 200,
code: [Buffer.from('Server: my_first_http_server')],
headers: resp,
body;
} }
If the URI is '/echo'
, we simply set the response
payload to the request payload. This essentially creates an echo server
in HTTP. You can test this by POST
ing data with the
curl
command.
curl -s --data-binary 'hello' http://127.0.0.1:1234/echo
The other sample response is a fixed string
'hello world.\n'
. To do this, we must first create the
BodyReader
object.
// BodyReader from in-memory data
function readerFromMemory(data: Buffer): BodyReader {
let done = false;
return {
: data.length,
length: async (): Promise<Buffer> => {
readif (done) {
return Buffer.from(''); // no more data
else {
} = true;
done return data;
},
};
} }
The read()
function returns the full data on the first
call and returns EOF after that. This is useful for responding with
something small and already fits in memory.
Step 7: Send the Response
After handling the request, we can send the response header and the
response body if there is one. In this chapter, we will only deal with
the payload body of known length; the chunked encoding is left for later
chapters. All we need to do is to add the Content-Length
field.
// send an HTTP response through the socket
async function writeHTTPResp(conn: TCPConn, resp: HTTPRes): Promise<void> {
if (resp.body.length < 0) {
throw new Error('TODO: chunked encoding');
}// set the "Content-Length" field
console.assert(!fieldGet(resp.headers, 'Content-Length'));
.headers.push(Buffer.from(`Content-Length: ${resp.body.length}`));
resp// write the header
await soWrite(conn, encodeHTTPResp(resp));
// write the body
while (true) {
const data = await resp.body.read();
if (data.length === 0) {
break;
}await soWrite(conn, data);
} }
The encodeHTTPResp()
function encodes a response header
into a byte buffer. The message format is almost identical to the
request message, except for the first line.
status-line = HTTP-version SP status-code SP [ reason-phrase ]
Encoding is much easier than parsing, so the implementation is left to the reader.
Step 8: Review the Server Loop
There is still work to be done after sending the response. We can provide some compatibility for HTTP/1.0 clients by closing the connection immediately, since the connection cannot be reused anyway.
And most importantly, before continuing the loop to the next request, we must make sure that the request body is completely consumed, because the handler function may have ignored the request body and left the parser at the wrong position.
async function serveClient(conn: TCPConn): Promise<void> {
const buf: DynBuf = {data: Buffer.alloc(0), length: 0};
while (true) {
// try to get 1 request header from the buffer
const msg: null|HTTPReq = cutMessage(buf);
if (!msg) {
// omitted ...
continue;
}
// process the message and send the response
const reqBody: BodyReader = readerFromReq(conn, buf, msg);
const res: HTTPRes = await handleReq(msg, reqBody);
await writeHTTPResp(conn, res);
// close the connection for HTTP/1.0
if (msg.version === '1.0') {
return;
}// make sure that the request body is consumed completely
while ((await reqBody.read()).length > 0) { /* empty */ }
// loop for IO
} }
Our first HTTP server is now complete.
7.2 Testing
The simplest test case is to make requests with curl
.
The server should greet you with “hello world”. You can also POST data
to the '/echo'
path and the server should echo the data
back.
curl -s --data-binary 'hello' http://127.0.0.1:1234/echo
Large HTTP Body
The curl
command can also post data from files. We can
post a really big file to verify that our server is only using constant
memory and not triggering OOM.
curl -s --data-binary @a_big_file http://127.0.0.1:1234/echo | sha1sum
Connection Reuse & Pipelining
Another important thing to test is the ability to handle multiple
requests per connection. You can test this either interactively via
socat
, or automatically via shell scripting.
(cat req1.txt; sleep 1; cat req2.txt) | socat tcp:127.0.0.1:1234,crnl -
Note the crnl
option in the socat
command,
this is to make sure that lines end with CRLF instead of just LF.
If you remove the sleep 1
in the above script, you will
also be testing pipelined requests.
7.3 Discussion: Nagle's Algorithm
Optimization: Combining Small Writes
When sending the response, we used the encodeHTTPResp()
function to create a byte buffer of the header before writing the
response to the socket. Some people may skip this step and write to the
socket line by line.
// Bad example!
await soWrite(conn, Buffer.from(`HTTP/1.1 ${msg.code} ${status}\r\n`));
for (const h of msg.headers) {
await soWrite(conn, h);
await soWrite(conn, Buffer.from('\r\n'));
}await soWrite(conn, Buffer.from('\r\n'));
The problem with this is that it generates many small writes, causing TCP to send many small packets. Not only does each packet have a relatively large space overhead, but more computation is required to process more packets. People saw this optimization opportunity and added a feature to the TCP stack known as “Nagle’s algorithm” — the TCP stack delays transmission to allow the send buffer to accumulate data, so that multiple consecutive small writes can be combined.
Premature Optimization
However, this is not a good optimization. Many newer network protocol designs, such as TLS, have put a lot of effort into reducing RTTs because many performance problems are latency problems. Adding delays to TCP to combine writes now looks like anti-optimization. And the intended optimization goal can easily be achieved at the application level instead; applications can simply combine small data themselves without delays.
Well-written applications should manage buffers carefully, either by explicitly serializing data into a buffer, or by using some buffered IO interfaces, so that Nagle’s algorithm is not needed. And high-performance applications will want to minimize the number of syscalls, making Nagle’s algorithm even more useless.
What People Actually Do in Practice
When developing networked applications:
- Avoid small writes by combining small data before writing.
- Disable Nagle’s algorithm.
Nagle’s algorithm is often enabled by default. This can be disabled
using the noDelay
flag in Node.js.
const server = net.createServer({
: true, // TCP_NODELAY
noDelay; })
7.4 Discussion: Buffered Writer
Alternative: Make Buffering Semi-Transparent
Instead of explicitly serializing data into a buffer, as we do with
the response header, we can also add a buffer to the
TCPConn
type and change the way it works.
// append data to an internal buffer
function soWrite(conn: TCPConn, data: Buffer): Promise<void>;
// flush the buffer to the runtime
function soFlush(conn: TCPConn): Promise<void>;
In the new scheme, the soWrite()
function is changed to
append data to an internal buffer in TCPConn
, and the new
soFlush()
function is used to actually write the data. The
buffer size is limited, and the soWrite()
function can also
flush the buffer when it is full.
This style of IO is very popular and you may have seen it in other
programming languages. For example, stdio
in C has a
built-in buffer which is enabled by default, you must use
fflush()
when appropriate.
Alternative: Add a Buffered Wrapper
Alternatively, you can leave the TCPConn
as is, and add
a separate wrapper type like this:
type BufferedWriter = {
: (data: Buffer) => Promise<void>,
write: () => Promise<void>,
flush// ...
;
}
function createBufferedWriter(conn: TCPConn): BufferedWriter;
This is similar to the bufio.Writer
in Golang. This
scheme is more flexible than adding buffering to the socket code,
because the buffered wrapper is also applicable to other forms of IO.
And the Go standard library was designed with well-defined interfaces
(io.Writer
),
making the buffered writer a drop-in replacement for the unbuffered
writer.
There are many more good ideas to steal from the Go standard library.
One of them is that the bufio.Writer
is not just an
io.Writer
, but also exposes
its internal buffer so that you can write to it directly! This can
eliminate temporary buffers and extra data copies when serializing
data.