Files
smartacme/ts_server/server.classes.router.ts

117 lines
3.6 KiB
TypeScript

import type * as http from 'node:http';
import type { TRouteHandler } from './server.interfaces.js';
import type { NonceManager } from './server.classes.nonce.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
interface IRoute {
method: string;
pattern: string;
segments: string[];
handler: TRouteHandler;
}
/**
* Minimal HTTP router for the ACME server.
* Supports parameterized paths like /order/:id.
*/
export class AcmeRouter {
private routes: IRoute[] = [];
private nonceManager: NonceManager;
constructor(nonceManager: NonceManager) {
this.nonceManager = nonceManager;
}
addRoute(method: string, pattern: string, handler: TRouteHandler): void {
this.routes.push({
method: method.toUpperCase(),
pattern,
segments: pattern.split('/').filter(Boolean),
handler,
});
}
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = (req.method || 'GET').toUpperCase();
const pathSegments = url.pathname.split('/').filter(Boolean);
// Always add a fresh nonce to every response
res.setHeader('Replay-Nonce', this.nonceManager.generate());
res.setHeader('Cache-Control', 'no-store');
// Find matching route
for (const route of this.routes) {
if (route.method !== method) continue;
const params = this.matchPath(route.segments, pathSegments);
if (params === null) continue;
try {
const body = method === 'POST' ? await this.parseBody(req) : undefined;
await route.handler(req, res, params, body);
} catch (err) {
this.sendError(res, err);
}
return;
}
// No route found
this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found'));
}
private matchPath(
routeSegments: string[],
pathSegments: string[],
): Record<string, string> | null {
if (routeSegments.length !== pathSegments.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < routeSegments.length; i++) {
if (routeSegments[i].startsWith(':')) {
params[routeSegments[i].slice(1)] = pathSegments[i];
} else if (routeSegments[i] !== pathSegments[i]) {
return null;
}
}
return params;
}
private parseBody(req: http.IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (!raw) {
resolve(undefined);
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body'));
}
});
req.on('error', reject);
});
}
private sendError(res: http.ServerResponse, err: unknown): void {
if (err instanceof AcmeServerError) {
res.writeHead(err.status, { 'Content-Type': 'application/problem+json' });
res.end(JSON.stringify({
type: err.type,
detail: err.detail,
status: err.status,
}));
} else {
const message = err instanceof Error ? err.message : 'Internal server error';
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
res.end(JSON.stringify({
type: 'urn:ietf:params:acme:error:serverInternal',
detail: message,
status: 500,
}));
}
}
}