import assert from "node:assert/strict"; import crypto from "node:crypto"; import http from "node:http"; import process from "node:process"; import * as u from "./utils.mjs"; export const normalizeSegments = segments => segments.length === 1 && segments[0] === "" ? segments : segments.concat([""]); export const pathToSegments = path => normalizeSegments(path .replace(/^\/*/, "") .replace(/\/*$/, "") .replace(/\/+/, "/") .split("/")); export const hasPathParams = segments => segments.some(s => s.startsWith(":")); const HTTP_METHODS = new Set([ "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", ]); const HTTP_METHODS_ARR = [...HTTP_METHODS.keys()].sort(); export const addRoute = (table, methods, path, handlerFn) => { if (methods === "*") { return addRoute(table, HTTP_METHODS_ARR, path, handlerFn); } if (!Array.isArray(methods)) { return addRoute(table, [methods], path, handlerFn); } assert.ok(methods.every(m => HTTP_METHODS.has(m))); const segments = pathToSegments(path); const kw = hasPathParams(segments) ? "dynamic" : "static"; return methods.reduce( (acc, el) => u.assocIn(acc, [kw, el].concat(segments), handlerFn), table, ); }; export const findStaticHandler = (table, method, segments) => { const handlerFn = u.getIn(table, ["static", method].concat(segments)); return !handlerFn ? null : { handlerFn, params: {} }; }; /** * "first" as is: * - depth-first, as we look for a full match and use it instead of searching * in parallel; * - the first param in the same level to show up alphabetically, e.g. * ":a-user" matches before ":id" does. */ export const firstParamMatch = (tree, segments, params) => { assert.notEqual(segments.length, 0); const [seg, ...nextSegments] = segments; if (segments.length === 1) { assert.equal(seg, ""); const handlerFn = tree[""]; return !handlerFn ? null : { handlerFn, params }; } const subtree = tree[seg]; if (subtree) { const submatch = firstParamMatch(subtree, nextSegments, params); // propagation of the end of recursion if (submatch) { return submatch; } } // literal matching failed, we now look for patterns that might match const paramOptions = Object.keys(tree) .filter(s => s.startsWith(":")) .sort(); return u.first(paramOptions, param => firstParamMatch(tree[param], nextSegments, { ...params, [param.slice(1)]: seg })); }; export const findDynamicHandler = (table, method, segments) => { const tree = table?.dynamic?.[method]; return !tree ? null : firstParamMatch(tree, segments, {}); }; export const findHandler = (table, method, path) => { const segments = pathToSegments(path); return ( findStaticHandler(table, method, segments) || findDynamicHandler(table, method, segments) ); }; export const extractQueryParams = s => { const ret = {}; for (const [k, v] of new URLSearchParams(s)) { ret[k] = v; } return ret; }; export const handleRequest = async (table, method, url) => { const [ path, queryParams ] = url.split("?"); const handler = findHandler(table, method, path); if (!handler) { return { status: 404, body: "Not Found\n", }; } const request = { params: { path: handler.params, query: extractQueryParams(queryParams), }, method, path, handler: handler.handlerFn, }; return await handler.handlerFn(request); }; export const makeRequestListener = table => async (req, res) => { const response = await handleRequest(table, req.method, req.url); res.writeHead(response.status, response.headers); res.end(response.body); }; export const log = o => console.error(JSON.stringify(o)); export const interceptorsFn = ({ uuidFn, logger, } = { uuidFn: crypto.randomUUID, logger: log, }) => ({ requestId: (req, next) => next({ ...req, id: uuidFn() }), logged: async (req, next) => { const { id, url, method } = req; logger({ id, url, method, type: "in-request", }); const response = await next(req); const { status } = response; logger({ id, status, type: "in-response", }); return response; }, contentType: async (req, next) => { const response = await next(req); const [mimeType, body] = typeof response.body === "string" ? ["text/html", response.body] : ["application/json", JSON.stringify(response.body) || ""]; return { ...response, body, headers: { "Content-Type": mimeType, "Content-Length": Buffer.byteLength(body), ...(response.headers || {}) }, }; }, serverError: async (req, next) => { try { const response = await next(req); assert.ok("status" in response, `Missing "status"`); return response; } catch (error) { logger({ id: req.id, type: "server-error-interceptor", message: error.message, }); return { status: 500, body: "Internal Server Error\n", }; } }, }); export const interceptors = interceptorsFn(); export const defaultInterceptors = [ interceptors.serverError, interceptors.contentType, interceptors.requestId, interceptors.logged, ]; export const chainInterceptors = arr => req => arr.length === 0 ? req : arr[0](req, chainInterceptors(arr.slice(1))); export const wrapHandler = (fn, arr) => arr.length === 0 ? fn : chainInterceptors(arr.concat([ (req, _next) => fn(req) ])); export const buildRoutes = (routes, globalInterceptors = []) => routes.reduce( (acc, [methods, path, handlerFn, interceptors = []]) => addRoute( acc, methods, path, wrapHandler( handlerFn, globalInterceptors.concat(interceptors), ), ), {} ); export const promisifyServer = serverHandle => ({ ref: serverHandle, listen: u.promisify((...args) => serverHandle.listen(...args)), close: u.promisify((...args) => serverHandle.close(...args)), events: serverHandle, }); export const buildServer = (routes, globalInterceptors = []) => { const table = buildRoutes(routes, globalInterceptors); const requestListener = makeRequestListener(table); const server = http.createServer(requestListener); return promisifyServer(server); };