@quiltjs/quilt
Version:
Lightweight, type-safe handler and router abstraction for Node HTTP servers.
168 lines • 5.57 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
import http from 'node:http';
import { createHandler, } from '../Handler.js';
/**
* Minimal adapter that implements the ServerEngineAdapter interface using
* Node's built-in `http` module.
*
* It provides basic routing with support for:
* - Static paths, e.g. `/status`
* - Parameterized paths, e.g. `/users/:id/orders/:orderId`
* - JSON request bodies (when `content-type` includes `application/json`)
*/
export class NodeHttpEngineAdapter {
routes = [];
server;
port;
get(path, handler) {
this.routes.push({ method: 'GET', path, handler });
}
post(path, handler) {
this.routes.push({ method: 'POST', path, handler });
}
put(path, handler) {
this.routes.push({ method: 'PUT', path, handler });
}
patch(path, handler) {
this.routes.push({ method: 'PATCH', path, handler });
}
delete(path, handler) {
this.routes.push({ method: 'DELETE', path, handler });
}
options(path, handler) {
this.routes.push({ method: 'OPTIONS', path, handler });
}
head(path, handler) {
this.routes.push({ method: 'HEAD', path, handler });
}
listen(port, callback) {
// Close any existing server before starting a new one
if (this.server) {
this.server.close();
this.server = undefined;
this.port = undefined;
}
const server = http.createServer(async (req, res) => {
const method = (req.method || 'GET').toUpperCase();
const url = req.url ?? '/';
const requestUrl = new URL(url, 'http://localhost');
const pathname = requestUrl.pathname;
const match = this.findRoute(method, pathname);
if (!match) {
res.statusCode = 404;
res.setHeader('content-type', 'text/plain; charset=utf-8');
res.end('Not Found');
return;
}
const params = match.params;
const query = Object.fromEntries(requestUrl.searchParams.entries());
const body = await this.readBody(req);
const nodeReq = Object.assign(req, {
params,
query,
body,
});
await match.route.handler(nodeReq, res);
});
this.server = server;
server.listen(port, () => {
const address = server.address();
if (typeof address === 'object' && address !== null) {
this.port = address.port;
}
if (callback) {
callback();
}
});
}
getPort() {
return this.port;
}
async close() {
if (!this.server) {
return;
}
const server = this.server;
this.server = undefined;
this.port = undefined;
await new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}
else {
resolve();
}
});
});
}
findRoute(method, path) {
for (const route of this.routes) {
if (route.method !== method)
continue;
const params = this.matchPath(route.path, path);
if (params) {
return { route, params };
}
}
return null;
}
matchPath(routePath, actualPath) {
const routeSegments = routePath.split('/').filter(Boolean);
const pathSegments = actualPath.split('/').filter(Boolean);
if (routeSegments.length !== pathSegments.length) {
return null;
}
const params = {};
for (let i = 0; i < routeSegments.length; i++) {
const routeSegment = routeSegments[i];
const pathSegment = pathSegments[i];
if (routeSegment.startsWith(':')) {
const key = routeSegment.slice(1);
params[key] = decodeURIComponent(pathSegment);
}
else if (routeSegment !== pathSegment) {
return null;
}
}
return params;
}
readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
});
req.on('end', () => {
if (chunks.length === 0) {
resolve(undefined);
return;
}
const raw = Buffer.concat(chunks).toString('utf8');
const contentType = req.headers['content-type'];
if (typeof contentType === 'string' &&
contentType.includes('application/json')) {
try {
resolve(JSON.parse(raw));
}
catch {
resolve(raw);
}
}
else {
resolve(raw);
}
});
req.on('error', (err) => {
reject(err);
});
});
}
}
export function createNodeHttpRouteHandler({ execute, dependencies, }) {
return createHandler({
dependencies,
execute: (ctx, deps) => execute(ctx, deps),
});
}
//# sourceMappingURL=NodeHttpEngineAdapter.js.map