DEV Community

Cover image for Exploring the Architecture of All Node.js Web Frameworks
Leapcell
Leapcell

Posted on

3 1 1 1 1

Exploring the Architecture of All Node.js Web Frameworks

Image description

Leapcell: The Best of Serverless Web Hosting

How to Write a Web Framework Using the Node.js HTTP Module

When developing web applications with Node.js, the http module is a fundamental and crucial component. With its help, you can start an HTTP server with just a few lines of code. Next, we will delve into how to use the http module to write a simple web framework and understand the entire process from the arrival of an HTTP request to the response.

Start the HTTP Server

The following is a simple Node.js code example for starting an HTTP server:

'use strict';
const { createServer } = require('http');

createServer(function (req, res) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
})
.listen(3000, function () { console.log('Listening on port 3000') });
Enter fullscreen mode Exit fullscreen mode

When you run the above code, using the curl localhost:3000 command in the terminal, you can see the Hello World message returned by the server. This is because Node.js has encapsulated many details in the source code, and the main code is stored in files like lib/_http_*.js. Next, we will explore in detail the source code implementation from the arrival of an HTTP request to the response.

Handling of HTTP Requests

Create a Server Instance

In Node.js, to receive HTTP requests, you first need to create an instance of the http.Server class and listen for its request event. Since the HTTP protocol is at the application layer and the underlying transport layer usually uses the TCP protocol, the net.Server class is the parent class of the http.Server class. The specific HTTP-related part is encapsulated by listening for the connection event of an instance of the net.Server class:

// lib/_http_server.js
// ...

function Server(requestListener) {
    if (!(this instanceof Server)) return new Server(requestListener);
    net.Server.call(this, { allowHalfOpen: true });

    if (requestListener) {
        this.addListener('request', requestListener);
    }

    // ...
    this.addListener('connection', connectionListener);

    // ...
}
util.inherits(Server, net.Server);
Enter fullscreen mode Exit fullscreen mode

Parse Request Data

At this time, an HTTP parser is needed to parse the data transmitted via TCP:

// lib/_http_server.js
const parsers = common.parsers;
// ...

function connectionListener(socket) {
    // ...
    var parser = parsers.alloc();
    parser.reinitialize(HTTPParser.REQUEST);
    parser.socket = socket;
    socket.parser = parser;
    parser.incoming = null;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

It is worth noting that the parser parser is obtained from a "pool", and this "pool" uses the free list data structure. Its purpose is to reuse the parser as much as possible to avoid the performance consumption caused by frequent calls to the constructor, and there is also an upper limit on the number (in the http module, it is 1000):

// lib/freelist.js
'use strict';

exports.FreeList = function(name, max, constructor) {
    this.name = name;
    this.constructor = constructor;
    this.max = max;
    this.list = [];
};

exports.FreeList.prototype.alloc = function() {
    return this.list.length ? this.list.pop() :
                              this.constructor.apply(this, arguments);
};

exports.FreeList.prototype.free = function(obj) {
    if (this.list.length < this.max) {
        this.list.push(obj);
        return true;
    }
    return false;
};
Enter fullscreen mode Exit fullscreen mode

Since the data is continuously transmitted via TCP, the parser works based on events, which is in line with the core idea of Node.js. The http-parser library is used:

// lib/_http_common.js
// ...
const binding = process.binding('http_parser');
const HTTPParser = binding.HTTPParser;
const FreeList = require('internal/freelist').FreeList;
// ...

var parsers = new FreeList('parsers', 1000, function() {
    var parser = new HTTPParser(HTTPParser.REQUEST);
    // ...
    parser[kOnHeaders] = parserOnHeaders;
    parser[kOnHeadersComplete] = parserOnHeadersComplete;
    parser[kOnBody] = parserOnBody;
    parser[kOnMessageComplete] = parserOnMessageComplete;
    parser[kOnExecute] = null;

    return parser;
});
exports.parsers = parsers;

// lib/_http_server.js
// ...

function connectionListener(socket) {
    parser.onIncoming = parserOnIncoming;
}
Enter fullscreen mode Exit fullscreen mode

A complete HTTP request, from reception to full parsing, will pass through the following event listeners on the parser in sequence:

  • parserOnHeaders: Continuously parses the incoming request header data.
  • parserOnHeadersComplete: After the request header is parsed, constructs the header object and creates an http.IncomingMessage instance for the request body.
  • parserOnBody: Continuously parses the incoming request body data.
  • parserOnExecute: After the request body is parsed, checks if there is an error in the parsing. If there is an error, directly triggers the clientError event. If the request uses the CONNECT method or has an Upgrade header, directly triggers the connect or upgrade event.
  • parserOnIncoming: Handles the parsed specific request.

Trigger the request Event

The following is the key code of the parserOnIncoming listener, which completes the triggering of the final request event:

// lib/_http_server.js
// ...

function connectionListener(socket) {
    var outgoing = [];
    var incoming = [];
    // ...

    function parserOnIncoming(req, shouldKeepAlive) {
        incoming.push(req);
        // ...
        var res = new ServerResponse(req);

        if (socket._httpMessage) { 
            // If true, it means the socket is being occupied by a previous ServerResponse instance in the queue
            outgoing.push(res);
        } else {
            res.assignSocket(socket);
        }

        res.on('finish', resOnFinish);
        function resOnFinish() {
            incoming.shift();
            // ...
            var m = outgoing.shift();
            if (m) {
                m.assignSocket(socket);
            }
        }
        // ...
        self.emit('request', req, res);
    }
}
Enter fullscreen mode Exit fullscreen mode

It can be seen that for requests sent by the same socket, the source code maintains two queues, which are used to cache IncomingMessage instances and corresponding ServerResponse instances respectively. The earlier ServerResponse instance will occupy the socket first and listen for its finish event. When the event is triggered, it will release the ServerResponse instance and the corresponding IncomingMessage instance from their respective queues.

Respond to HTTP Requests

At the response stage, things are relatively simple. The incoming ServerResponse has already obtained the socket. The http.ServerResponse inherits from the internal class http.OutgoingMessage. When calling ServerResponse#writeHead, Node.js will piece together the header string and cache it in the _header property of the ServerResponse instance:

// lib/_http_outgoing.js
// ...

OutgoingMessage.prototype._storeHeader = function(firstLine, headers) {
    // ...
    if (headers) {
        var keys = Object.keys(headers);
        var isArray = Array.isArray(headers);
        var field, value;

        for (var i = 0, l = keys.length; i < l; i++) {
            var key = keys[i];
            if (isArray) {
                field = headers[key][0];
                value = headers[key][1];
            } else {
                field = key;
                value = headers[key];
            }

            if (Array.isArray(value)) {
                for (var j = 0; j < value.length; j++) {
                    storeHeader(this, state, field, value[j]);
                }
            } else {
                storeHeader(this, state, field, value);
            }
        }
    }
    // ...
    this._header = state.messageHeader + CRLF;
}
Enter fullscreen mode Exit fullscreen mode

Immediately after, when calling ServerResponse#end, it will splice the data after the header string, add the corresponding tail, and then write it to the TCP connection. The specific write operation is in the internal method ServerResponse#_writeRaw:

// lib/_http_outgoing.js
// ...

OutgoingMessage.prototype.end = function(data, encoding, callback) {
    // ...
    if (this.connection && data)
        this.connection.cork();

    var ret;
    if (data) {
        this.write(data, encoding);
    }

    if (this._hasBody && this.chunkedEncoding) {
        ret = this._send('0\r\n' + this._trailer + '\r\n', 'binary', finish);
    } else {
        ret = this._send('', 'binary', finish);
    }

    if (this.connection && data)
        this.connection.uncork();

    // ...
    return ret;
}

OutgoingMessage.prototype._writeRaw = function(data, encoding, callback) {
    if (typeof encoding === 'function') {
        callback = encoding;
        encoding = null;
    }

    var connection = this.connection;
    // ...
    return connection.write(data, encoding, callback);
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

At this point, a request has been sent back to the client via TCP. This article only explores the main processing flow. In fact, the Node.js source code also takes into account more situations, such as timeout handling, the caching mechanism when the socket is occupied, special header handling, countermeasures for problems upstream, and more efficient written header querying, etc. These details are all worthy of in-depth study and learning. Through the analysis of the http module source code, we can better understand how to use it to build powerful web frameworks.

Leapcell: The Best of Serverless Web Hosting

Finally, I would like to recommend a platform that is most suitable for deploying Go services: Leapcell

Image description

🚀 Build with Your Favorite Language

Develop effortlessly in JavaScript, Python, Go, or Rust.

🌍 Deploy Unlimited Projects for Free

Only pay for what you use—no requests, no charges.

⚡ Pay-as-You-Go, No Hidden Costs

No idle fees, just seamless scalability.

Image description

📖 Explore Our Documentation

🔹 Follow us on Twitter: @LeapcellHQ

Neon image

Set up a Neon project in seconds and connect from a Next.js application ⚡

If you're starting a new project, Neon has got your databases covered. No credit cards. No trials. No getting in your way.

Get started →

Top comments (0)

Jetbrains image

Build Secure, Ship Fast

Discover best practices to secure CI/CD without slowing down your pipeline.

Read more

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, cherished by the supportive DEV Community. Coders of every background are encouraged to bring their perspectives and bolster our collective wisdom.

A sincere “thank you” often brightens someone’s day—share yours in the comments below!

On DEV, the act of sharing knowledge eases our journey and forges stronger community ties. Found value in this? A quick thank-you to the author can make a world of difference.

Okay