diff options
author | EuAndreh <eu@euandre.org> | 2024-02-23 06:05:19 -0300 |
---|---|---|
committer | EuAndreh <eu@euandre.org> | 2024-02-23 06:05:21 -0300 |
commit | c36bf8e3577da31cf6d575879c7e92d3e9c7e4f1 (patch) | |
tree | 4fd5414e76490e297c4c770ff35e09149ef3658f /src/hero.mjs | |
parent | Remove C code and cleanup repository (diff) | |
download | papod-c36bf8e3577da31cf6d575879c7e92d3e9c7e4f1.tar.gz papod-c36bf8e3577da31cf6d575879c7e92d3e9c7e4f1.tar.xz |
Big cleanup
- delete all SQLite Node-API code: we'll use the C++ one instead;
- implement hero.mjs, with tests!
- use ESM all over.
Diffstat (limited to 'src/hero.mjs')
-rw-r--r-- | src/hero.mjs | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/src/hero.mjs b/src/hero.mjs new file mode 100644 index 0000000..a8532a8 --- /dev/null +++ b/src/hero.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert"; +import crypto from "node:crypto"; +import http from "node:http"; + +import { assocIn, getIn, first, log } 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) => assocIn(acc, [kw, el].concat(segments), handlerFn), + table, + ); +}; + +export const buildRoutes = routes => + routes.reduce( + (acc, [methods, path, handlerFn]) => + addRoute(acc, methods, path, handlerFn), + {} + ); + +export const findStaticHandler = (table, method, segments) => { + const handlerFn = 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 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", + }; + } + + 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 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", + }; + } + }, +}); + +export const interceptors = interceptorsFn(); + +export const chainInterceptors = arr => + req => arr.length === 0 ? + req : + arr[0](req, chainInterceptors(arr.slice(1))); + +export const wrapHandler = (fn, arr) => + chainInterceptors(arr.concat([ (req, _next) => fn(req) ])); |