A lightweight, Express-inspired HTTP framework focused on clarity, performance, and minimalism β built from scratch in TypeScript/Bun.
- Basic app structure (
send,post,res,req,next) - Reduced dependencies β core libs rebuilt from scratch
- Strong TypeScript interfaces
- Static file support (
sendFile) - Route aliases for cleaner code
- IP middleware for blocking & rate-limiting
- Hot Module Reload for dev productivity
- Custom 404 handler
- Build-time route compilation
- Trie-based runtime route matcher
- Query string parsing
- Dynamic route params (
:id) - Middleware pipeline with
next() - Logger middleware
- Expanded test coverage
RouteX follows a build-time route compilation approach β routes are compiled into a data structure once at startup, and every incoming request is matched against that structure in O(k) time, where k is the number of path segments.
Every app.get(), app.post(), or app.use() call creates a Layer and pushes it onto the router's internal stack:
{
path: '/users/:id',
aliases: ['/users/:id', '/u/:id'], // alternative paths
methods: Set { 'GET' },
handler: Function,
type: 'route' | 'middleware'
}router.stack is an ordered array of Layer objects, accumulated at startup. The stack is never consulted at request time β it exists solely as input to the compiler.
router.stack
βββ Layer { path: '/', type: 'middleware', methods: ANY } β init middleware
βββ Layer { path: '/users', type: 'route', methods: GET }
βββ Layer { path: '/users/:id', type: 'route', methods: GET }
βββ Layer { path: '/posts', type: 'route', methods: POST }
RouterCompiler.compile(stack) walks every layer and builds a prefix trie (also called a radix-style route tree). Each path segment becomes a trie node:
Registered routes:
GET /users
GET /users/:id
POST /users/:id
Compiled trie:
root
β [ANY] β initMiddleware
β
βββ "users"
[GET] β getUsersHandler
βββ :id (paramName = "id")
[GET] β getUserByIdHandler
[POST] β updateUserHandler
Dynamic segments (:param) become a paramChild node with a paramName property. Static segments become entries in the children Map.
RouterMatcher.match(method, url) traverses the compiled trie segment by segment:
- Strip the query string from the URL
- Split the path into segments (
/users/42β['users', '42']) - Walk the trie β static match first, then
paramChildfallback - On a match, collect
params(e.g.{ id: '42' }) andquery(parsed query string) - Look up the HTTP method handler on the final node
No file loading, no regex scanning, no linear search β just map lookups down a tree.
Handlers stored on each trie node are executed as a pipeline. Each handler receives (req, res, next) and calls next() to pass control to the next handler in the chain:
// Conceptually, per matched node:
let index = 0;
const next = () => {
const handler = handlers[index++];
if (!handler) return;
handler(req, res, next);
};
next();PipelineCompiler.compilePipeline() provides the async version of this pattern.
In development mode, RouteManager uses chokidar to watch the routes directory. On any file change:
- All
route-type layers are removed fromrouter.stack(middleware layers are preserved) - The changed route file is re-required (module cache is cleared first)
router.rebuild()recompiles the trie from the updated stack
[chokidar detects change]
β
router.stack.filter(l => l.type !== 'route') β clear old routes
β
delete require.cache[...] + require(file) β reload file
β
router.rebuild() β recompile trie
Hot reload does not run in production.
STARTUP
βββ RouteManager.loadRoutes()
β βββ require() each route file β app.get/post/use() β stack.push(Layer)
βββ app.listen()
β βββ router.compile()
β βββ RouterCompiler.compile(stack) β builds CompiledNode trie
βββ http.createServer(app).listen(port)
βββββββββββββββββββββββββββββββββββββββββββββ
RUNTIME (per request)
βββ app(req, res)
β βββ prototype.handle()
β βββ Response.send/json/redirect/... attached to res
β βββ router.handle(req, res)
β βββ RouterMatcher.match(method, url)
β β βββ Parse query string
β β βββ Split path into segments
β β βββ Walk trie (static β param fallback)
β β βββ Return { handler[], params, query } or null
β β
β βββ null β 404 response
β βββ match β req.params = params
β req.query = query
β next() pipeline β handler(req, res, next)
src/
βββ api/
β βββ index.ts # Entry point β loads routes, starts server
β βββ routex.ts # createApp() factory β merges prototype onto app
β
βββ core/
β βββ layer/
β β βββ layer.ts # Layer class β single route/middleware unit
β βββ router/
β β βββ CompiledNode.ts # Trie node (children map, paramChild, handlers map)
β β βββ RouterCompiler.ts # Compiles Layer stack β CompiledNode trie
β β βββ RouterMatcher.ts # Traverses trie, parses params & query
β β βββ PipelineCompiler.ts # Builds async middleware chain
β β βββ router.ts # Router class β stack, compile(), rebuild(), handle()
β βββ types/
β βββ IApp.ts # App interface
β βββ IRouteHandler.ts # Handler function type
β βββ IOptionsFile.ts # sendFile options
β βββ IDetails.ts # Error detail shape
β βββ IProtoype.ts # GetOptions type
β
βββ http/
β βββ errors/
β β βββ details.ts # Structured error factory
β βββ middleware/
β β βββ init.ts # Sets res prototype on each request
β β βββ ip.ts # IP blocking / rate-limit middleware
β β βββ prototype.ts # App prototype β handle, get, post, use, listen, lazyrouter
β βββ request/
β β βββ IServerRequest.ts # Extends IncomingMessage with params, query, body...
β β βββ request.ts # Request helper class
β βββ response/
β βββ IServerResponse.ts # Extends ServerResponse with send, json, redirect...
β βββ response.ts # Static methods that attach response helpers to res
β
βββ middleware/
β βββ logger/
β β βββ LoggerMiddleware.ts
β βββ RouteManager.ts # Route file discovery, loading, hot reload
β βββ RouteMiddleware.ts # RouteXMiddleware type (req, res, next)
β
βββ utils/
β βββ flatten.ts # Array flattening helper
β βββ merge.ts # Object property merging (used in createApp)
β
βββ examples/
β βββ routes/ # Example route files (one export per file)
β βββ main.ts
β βββ params.ts
β βββ json.ts
β βββ redirect.ts
β βββ send.ts
β
βββ __mocks__/
β βββ mime.mock.ts
β βββ response.mock.ts
β
βββ tests/
βββ app.test.ts
βββ prototype.test.ts
βββ response.test.ts
| Method | Description |
|---|---|
res.send(data) |
Sends plain text or serialized object |
res.json(data) |
Sends a JSON response with correct headers |
res.download(path) |
Forces a file download |
res.redirect(url) |
302 redirect |
res.sendFile(path, options?) |
Serves a static file with optional headers and cache control |
req.params |
Dynamic route parameters (/users/:id β { id: '42' }) |
req.query |
Parsed query string (?a=1&b=2 β { a: '1', b: '2' }) |
| Route aliases | Register the same handler under multiple paths |
import { app } from './api/routex';
// Simple route
app.get('/users', { aliases: '/u' }, (req, res) => {
res.json({ users: [] });
});
// Dynamic param
app.get('/users/:id', {}, (req, res) => {
res.json({ id: req.params.id });
});
// POST route
app.post('/users', {}, (req, res) => {
res.json({ created: true });
});
// Custom 404
app.setCustom404((req, res) => {
res.statusCode = 404;
res.json({ error: 'Not found' });
});res.send("Hello, world!");
res.json({ hello: "world" });
res.redirect("https://example.com");
res.download("./report.pdf");
res.sendFile("./index.html", { root: process.cwd() });Each file in src/examples/routes/ exports a default function. RouteManager discovers and loads them automatically:
// src/examples/routes/users.ts
import { app } from '../../api/routex';
export default function usersRoutes() {
app.get('/users', {}, (req, res) => {
res.json({ users: [] });
});
}git clone https://github.com/criszst/RouteX.git
cd RouteX
bun install
# Development (hot reload enabled)
bun dev
# Production build + start
bun start
# Tests
bun testThe server runs on http://localhost:3000 by default.
curl http://localhost:3000/
# {"hello":"world"}
curl http://localhost:3000/json
# {"json":"test for json method"}