My weekend project: a tiny framework. Roast my code?
I spent my weekend building a tiny Node.js web framework from scratch to learn what's under the hood. Come see how it works and roast my code!
Alex Donovan
A full-stack developer passionate about demystifying software and building things from scratch.
You know that feeling? It’s Saturday morning, the coffee is brewing, and you have a whole weekend stretching out before you. You could catch up on that new streaming series, you could finally fix that squeaky door, or… you could dive headfirst into a completely unnecessary, gloriously nerdy coding project.
I chose option three. My mission, which I very much chose to accept, was to demystify the magic behind web frameworks like Express.js by building my own. The result is a tiny, feature-light, and probably bug-riddled creation I’m calling Imp.js.
Why Imp? Because it's small, a little mischievous, and does my bidding (mostly). And now, I’m throwing it to the wolves. I’m here to walk you through what I built and, more importantly, to ask you to roast my code.
The "Why": Peeking Behind the Curtain
I use Node.js frameworks every single day. I type app.get('/', (req, res) => ...)
in my sleep. But if you asked me to explain exactly what happens between that line of code and the response hitting the browser, I’d have to hand-wave a bit. I’d mumble something about "event loops" and "request handlers," but the real mechanical details? They were a black box.
This project wasn't about building the next big thing. It was about prying open that box. The goal was simple: create the absolute bare minimum of a web framework that could:
- Start a server and listen for requests.
- Route different paths and HTTP methods to specific handler functions.
- Provide a simplified way to handle requests and send responses.
The Foundation: Just Plain Node.js
Every Node.js web server, from the simplest script to a massive enterprise application, starts in the same place: the built-in http
module. Before any framework magic, there’s this:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!\n');
});
server.listen(3000, () => {
console.log('Server listening on port 3000');
});
This is our ground zero. It works, but it’s not very flexible. If we want to handle /about
differently from /contact
, or POST
requests differently from GET
requests, we’d end up with a monstrous pile of if/else
statements inside that callback. That’s the first problem a framework needs to solve.
The Router: A Map and a Prayer
The heart of any web framework is its router. It’s the traffic cop that directs incoming requests to the right piece of code. For Imp.js, I wanted something simple and explicit.
I decided to use a JavaScript Map
. The key would be a unique string combining the HTTP method and the URL path (e.g., "GET:/users"
), and the value would be the handler function itself. It’s not sophisticated—it doesn't handle URL parameters like /users/:id
or wildcards—but for a weekend project, it's a solid start.
Here’s the basic idea for the Router
class:
class Router {
constructor() {
this.routes = new Map();
}
add(method, path, handler) {
const key = `${method.toUpperCase()}:${path}`;
this.routes.set(key, handler);
}
find(method, path) {
const key = `${method.toUpperCase()}:${path}`;
return this.routes.get(key);
}
}
Simple, right? We can add routes and then look them up. This class becomes the core of our application's logic.
The Context: Simplifying `req` and `res`
Working directly with Node's raw req
(IncommingMessage) and res
(ServerResponse) objects is powerful but verbose. You have to manually set headers, call res.end()
, and handle different content types. Frameworks provide a wrapper or a "context" object to make this easier.
I created a simple Context
class that would be passed to every handler. It holds the original request and response objects but provides helper methods like ctx.json()
or ctx.text()
.
class Context {
constructor(req, res) {
this.req = req;
this.res = res;
}
text(body, status = 200) {
this.res.writeHead(status, { 'Content-Type': 'text/plain' });
this.res.end(body);
}
json(body, status = 200) {
this.res.writeHead(status, { 'Content-Type': 'application/json' });
this.res.end(JSON.stringify(body));
}
}
Now, instead of the clunky res.writeHead(...)
and res.end(...)
, a user of Imp.js can just call ctx.json({ message: 'Hello' })
. Much cleaner!
Putting It All Together: A Working Example
With a router and a context object, we can finally build the main application class. This class will tie everything together: it will contain the router, create the HTTP server, and in the server's callback, it will find the right handler and pass it a newly created context object.
Here’s what using Imp.js looks like:
// This is how you'd use the final framework
const Imp = require('./imp'); // Assuming the framework code is in imp.js
const app = new Imp();
app.get('/', (ctx) => {
ctx.text('Welcome to the homepage!');
});
app.get('/about', (ctx) => {
ctx.text('This is a tiny framework built for learning.');
});
app.get('/api/user', (ctx) => {
ctx.json({ id: 1, name: 'Alex' });
});
app.listen(3000, () => {
console.log('Imp.js server running on port 3000');
});
Look familiar? The API is heavily inspired by Express and its peers, which was intentional. The goal was to recreate a familiar developer experience to better understand the underlying mechanics.
The Moment of Truth: Roast My Code!
Okay, I’ve shown you the concepts and the desired outcome. Now, here is the complete (and very minimal) source code for Imp.js. This is where you come in. I want you to pick it apart. What did I do wrong? What’s naive? What obvious security flaw am I missing? How could this be better?
Don't hold back. The goal is to learn.
// imp.js
const http = require('http');
const { URL } = require('url');
class Context {
constructor(req, res) {
this.req = req;
this.res = res;
}
text(body, status = 200) {
this.res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
this.res.end(body);
}
json(body, status = 200) {
this.res.writeHead(status, { 'Content-Type': 'application/json' });
this.res.end(JSON.stringify(body));
}
}
class Imp {
constructor() {
this.router = new Map();
}
_addRoute(method, path, handler) {
const key = `${method.toUpperCase()}:${path}`;
if (this.router.has(key)) {
throw new Error(`Route already exists: ${key}`);
}
this.router.set(key, handler);
}
get(path, handler) {
this._addRoute('GET', path, handler);
}
post(path, handler) {
this._addRoute('POST', path, handler);
}
// You could add put, delete, etc. here
_handleRequest(req, res) {
const { method, url } = req;
// Using the URL object to easily get the pathname
const parsedUrl = new URL(url, `http://${req.headers.host}`);
const path = parsedUrl.pathname;
const key = `${method}:${path}`;
const handler = this.router.get(key);
const ctx = new Context(req, res);
if (handler) {
try {
handler(ctx);
} catch (err) {
console.error('Error in handler:', err);
ctx.text('Internal Server Error', 500);
}
} else {
ctx.text('Not Found', 404);
}
}
listen(port, callback) {
const server = http.createServer(this._handleRequest.bind(this));
server.listen(port, () => {
if (callback) callback();
});
}
}
module.exports = Imp;
Specific Questions for the Roast:
- Routing: The
Map
is fast for exact matches, but it's inflexible. How would you approach adding support for parameters (e.g.,/users/:id
)? A tree structure (like a Radix tree)? A series of regular expressions? - Error Handling: My
try...catch
block is super basic. What’s a more robust way to handle errors in handlers without crashing the server? - Request Body: I completely ignored parsing the request body (e.g., for POST requests with JSON). What's the proper way to handle streams and buffers to get that data?
- Middleware: There's no concept of middleware (like for logging, authentication, etc.). How would you refactor
_handleRequest
to support a chain of functions? - Security: What’s the most glaring security hole you can spot? I'm sure there's at least one!
My Takeaways
Even this tiny project was incredibly illuminating. It forced me to engage directly with the http
module, think about API design, and appreciate the immense amount of work that goes into polished frameworks like Express, Fastify, and Koa. They handle so many edge cases, performance optimizations, and security considerations that you never have to think about.
So, what do you think? Let me know your thoughts on GitHub, Twitter, or in the comments below. Let the roast begin!