Handle HTTP connections in Node.js like Deno does
This small post is intended to show an alternative method for handling HTTP connections.
It demonstrates a pattern that is known from the alternative JavaScript framework Deno, which is actually more of a TypeScript framework.
Background
Deno is an alternative framework to Node.js and is compatible in many aspects. Two additional main goals are to ensure high security in the execution of scripts (sandbox mode and explicit permissions) and also to implement the well-known browser APIs, like Blobs, FormData or web storage.
It is written in Rust and supports TypeScript natively.
Now the idea is to handle HTTP connections in Node through a for
loop, just like it can be done in Deno, instead of using a legacy callback or event pattern:
const server = Deno.listen({
port: 8080
});
for await (const connection of server) {
// handle `connection`
}
Implementation
The demo project on GitHub demonstrates how to make use of so called AsyncIterator protocol.
First we have to do is to define an interface for our HTTP server:
// ...
/**
* A HTTP server.
*/
export interface IHttpServer extends AsyncIterable<IHttpConnection>, NodeJS.EventEmitter {
}
// ...
On the one hand, the interface describes, that it can be used in an asynchronious use of a for
loop by returning IHttpConnection
objects in each loop.
Such an object will hold the request and response contextes of the underlying HTTP connection:
// ...
import {
IncomingMessage,
ServerResponse
} from 'node:http';
// ...
/**
* A connection between a HTTP server and a remote client.
*/
export interface IHttpConnection {
/**
* The request context.
*/
request: IncomingMessage;
/**
* The response context.
*/
response: ServerResponse;
}
// ...
On the other hand NodeJS.EventEmitter
interface ensures, that we will implement a server class that is able to handle events. For this we can use shipped-in EventEmitter from node:events module as base class.
Create and start a new server
To create and start a new HTTP server instance, I decided to implement a factory function, called listenHttp()
:
// ...
export function listenHttp(port: number | string = 8080): IHttpServer {
// ...
}
In the very first step, we create a new Node.js server:
// ...
import {
createServer,
IncomingMessage,
Server,
ServerResponse
} from 'node:http';
// ...
export function listenHttp(port: number | string = 8080): IHttpServer {
const instance = createServer();
// ...
}
Next, we implement the AsyncIterator
, I already mentioned, by only implementing the next() method.
On every call, this will return a Promise that listens to error and request events of the server instance, we created in the previous step.
And calling this method is what an asynchronious use of for
loop does:
// ...
type HttpIterator = AsyncIterator<IHttpConnection, any, undefined>;
type HttpIteratorResult = IteratorResult<IHttpConnection, any>;
// ...
export function listenHttp(port: number | string = 8080): IHttpServer {
// ...
const iterator: HttpIterator = {
next: () => {
return new Promise<HttpIteratorResult>((resolve, reject) => {
instance.once('error', (error) => {
reject(error);
});
instance.once('request', (request, response) => {
const newConnection: IHttpConnection = {
request,
response
};
resolve({
done: !instance.listening, // we do not continue when we are not listening anymore
value: newConnection
});
});
});
}
};
// ...
}
// ...
Both instances we now give to the constructor of our internal HttpServer class, which is an implementation of IHttpServer
interface but not accessible from outside:
// ...
export function listenHttp(port: number | string = 8080): IHttpServer {
// ...
const server = new HttpServer(instance, iterator);
// ...
}
Before we start listening on the submitted port, we forward all error
events of the Node.js server to our own HttpServer
instance:
// ...
export function listenHttp(port: number | string = 8080): IHttpServer {
// ...
// proxy from Node server instance
// to own `HttpServer` instance
server.instance.on('error', (instanceError) => {
server.emit('error', instanceError);
});
// now start listening
server.instance.listen(Number(port), '0.0.0.0');
return server;
}
Usage
That’s all we need to do.
You can now start a HTTP server instance and handle connections in a for
loop:
// ...
async function handleConnection(connection: IHttpConnection) {
const {
request,
response
} = connection;
try {
// ...
} catch (error) {
const errorMessage = Buffer.from(
String(error), 'utf8'
);
if (!response.headersSent) {
response.writeHead(500, {
'Content-Type': 'text/plain; charset=UTF-8',
'Content-Length': String(errorMessage.length)
});
}
response.write(errorMessage);
} finally {
response.end(); // ensure connection is closed
}
}
const server = listenHttp(8080);
for await (const connection of server) {
// we do not need an await here
//
// this here ensures that actions are handled
// as asynchronious as possible
handleConnection(connection);
}
// ...
Have fun while implementing and trying it out! 🎉