summaryrefslogtreecommitdiff
path: root/src/hero.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'src/hero.mjs')
-rw-r--r--src/hero.mjs220
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) ]));