summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore15
-rw-r--r--Makefile62
-rw-r--r--deps.mk38
-rw-r--r--index.mjs1
-rwxr-xr-xmkdeps.sh8
-rw-r--r--src/api.mjs (renamed from src/api.js)12
-rwxr-xr-xsrc/bin.mjs4
-rwxr-xr-xsrc/cli4
-rw-r--r--src/db.mjs (renamed from src/db.js)18
-rw-r--r--src/escape.mjs14
-rw-r--r--src/hero.mjs220
-rw-r--r--src/ircd.mjs (renamed from src/ircd.js)10
-rw-r--r--src/package.json3
-rw-r--r--src/utils.mjs (renamed from src/utils.js)37
-rw-r--r--src/web.mjs (renamed from src/web.js)8
-rw-r--r--tests/js/db.js16
-rw-r--r--tests/js/db.mjs15
-rw-r--r--tests/js/escape.mjs62
-rw-r--r--tests/js/hero.mjs1050
-rw-r--r--tests/js/ircd.js7
-rw-r--r--tests/js/ircd.mjs7
-rw-r--r--tests/js/utils.js130
-rw-r--r--tests/js/utils.mjs225
-rw-r--r--tests/js/web.js7
-rw-r--r--tests/js/web.mjs7
-rw-r--r--tests/runner.mjs (renamed from tests/runner.js)8
26 files changed, 1696 insertions, 292 deletions
diff --git a/.gitignore b/.gitignore
index ea353c9..f13a163 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1,2 @@
-/*.bin
-/*.so
-/src/config.h
-/src/*.o
-/src/*.lo
-/src/*.to
-/src/*.ta
-/src/*.t
-/src/*.c.txt
-/src/*.cat
/doc/*.[0-9]
/doc/*.3js
-/src/index.js
-/node_modules/
-/src/napi-sqlite.node
-/tests/tests-lib.o
-/tests/slurp.o
diff --git a/Makefile b/Makefile
index f150cf9..1dbf13e 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@ NAME = papo
NAME_UC = $(NAME)
URL = papo.im
MAILING_LIST = list@$(URL)
-TRANSLATIONS =
+LANGUAGES =
## Installation prefix. Defaults to "/usr".
PREFIX = /usr
BINDIR = $(PREFIX)/bin
@@ -42,27 +42,18 @@ include deps.mk
manpages = $(manpages.in:.in=)
sources = \
- $(sources.js) \
- src/cli \
+ $(sources.mjs) \
installable = \
$(sources.sql) \
- $(sources.js) \
- src/index.js \
- src/cli \
+ $(sources.mjs) \
+ src/package.json \
derived-assets = \
- $(NAME).bin \
$(manpages) \
- src/index.js \
- node_modules/dir.sentinel \
- node_modules/ \
- node_modules/$(NAME) \
side-assets = \
- src/logerr.c.txt \
- src/catalog.c.txt \
ircd.sock \
web.sock \
@@ -73,40 +64,23 @@ side-assets = \
all: $(derived-assets)
-$(NAME).bin:
- ln -fs src/cli $@
-
$(manpages): Makefile deps.mk
-src/index.js:
- ln -fs api.js $@
-
-node_modules/dir.sentinel:
- mkdir $(@D)
- touch $@
-
-node_modules/$(NAME): node_modules/dir.sentinel
- rm -f $@
- ln -s ../src $@
- touch $@
-
-node_modules/: node_modules/dir.sentinel node_modules/$(NAME)
+.SUFFIXES: .mjs .mjs-check
+tests.mjs-check = $(tests.mjs:.mjs=.mjs-check)
+$(tests.mjs-check):
+ node $*.mjs
-.SUFFIXES: .js .js-t
-tests.js-t = $(tests.js:.js=.js-t)
-$(tests.js-t):
- node $*.js
-
-check-unit: $(tests.js-t)
+check-unit: $(tests.mjs-check)
integration-tests = \
tests/cli-opts.sh \
-$(integration-tests): $(NAME).bin ALWAYS
- sh $@ $(EXEC)$(NAME).bin
+$(integration-tests): ALWAYS
+ sh $@ $(EXEC)src/bin.mjs
check-integration: $(integration-tests)
@@ -129,7 +103,7 @@ install: all
mkdir -p \
'$(DESTDIR)$(BINDIR)' \
'$(DESTDIR)$(JSLIBDIR)'
- ln -fs '$(DESTDIR)$(JSLIBDIR)'/cli '$(DESTDIR)$(BINDIR)'/$(NAME)
+ ln -fs '$(DESTDIR)$(JSLIBDIR)'/bin.mjs '$(DESTDIR)$(BINDIR)'/$(NAME)
for f in $(installable); do \
dir='$(DESTDIR)$(JSLIBDIR)'/"`dirname "$${f#src/}"`"; \
mkdir -p "$$dir"; \
@@ -140,11 +114,6 @@ install: all
mkdir -p "$$dir"; \
cp -P "$$f" "$$dir"; \
done
- for l in en $(TRANSLATIONS); do \
- dir='$(DESTDIR)$(LOCALEDIR)'/"$$l"/LC_MESSAGES; \
- mkdir -p "$$dir"; \
- cp src/$(NAME)."$$l".cat "$$dir"/$(NAME).cat; \
- done
sh tools/manpages.sh -ip '$(DESTDIR)$(MANDIR)' $(manpages)
## Uninstalls from $(DESTDIR)$(PREFIX). This is a perfect mirror
@@ -155,19 +124,16 @@ uninstall:
'$(DESTDIR)$(BINDIR)'/$(NAME) \
'$(DESTDIR)$(JSLIBDIR)' \
'$(DESTDIR)$(SRCDIR)'
- for l in en $(TRANSLATIONS); do \
- rm -f '$(DESTDIR)$(LOCALEDIR)'/"$$l"/LC_MESSAGES/$(NAME).cat; \
- done
sh tools/manpages.sh -up '$(DESTDIR)$(MANDIR)' $(manpages)
run-ircd: all
rm -f ircd.sock
- ./src/cli ircd ircd.sock
+ ./src/papo ircd ircd.sock
run-web: all
rm -f web.sock
- ./src/cli web web.sock
+ ./src/papo web web.sock
## Run the web and IRC server locally.
run: all
diff --git a/deps.mk b/deps.mk
index cc22142..076ad1a 100644
--- a/deps.mk
+++ b/deps.mk
@@ -1,21 +1,29 @@
-sources.js = \
- src/api.js \
- src/db.js \
- src/ircd.js \
- src/utils.js \
- src/web.js \
+sources.mjs = \
+ src/api.mjs \
+ src/bin.mjs \
+ src/db.mjs \
+ src/escape.mjs \
+ src/hero.mjs \
+ src/ircd.mjs \
+ src/utils.mjs \
+ src/web.mjs \
-tests.js = \
- tests/js/db.js \
- tests/js/ircd.js \
- tests/js/utils.js \
- tests/js/web.js \
+tests.mjs = \
+ tests/js/db.mjs \
+ tests/js/escape.mjs \
+ tests/js/hero.mjs \
+ tests/js/ircd.mjs \
+ tests/js/utils.mjs \
+ tests/js/web.mjs \
sources.sql = \
src/sql/migrations/2023-11-16T15:46:27-03:00-init-auth-data.sql \
+manpages.en.in = \
+ doc/papo.en.1.in \
+ doc/papo.en.3js.in \
+ doc/papo.recipes.en.7.in \
+ doc/papo.tutorial.en.7.in \
+ doc/papo.why.en.7.in \
-tests/js/db.js-t: tests/js/db.js
-tests/js/ircd.js-t: tests/js/ircd.js
-tests/js/utils.js-t: tests/js/utils.js
-tests/js/web.js-t: tests/js/web.js
+manpages.in = $(manpages.en.in)
diff --git a/index.mjs b/index.mjs
new file mode 100644
index 0000000..898ccb4
--- /dev/null
+++ b/index.mjs
@@ -0,0 +1 @@
+export * from "./api.mjs";
diff --git a/mkdeps.sh b/mkdeps.sh
index 43b7e03..954c62b 100755
--- a/mkdeps.sh
+++ b/mkdeps.sh
@@ -8,9 +8,9 @@ varlist() {
}
export LANG=POSIX.UTF-8
-find src/*.js -not -name index.js | sort | varlist 'sources.js'
-find tests/js/*.js | sort | varlist 'tests.js'
+find src/*.mjs | sort | varlist 'sources.mjs'
+find tests/js/*.mjs | sort | varlist 'tests.mjs'
find src/sql/migrations/*.sql | sort | varlist 'sources.sql'
+find doc/*.en.*.in | sort | varlist 'manpages.en.in'
-printf '\n'
-find tests/js/*.js | sort | sed 's|^\(.*\)$|\1-t: \1|'
+echo 'manpages.in = $(manpages.en.in)'
diff --git a/src/api.js b/src/api.mjs
index 7f0991f..27a14b1 100644
--- a/src/api.js
+++ b/src/api.mjs
@@ -1,8 +1,8 @@
-const { eq } = require("./utils.js");
-const ircd = require("./ircd.js");
-const web = require("./web.js");
+import { eq } from "./utils.mjs";
+import * as ircd from "./ircd.mjs";
+import * as web from "./web.mjs";
-const main = async () => {
+export const main = async () => {
if (process.argv.length === 3 && process.argv[2] === "-V") {
console.log("papo 1970-01-01 0.1.0");
return;
@@ -22,7 +22,3 @@ const main = async () => {
argv: process.argv,
});
};
-
-module.exports = {
- main,
-};
diff --git a/src/bin.mjs b/src/bin.mjs
new file mode 100755
index 0000000..5dbbb25
--- /dev/null
+++ b/src/bin.mjs
@@ -0,0 +1,4 @@
+#!/usr/bin/env node
+import { main } from "./api.mjs";
+
+main();
diff --git a/src/cli b/src/cli
deleted file mode 100755
index 1de3383..0000000
--- a/src/cli
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env node
-const { main } = require("papo");
-
-main();
diff --git a/src/db.js b/src/db.mjs
index acbc7b4..59d6a0e 100644
--- a/src/db.js
+++ b/src/db.mjs
@@ -1,15 +1,17 @@
-const assert = require("node:assert/strict");
-const fs = require("node:fs");
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import url from "node:url";
-const sqlite = require("./napi-sqlite.node");
-const { difference, log } = require("./utils");
+import { difference, log } from "./utils.mjs";
-const MIGRATIONS_DIR = __dirname + "/sql/migrations/";
+const DIRNAME = path.dirname(url.fileURLToPath(import.meta.url));
+const MIGRATIONS_DIR = DIRNAME + "/sql/migrations/";
const MIGRATION_FILENAMES = fs.readdirSync(MIGRATIONS_DIR, "UTF-8");
let db = null;
-const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => {
+export const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => {
assert(dbName);
db = await sqlite.open(dbName);
@@ -45,7 +47,3 @@ const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => {
}
await db.exec("COMMIT TRANSACTION;");
};
-
-module.exports = {
- init,
-};
diff --git a/src/escape.mjs b/src/escape.mjs
new file mode 100644
index 0000000..78e08f8
--- /dev/null
+++ b/src/escape.mjs
@@ -0,0 +1,14 @@
+const FROM = /[&<>'"]/g;
+
+const ESCAPES = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ "'": '&#39;',
+ '"': '&quot;'
+};
+
+const mappingFn = c => ESCAPES[c];
+
+export const escape = s =>
+ String.prototype.replace.call(s, FROM, mappingFn);
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) ]));
diff --git a/src/ircd.js b/src/ircd.mjs
index 6a02bd6..38903f8 100644
--- a/src/ircd.js
+++ b/src/ircd.mjs
@@ -1,18 +1,14 @@
-const net = require("node:net");
-const db = require("./db.js");
+import net from "node:net";
+import {} from "./db.mjs";
const server = net.createServer(socket => {
socket.write("olar\r\n");
socket.pipe(socket);
});
-const app = async udsPath => {
+export const app = async udsPath => {
await db.init();
server.listen(udsPath, () => {
console.log("I'm ircd.");
});
};
-
-module.exports = {
- app,
-};
diff --git a/src/package.json b/src/package.json
new file mode 100644
index 0000000..fb3cb61
--- /dev/null
+++ b/src/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "api.mjs"
+}
diff --git a/src/utils.js b/src/utils.mjs
index e1725ef..e0e20a7 100644
--- a/src/utils.js
+++ b/src/utils.mjs
@@ -1,4 +1,4 @@
-const eq = (a, b) => {
+export const eq = (a, b) => {
if (a === b) {
return true;
}
@@ -31,14 +31,14 @@ const eq = (a, b) => {
return true;
};
-const keys = (ks, obj) =>
+export const keys = (ks, obj) =>
ks.reduce(
(ret, k) =>
obj.hasOwnProperty(k) ? {...ret, [k]: obj[k]} : ret,
{},
);
-const difference = (a, b) => {
+export const difference = (a, b) => {
const diff = new Set(a);
for (const el of b) {
diff.delete(el);
@@ -46,12 +46,31 @@ const difference = (a, b) => {
return diff;
};
-const log = o => console.error(JSON.stringify(o));
+export const assocIn = (obj, path, value) =>
+ path.length === 0 ? obj :
+ path.length === 1 ? { ...obj, [path[0]]: value } :
+ {
+ ...obj,
+ [path[0]]: assocIn(
+ (obj[path[0]] || {}),
+ path.slice(1),
+ value
+ )
+ };
+export const getIn = (obj, path) =>
+ path.length === 0 ? obj :
+ getIn(obj?.[path[0]], path.slice(1));
-module.exports = {
- eq,
- keys,
- difference,
- log,
+export const first = (arr, fn) => {
+ for (const x of arr) {
+ const ret = fn(x);
+ if (ret) {
+ return ret;
+ }
+ }
+
+ return null;
};
+
+export const log = o => console.error(JSON.stringify(o));
diff --git a/src/web.js b/src/web.mjs
index bfa1807..8eed5b4 100644
--- a/src/web.js
+++ b/src/web.mjs
@@ -1,4 +1,4 @@
-const http = require("node:http");
+import http from "node:http";
const listProducts = () => {};
const getProduct = () => {};
@@ -23,12 +23,8 @@ const server = http.createServer((req, res) => {
res.end("Hello, web!\n");
});
-const app = udsPath => {
+export const app = udsPath => {
server.listen(udsPath, () => {
console.log("I'm web.");
});
};
-
-module.exports = {
- app,
-};
diff --git a/tests/js/db.js b/tests/js/db.js
deleted file mode 100644
index c99dd1b..0000000
--- a/tests/js/db.js
+++ /dev/null
@@ -1,16 +0,0 @@
-const { runTests } = require("../runner.js");
-const { init } = require("../../src/db.js");
-
-
-const test_init = t => {
- t.start("init()");
- t.test("FIXME", () => {
- // init();
- });
-};
-
-const tests = [
- test_init,
-];
-
-runTests(tests);
diff --git a/tests/js/db.mjs b/tests/js/db.mjs
new file mode 100644
index 0000000..e4a8a51
--- /dev/null
+++ b/tests/js/db.mjs
@@ -0,0 +1,15 @@
+import { runTests } from "../runner.mjs";
+import { init } from "../../src/db.mjs";
+
+
+const test_init = t => {
+ t.start("init()");
+ t.test("FIXME", () => {
+ // init();
+ });
+};
+
+
+await runTests([
+ test_init,
+]);
diff --git a/tests/js/escape.mjs b/tests/js/escape.mjs
new file mode 100644
index 0000000..2cad16a
--- /dev/null
+++ b/tests/js/escape.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import { runTests } from "../runner.mjs";
+import { escape } from "../../src/escape.mjs";
+
+const test_escape = t => {
+ t.start("escape()");
+
+ t.test("numbers", () => {
+ assert.equal(escape(0), "0");
+ assert.equal(escape(42), "42");
+ assert.equal(escape(-1), "-1");
+ });
+
+ t.test("object", () => {
+ assert.equal(escape({}), "[object Object]");
+ assert.equal(escape({ k: "v" }), "[object Object]");
+ });
+
+ t.test("string with special chars", () => {
+ assert.strictEqual(escape(`"`), "&quot;");
+ assert.strictEqual(escape(`"bar`), "&quot;bar");
+ assert.strictEqual(escape(`foo"`), "foo&quot;");
+ assert.strictEqual(escape(`foo"bar`), "foo&quot;bar");
+ assert.strictEqual(escape(`foo""bar`), "foo&quot;&quot;bar");
+
+ assert.strictEqual(escape("&"), "&amp;");
+ assert.strictEqual(escape("&bar"), "&amp;bar");
+ assert.strictEqual(escape("foo&"), "foo&amp;");
+ assert.strictEqual(escape("foo&bar"), "foo&amp;bar");
+ assert.strictEqual(escape("foo&&bar"), "foo&amp;&amp;bar");
+
+ assert.strictEqual(escape("'"), "&#39;");
+ assert.strictEqual(escape("'bar"), "&#39;bar");
+ assert.strictEqual(escape("foo'"), "foo&#39;");
+ assert.strictEqual(escape("foo'bar"), "foo&#39;bar");
+ assert.strictEqual(escape("foo''bar"), "foo&#39;&#39;bar");
+
+ assert.strictEqual(escape("<"), "&lt;");
+ assert.strictEqual(escape("<bar"), "&lt;bar");
+ assert.strictEqual(escape("foo<"), "foo&lt;");
+ assert.strictEqual(escape("foo<bar"), "foo&lt;bar");
+ assert.strictEqual(escape("foo<<bar"), "foo&lt;&lt;bar");
+
+ assert.strictEqual(escape(">"), "&gt;");
+ assert.strictEqual(escape(">bar"), "&gt;bar");
+ assert.strictEqual(escape("foo>"), "foo&gt;");
+ assert.strictEqual(escape("foo>bar"), "foo&gt;bar");
+ assert.strictEqual(escape("foo>>bar"), "foo&gt;&gt;bar");
+ });
+
+ t.test("the combination of all special characters", () => {
+ assert.strictEqual(
+ escape(`foo, "bar", 'baz' & <quux>`),
+ "foo, &quot;bar&quot;, &#39;baz&#39; &amp; &lt;quux&gt;",
+ );
+ });
+};
+
+
+await runTests([
+ test_escape,
+]);
diff --git a/tests/js/hero.mjs b/tests/js/hero.mjs
new file mode 100644
index 0000000..09e6e54
--- /dev/null
+++ b/tests/js/hero.mjs
@@ -0,0 +1,1050 @@
+import assert from "node:assert/strict";
+import { runTests } from "../runner.mjs";
+import { assocIn } from "../../src/utils.mjs";
+import {
+ normalizeSegments,
+ pathToSegments,
+ hasPathParams,
+ addRoute,
+ buildRoutes,
+ findStaticHandler,
+ firstParamMatch,
+ findDynamicHandler,
+ findHandler,
+ extractQueryParams,
+ handleRequest,
+ makeRequestListener,
+ interceptorsFn,
+ chainInterceptors,
+ wrapHandler,
+} from "../../src/hero.mjs";
+
+
+const test_normalizeSegments = t => {
+ t.start("normalizeSegments()");
+
+ t.test("unchanged when already normalized", () => {
+ assert.deepEqual(normalizeSegments([""]), [""]);
+ });
+
+ t.test("empty terminator added when missing", () => {
+ assert.deepEqual(normalizeSegments([]), [""]);
+ assert.deepEqual(normalizeSegments([" "]), [" ", ""]);
+ assert.deepEqual(normalizeSegments(["_"]), ["_", ""]);
+ assert.deepEqual(normalizeSegments(["a"]), ["a", ""]);
+ assert.deepEqual(normalizeSegments([":a"]), [":a", ""]);
+ assert.deepEqual(normalizeSegments(["path"]), ["path", ""]);
+ });
+};
+
+const test_pathToSegments = t => {
+ t.start("pathToSegments()");
+
+ t.test("simple paths", () => {
+ assert.deepEqual(pathToSegments("/"), [ "" ]);
+ assert.deepEqual(pathToSegments("/simple"), ["simple", ""]);
+ assert.deepEqual(pathToSegments("/simple/route"), ["simple", "route", ""]);
+
+ assert.deepEqual(pathToSegments("/api/user/:id"), ["api", "user", ":id", ""]);
+ assert.deepEqual(pathToSegments("/api/user/:id/info"), ["api", "user", ":id", "info", ""]);
+ });
+
+ t.test("extra separators", () => {
+ assert.deepEqual(pathToSegments("/api/health/"), ["api", "health", ""]);
+ assert.deepEqual(pathToSegments("/api///health"), ["api", "health", ""]);
+ assert.deepEqual(pathToSegments("//api///health//"), ["api", "health", ""]);
+ });
+};
+
+const test_hasPathParams = t => {
+ t.start("hasPathParam()");
+
+ t.test("has it", () => {
+ assert(hasPathParams(["some", ":path", ""]));
+ assert(hasPathParams(["path", ":params", ""]));
+ assert(hasPathParams(["api", "user", ":id", "info", ""]));
+ });
+
+ t.test("doesn't have it", () => {
+ assert(!hasPathParams([]));
+ assert(!hasPathParams([ "" ]));
+ assert(!hasPathParams(["some", "path", ""]));
+ assert(!hasPathParams(["s:o:m:e:", "p::ath:::"]));
+ assert(!hasPathParams([" :"]));
+ });
+};
+
+const test_addRoute = t => {
+ t.start("addRoute()");
+
+ const fn1 = () => {};
+
+ t.test("daily usage examples", () => {
+ assert.deepEqual(
+ addRoute({}, "GET", "/home", fn1),
+ { static: { GET: { home: { "": fn1 }}}},
+ );
+
+ assert.deepEqual(
+ addRoute({}, ["PUT", "POST", "PATCH"], "/api/user", fn1),
+ {
+ static: {
+ PUT: { api: { user: { "": fn1 }}},
+ POST: { api: { user: { "": fn1 }}},
+ PATCH: { api: { user: { "": fn1 }}},
+ },
+ },
+ );
+
+ assert.deepEqual(
+ addRoute({}, "*", "/settings", fn1),
+ {
+ static: {
+ GET: { settings: { "": fn1 }},
+ HEAD: { settings: { "": fn1 }},
+ POST: { settings: { "": fn1 }},
+ PUT: { settings: { "": fn1 }},
+ PATCH: { settings: { "": fn1 }},
+ DELETE: { settings: { "": fn1 }},
+ OPTIONS: { settings: { "": fn1 }},
+ },
+ },
+ );
+
+ assert.deepEqual(
+ addRoute({}, "GET", "/", fn1),
+ { static: { GET: { "": fn1 }}},
+ );
+
+ assert.deepEqual(
+ addRoute({}, "GET", "/api/user/:id", fn1),
+ { dynamic: { GET: { api: { user: { ":id": { "": fn1 }}}}}},
+ );
+
+ assert.deepEqual(
+ addRoute({}, ["PUT", "PATCH"], "/api/org/:orgid/member/:memberid", fn1),
+ {
+ dynamic: {
+ PUT: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}},
+ PATCH: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}},
+ },
+ },
+ );
+ });
+
+ t.test("bad method", () => {
+ assert.throws(
+ () => addRoute({}, "VERB", "/path", fn1),
+ assert.AssertionError,
+ );
+ });
+
+ t.test("empty methods array", () => {
+ assert.deepEqual(addRoute({}, [], "", fn1), {});
+ });
+
+ t.test("subpaths", () => {
+ const fn1 = () => {};
+ const fn2 = () => {};
+
+ const t1 = addRoute({}, "GET", "/home", fn1);
+ const t2 = addRoute(t1, "GET", "/home/details", fn2);
+ assert.deepEqual(
+ t2,
+ {
+ static: {
+ GET: {
+ home: {
+ "": fn1,
+ details: {
+ "": fn2,
+ },
+ },
+ },
+ },
+ },
+ );
+ });
+};
+
+const test_buildRoutes = t => {
+ t.start("buildRoutes()");
+
+ t.test("empty values", () => {
+ assert.deepEqual(buildRoutes([]), {});
+ });
+
+ t.test("overwrites", () => {
+ const fn1 = () => {};
+ const fn2 = () => {};
+
+ const r1 = [ [ "GET", "/", fn1 ] ];
+ const r2 = [ [ "GET", "/", fn2 ] ];
+
+ assert.deepEqual(
+ buildRoutes(r1),
+ {
+ static: {
+ GET: {
+ "": fn1,
+ },
+ },
+ },
+ );
+
+ assert.deepEqual(
+ buildRoutes(r2),
+ {
+ static: {
+ GET: {
+ "": fn2,
+ },
+ },
+ },
+ );
+
+ assert.deepEqual(
+ buildRoutes(r1.concat(r2)),
+ buildRoutes(r2),
+ );
+
+ assert.deepEqual(
+ buildRoutes(r2.concat(r1)),
+ buildRoutes(r1),
+ );
+ });
+
+ t.test("multiple routes built", () => {
+ const fn1 = () => {};
+ const fn2 = () => {};
+ const fn3 = () => {};
+ const fn4 = () => {};
+ const fn5 = () => {};
+
+ const routes = [
+ [ "GET", "/", fn1 ],
+ [ "GET", "/home", fn2 ],
+ [ "GET", "/user", fn3 ],
+ [ "POST", "/user", fn4 ],
+ [ "GET", "/user/:id", fn5 ],
+ ];
+
+ assert.deepEqual(
+ buildRoutes(routes),
+ {
+ static: {
+ GET: {
+ "": fn1,
+ home: {
+ "": fn2,
+ },
+ user: {
+ "": fn3,
+ },
+ },
+ POST: {
+ user: {
+ "": fn4,
+ },
+ },
+ },
+ dynamic: {
+ GET: {
+ user: {
+ ":id": {
+ "": fn5,
+ }
+ }
+ }
+ },
+ },
+ );
+ });
+};
+
+const test_findStaticHandler = t => {
+ t.start("findStaticHandler()");
+
+ t.test("multiple accesses to the same table", () => {
+ const fn1 = () => {};
+ const fn2 = () => {};
+ const fn3 = () => {};
+
+ const table = {
+ static: {
+ GET: {
+ api: {
+ home: { "": fn1 },
+ settings: { "": fn2 },
+ },
+ },
+ POST: {
+ api: {
+ settings: { "": fn3 },
+ },
+ },
+ },
+ };
+
+ assert.deepEqual(
+ findStaticHandler(table, "GET", [ "api", "home", "" ]),
+ { handlerFn: fn1, params: {} },
+ );
+ assert.deepEqual(
+ findStaticHandler(table, "PUT", [ "api", "home", "" ]),
+ null,
+ );
+
+ assert.deepEqual(
+ findStaticHandler(table, "GET", [ "api", "settings", "" ]),
+ { handlerFn: fn2, params: {} },
+ );
+ assert.deepEqual(
+ findStaticHandler(table, "POST", [ "api", "settings", "" ]),
+ { handlerFn: fn3, params: {} },
+ );
+ assert.deepEqual(
+ findStaticHandler(table, "PUT", [ "api", "settings", "" ]),
+ null,
+ );
+
+ assert.deepEqual(
+ findStaticHandler(table, "GET", [ "api", "profile", "" ]),
+ null,
+ );
+
+ assert.deepEqual(
+ findStaticHandler({}, "GET", [ "api", "profile", "" ]),
+ null,
+ );
+ });
+};
+
+const test_firstParamMatch = t => {
+ t.start("firstParamMatch()");
+
+ const params = {};
+ const fn1 = () => {};
+ const fn2 = () => {};
+
+ t.test("we BACKTRACK when searching down routes", () => {
+ const segments = [ "path", "split", "match", "" ];
+
+ const tree = {
+ path: {
+ split: {
+ MISMATCH: {
+ "": fn1,
+ },
+ },
+ ":param": {
+ match: {
+ "": fn2,
+ },
+ },
+ },
+ };
+
+ assert.deepEqual(
+ firstParamMatch(tree, segments, params),
+ { handlerFn: fn2, params: { param: "split" }},
+ );
+ });
+
+ t.test("ambiguous route prefers params at the end", () => {
+ const segments = [ "path", "param1", "param2", "" ];
+
+ const tree1 = {
+ path: {
+ ":shallower": {
+ param2: {
+ "": fn2,
+ },
+ },
+ },
+ };
+
+ const tree2 = assocIn(tree1, [ "path", "param1", ":deeper", "" ], fn1);
+
+ assert.deepEqual(
+ firstParamMatch(tree1, segments, params),
+ { handlerFn: fn2, params: { shallower: "param1" }},
+ );
+
+ assert.deepEqual(
+ firstParamMatch(tree2, segments, params),
+ { handlerFn: fn1, params: { deeper: "param2" }},
+ );
+ });
+
+ t.test("when 2 params are possible, we pick the first alphabetically", () => {
+ const segments = [ "user", "someId", "" ];
+
+ const tree1 = {
+ user: {
+ ":bbb": {
+ "": fn2,
+ },
+ },
+ };
+
+ const tree2 = assocIn(tree1, ["user", ":aaa", ""], fn1);
+
+ assert.deepEqual(
+ firstParamMatch(tree1, segments, params),
+ { handlerFn: fn2, params: { bbb: "someId" }},
+ );
+
+ assert.deepEqual(
+ firstParamMatch(tree2, segments, params),
+ { handlerFn: fn1, params: { aaa: "someId" }},
+ );
+ });
+};
+
+const test_findDynamicHandler = t => {
+ t.start("findDynamicHandler()");
+
+ t.test("daily usage cases", () => {
+ const fn1 = () => {};
+ const fn2 = () => {};
+
+ const table = {
+ dynamic: {
+ GET: {
+ users: {
+ "by-id": {
+ ":id": { "": fn1 },
+ },
+ },
+ },
+ PUT: {
+ user: {
+ ":user-id": {
+ info: { "": fn2 },
+ },
+ },
+ },
+ },
+ };
+
+ assert.deepEqual(
+ findDynamicHandler(table, "GET", [ "users", "by-id", "123", "" ]),
+ { handlerFn: fn1, params: { "id": "123" }},
+ );
+
+ assert.deepEqual(
+ findDynamicHandler({}, "GET", [ "users", "by-id", "123", "" ]),
+ null,
+ );
+
+ assert.deepEqual(
+ findDynamicHandler({ dynamic: { GET: { "": fn1 }}}, "GET", [ "users", "by-id", "123", "" ]),
+ null,
+ );
+
+ assert.deepEqual(
+ findDynamicHandler(table, "PUT", [ "user", "deadbeef", "info", "" ]),
+ { handlerFn: fn2, params: { "user-id": "deadbeef" }},
+ );
+ });
+};
+
+const test_findHandler = t => {
+ t.start("findHandler()");
+
+ t.test("mix of static and dynamic routes", () => {
+ const static1 = () => {};
+ const static2 = () => {};
+ const static3 = () => {};
+ const dynamic1 = () => {};
+ const dynamic2 = () => {};
+ const dynamic3 = () => {};
+ const dynamic4 = () => {};
+
+ const table = {
+ static: {
+ GET: {
+ user: {
+ "": static1,
+ },
+ pages: {
+ "": static2,
+ home: {
+ "": static3,
+ },
+ },
+ },
+ },
+ dynamic: {
+ GET: {
+ user: {
+ ":id": {
+ "": dynamic1,
+ },
+ },
+ },
+ PUT: {
+ user: {
+ ":id": {
+ "": dynamic2,
+ "info": {
+ "": dynamic3,
+ },
+ "preferences": {
+ "": dynamic4,
+ },
+ },
+ },
+ },
+ },
+ };
+
+ assert.deepEqual(
+ findHandler(table, "GET", "/"),
+ null,
+ );
+
+ assert.deepEqual(
+ findHandler(table, "GET", "/user/"),
+ { handlerFn: static1, params: {} },
+ );
+
+ assert.deepEqual(
+ findHandler(table, "GET", "/pages"),
+ { handlerFn: static2, params: {} },
+ );
+ assert.deepEqual(
+ findHandler(table, "GET", "/pages/home/"),
+ { handlerFn: static3, params: {} },
+ );
+
+ assert.deepEqual(
+ findHandler(table, "GET", "/user/some-id"),
+ { handlerFn: dynamic1, params: { id: "some-id" }},
+ );
+ assert.deepEqual(
+ findHandler(table, "GET", "/user/other-id/info"),
+ null,
+ );
+
+ assert.deepEqual(
+ findHandler(table, "PUT", "/user/other-id/info"),
+ { handlerFn: dynamic3, params: { id: "other-id" }},
+ );
+ assert.deepEqual(
+ findHandler(table, "PUT", "/user/another-id/preferences"),
+ { handlerFn: dynamic4, params: { id: "another-id" }},
+ );
+ assert.deepEqual(
+ findHandler(table, "POST", "/user/another-id/preferences"),
+ null,
+ );
+ });
+};
+
+const test_extractQueryParams = t => {
+ t.start("extractQueryParams()");
+
+ t.test("empty values", () => {
+ assert.deepEqual(extractQueryParams(), {});
+ assert.deepEqual(extractQueryParams(null), {});
+ assert.deepEqual(extractQueryParams(undefined), {});
+ });
+
+ t.test("we get a flat key-value strings", () => {
+ assert.deepEqual(
+ extractQueryParams("a[]=1&b=text&c=___"),
+ {
+ "a[]": "1",
+ b: "text",
+ c: "___",
+ },
+ );
+ });
+
+ t.test("duplicate values are suppressed, deterministically", () => {
+ assert.deepEqual(extractQueryParams("a=1&a=2&a=3"), { a: "3" });
+ assert.deepEqual(extractQueryParams("a=1&b=2&a=3"), { a: "3", b: "2" });
+ });
+};
+
+const test_handleRequest = t => {
+ t.start("handleRequest()");
+
+ const fn = req => req;
+
+ t.test("request without params", async () => {
+ const table = {
+ static: {
+ GET: {
+ "": fn,
+ },
+ },
+ };
+
+ assert.deepEqual(
+ await handleRequest(table, "GET", "/?q=1"),
+ {
+ params: {
+ path: {},
+ query: {
+ q: "1",
+ },
+ },
+ method: "GET",
+ path: "/",
+ handler: fn,
+ },
+ );
+ });
+
+ t.test("request with params", async () => {
+ const table = {
+ dynamic: {
+ PUT: {
+ api: {
+ user: {
+ ":userId": {
+ "": fn,
+ },
+ },
+ },
+ },
+ },
+ };
+
+ assert.deepEqual(
+ await handleRequest(table, "PUT", "/api/user/2222"),
+ {
+ params: {
+ path: {
+ userId: "2222",
+ },
+ query: {},
+ },
+ method: "PUT",
+ path: "/api/user/2222",
+ handler: fn,
+ },
+ );
+ });
+
+ t.test("missing route", async () => {
+ assert.deepEqual(
+ await handleRequest({}, "GET", "/"),
+ {
+ status: 404,
+ body: "Not Found",
+ },
+ );
+ });
+};
+
+const test_makeRequestListener = t => {
+ t.start("makeRequestListener()");
+
+ t.test("straightforward body execution", async () => {
+ const fn = _ => ({
+ status: "test status",
+ body: "test body",
+ headers: "test headers",
+ });
+ const routes = [[ "GET", "/route1", fn ]];
+ const requestListener = makeRequestListener(buildRoutes(routes));
+ const req = {
+ method: "GET",
+ url: "/route1",
+ };
+
+ const heads = [];
+ const bodies = [];
+ const res = {
+ writeHead: (status, headers) => heads.push({ status, headers }),
+ end: body => bodies.push(body),
+ };
+ await requestListener(req, res);
+
+ assert.deepEqual(
+ heads,
+ [{
+ status: "test status",
+ headers: "test headers",
+ }],
+ );
+ assert.deepEqual(
+ bodies,
+ [ "test body" ],
+ );
+ });
+
+ t.test("we break if handleRequest() throws an error", async () => {
+ const fn = _ => { throw new Error("handler error"); };
+ const routes = [[ "GET", "/route2", fn ]];
+ const requestListener = makeRequestListener(buildRoutes(routes));
+ const req = {
+ method: "GET",
+ url: "/route2",
+ };
+
+ const heads = [];
+ const bodies = [];
+ const res = {
+ writeHead: (status, headers) => heads.push({ status, headers }),
+ end: body => bodies.push(body),
+ };
+
+ await assert.rejects(
+ async () => await requestListener(req, res),
+ { message: "handler error" },
+ );
+
+ assert.deepEqual(heads, []);
+ assert.deepEqual(bodies, []);
+ });
+};
+
+const test_interceptorsFn = t => {
+ const next = x => x;
+
+ {
+ t.start("interceptorsFn().requestId()");
+
+ let i = 0;
+ const uuidFn = () => `${i++}`;
+
+ t.test("we add an id to whatever we receive", () => {
+ assert.deepEqual(
+ interceptorsFn({uuidFn}).requestId({}, next),
+ {
+ id: "0",
+ },
+ );
+ assert.deepEqual(
+ interceptorsFn({uuidFn}).requestId({ a: "existing data" }, next),
+ {
+ a: "existing data",
+ id: "1",
+ },
+ );
+ });
+
+ t.test(`we overwrite the "id" if it already exists`, async () => {
+ assert.deepEqual(
+ interceptorsFn({uuidFn}).requestId({ id: "before" }, next),
+ {
+ id: "2",
+ },
+ );
+ });
+ }
+
+ {
+ t.start("interceptorsFn().logged()");
+
+ t.test("we log before and after next()", async () => {
+ const contents = [];
+ const logger = x => contents.push(x);
+ const status = 201;
+ const req = {
+ id: "an ID",
+ url: "a URL",
+ method: "a method",
+ };
+
+ assert.deepEqual(
+ await interceptorsFn({logger}).logged(req, _ => ({ status })),
+ { status },
+ );
+ assert.deepEqual(
+ contents,
+ [
+ { ...req, type: "in-request" },
+ { id: req.id, status, type: "in-response" },
+ ],
+ );
+ });
+ }
+
+ {
+ t.start("interceptorsFn().contentType()");
+
+ t.test("empty values", async () => {
+ assert.deepEqual(
+ await interceptorsFn().contentType({}, next),
+ {
+ body: "",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": 0,
+ },
+ },
+ );
+
+ assert.deepEqual(
+ await interceptorsFn().contentType({ body: "" }, next),
+ {
+ body: "",
+ headers: {
+ "Content-Type": "text/html",
+ "Content-Length": 0,
+ },
+ },
+ );
+ });
+
+ t.test("body values", async () => {
+ assert.deepEqual(
+ await interceptorsFn().contentType({ body: { a: 1 }}, next),
+ {
+ body: `{"a":1}`,
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": 7,
+ },
+ },
+ );
+
+ assert.deepEqual(
+ await interceptorsFn().contentType({ body: "<br />" }, next),
+ {
+ body: "<br />",
+ headers: {
+ "Content-Type": "text/html",
+ "Content-Length": 6,
+ },
+ },
+ );
+ });
+
+ t.test("header values preference", async () => {
+ assert.deepEqual(
+ await interceptorsFn().contentType({
+ body: "",
+ headers: {
+ "Content-Type": "we have preference",
+ "Content-Length": "and so do we",
+ },
+ }, next),
+ {
+ body: "",
+ headers: {
+ "Content-Type": "we have preference",
+ "Content-Length": "and so do we",
+ },
+ }
+ );
+ });
+
+ t.test("headers get propagated", async () => {
+ assert.deepEqual(
+ await interceptorsFn().contentType({
+ body: "",
+ headers: {
+ "My": "Header",
+ },
+ }, next),
+ {
+ body: "",
+ headers: {
+ "My": "Header",
+ "Content-Type": "text/html",
+ "Content-Length": 0,
+ },
+ },
+ );
+ });
+ }
+
+ {
+ t.start("interceptorsFn().serverError()");
+
+ t.test("no-op when no error occurs", async () => {
+ assert.deepEqual(
+ await interceptorsFn().serverError({ status: 1 }, next),
+ { status: 1 },
+ );
+ });
+
+ t.test(`an error is thrown if "status" is missing`, async () => {
+ const contents = [];
+ const logger = x => contents.push(x);
+ assert.deepEqual(
+ await interceptorsFn({ logger }).serverError({ id: 123 }, next),
+ {
+ status: 500,
+ body: "Internal Server Error",
+ },
+ );
+ assert.deepEqual(
+ contents,
+ [{
+ id: 123,
+ type: "server-error-interceptor",
+ message: `Missing "status"`,
+ }],
+ );
+ });
+
+ t.test("we turn a handler error into a 500 response", async () => {
+ const contents = [];
+ const logger = x => contents.push(x);
+ assert.deepEqual(
+ await interceptorsFn({ logger }).serverError(
+ { id: "some ID" },
+ _ => { throw new Error("My test error message"); },
+ ),
+ {
+ status: 500,
+ body: "Internal Server Error",
+ },
+ );
+ assert.deepEqual(
+ contents,
+ [{
+ id: "some ID",
+ type: "server-error-interceptor",
+ message: "My test error message",
+ }],
+ );
+ });
+ }
+};
+
+const test_chainInterceptors = t => {
+ t.start("chainInterceptors()");
+
+ t.test("empty values", () => {
+ assert.equal(chainInterceptors([])("req"), "req");
+ });
+
+ t.test("the order of interceptors matter", () => {
+ const a = [];
+
+ const i1 = (req, next) => {
+ a.push("i1");
+ return next(req);
+ };
+ const i2 = (req, next) => {
+ a.push("i2");
+ return next(req);
+ };
+
+ assert.equal(chainInterceptors([i1, i2])("req"), "req");
+ assert.deepEqual(a, ["i1", "i2"]);
+ assert.equal(chainInterceptors([i2, i1])("req"), "req");
+ assert.deepEqual(a, ["i1", "i2", "i2", "i1"]);
+ });
+
+ t.test("with ordering, interceptors implicitly depend on each other", () => {
+ const i1 = (req, next) => next({ ...req, id: 1 });
+ const i2 = (req, next) => next({ ...req, id: req.id + 1 });
+
+ assert.deepEqual(
+ chainInterceptors([i1, i2])({}),
+ { id: 2 },
+ );
+
+ assert.deepEqual(
+ chainInterceptors([i2])({}),
+ { id: NaN },
+ );
+
+ assert.deepEqual(
+ chainInterceptors([i2, i1])({}),
+ { id: 1 },
+ );
+ });
+
+ t.test("we can chain async interceptors", async () => {
+ const i1 = async (req, next) => await next({ ...req, i1: true });
+ const i2 = async (req, next) => await next({ ...req, i2: true });
+
+ assert.deepEqual(
+ await chainInterceptors([i1, i2])({}),
+ {
+ i1: true,
+ i2: true,
+ },
+ );
+ });
+};
+
+const test_wrapHandler = t => {
+ t.start("wrapHandler()");
+
+ t.test("a handler with chained interceptors change its behaviour", async () => {
+ let i = 0;
+ const uuidFn = () => `${i++}`;
+
+ const contents = [];
+ const logger = x => contents.push(x);
+
+ const interceptors = interceptorsFn({uuidFn, logger});
+
+ const fn = async _ => await { status: 1, body: { a: 1 }};
+ const wrappedFn = wrapHandler(fn, [
+ interceptors.requestId,
+ interceptors.logged,
+ interceptors.contentType,
+ ]);
+
+ const req = {
+ url: "URL",
+ method: "METHOD",
+ };
+
+ assert.deepEqual(
+ await fn(req),
+ { status: 1, body: { a: 1 }},
+ );
+ assert.deepEqual(contents, []);
+
+ assert.deepEqual(
+ await wrappedFn(req),
+ {
+ status: 1,
+ body: `{"a":1}`,
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": 7,
+ },
+ },
+ );
+ assert.deepEqual(
+ contents,
+ [
+ {
+ id: "0",
+ url: "URL",
+ method: "METHOD",
+ type: "in-request",
+ },
+ {
+ id: "0",
+ status: 1,
+ type: "in-response",
+ },
+ ],
+ );
+ });
+};
+
+
+await runTests([
+ test_normalizeSegments,
+ test_pathToSegments,
+ test_hasPathParams,
+ test_addRoute,
+ test_buildRoutes,
+ test_findStaticHandler,
+ test_firstParamMatch,
+ test_findDynamicHandler,
+ test_findHandler,
+ test_extractQueryParams,
+ test_handleRequest,
+ test_makeRequestListener,
+ test_interceptorsFn,
+ test_chainInterceptors,
+ test_wrapHandler,
+]);
diff --git a/tests/js/ircd.js b/tests/js/ircd.js
deleted file mode 100644
index 08bb6dc..0000000
--- a/tests/js/ircd.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const { runTests } = require("../runner.js");
-const { } = require("../../src/ircd.js");
-
-const tests = [
-];
-
-runTests(tests);
diff --git a/tests/js/ircd.mjs b/tests/js/ircd.mjs
new file mode 100644
index 0000000..e5eda66
--- /dev/null
+++ b/tests/js/ircd.mjs
@@ -0,0 +1,7 @@
+import { runTests } from "../runner.mjs";
+import { } from "../../src/ircd.mjs";
+
+
+
+await runTests([
+]);
diff --git a/tests/js/utils.js b/tests/js/utils.js
deleted file mode 100644
index 670d89a..0000000
--- a/tests/js/utils.js
+++ /dev/null
@@ -1,130 +0,0 @@
-const assert = require("node:assert");
-
-const { runTests } = require("../runner.js");
-const { eq, keys } = require("../../src/utils.js");
-
-const test_eq = t => {
- t.start("eq()");
- t.test("scalar values equality", () => {
- assert(eq(0, 0));
- assert(eq(100, 100));
- assert(eq(1.5, 1.5));
- assert(eq(-9, -9));
-
- assert(!eq(0, 1));
- assert(!eq(100, 99));
- assert(!eq(1.5, 1.4));
- assert(!eq(-9, 9));
-
-
- assert(eq(null, null));
- assert(eq(undefined, undefined));
- assert(eq("", ""));
- assert(eq("a string", "a string"));
-
- assert(!eq(null, undefined));
- assert(!eq(undefined, 0));
- assert(!eq("", "0"));
- assert(!eq("a string", "another string"));
-
-
- assert(eq(true, true));
- assert(eq(false, false));
-
- assert(!eq(true, false));
-
-
- assert(eq(1n, 1n));
- assert(eq(99999999999999n, 99999999999999n));
- });
-
- t.test("array equality", () => {
- assert(eq([], []));
-
-
- assert(eq([0, 1, 2], [0, 1, 2]));
- assert(eq([0, 1, 2], new Array(0, 1, 2)));
-
- assert(!eq([0, 1, 2], [0, 1]));
- assert(!eq([0, 1], new Array(0, 1, 2)));
-
-
- assert(eq([undefined], [undefined]));
- assert(eq([null, 0, "", true], [null, 0, "", true]));
-
-
- assert(eq([[[[0]]]], [[[[0]]]]));
-
- assert(!eq([[[[0]]]], [0]));
- });
-
- t.test("object equality", () => {
- assert(eq({}, {}));
- assert(eq({ a: 1 }, { a: 1 }));
-
- assert(!eq({ a: 1, b: undefined }, { a: 1 }));
-
-
- assert(eq(
- { a: 1, b: { c: { d: "e" } } },
- { a: 1, b: { c: { d: "e" } } },
- ));
-
- class C {}
- // ... FIXME: finish
- });
-
- t.test("mixed values", () => {
- assert(eq(
- {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]},
- {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]},
- ));
-
- assert(!eq(null, {}));
-
- assert(!eq([], {}));
- });
-};
-
-const test_keys = t => {
- t.start("keys()");
- t.test("happy paths", () => {
- assert.deepEqual(
- { a: 1, b: 2 },
- keys(["a", "b"], { a: 1, b: 2, c: 3 }),
- );
- });
-
- t.test("stress scenarios", () => {
- assert.deepEqual(
- {},
- keys([], {}),
- "empty selection of empty object",
- );
-
- assert.deepEqual(
- {},
- keys([], {a: 1}),
- "empty selection of non-empty object",
- );
-
- assert.deepEqual(
- {},
- keys(["a"], {}),
- "non-empty selection of empty object",
- );
-
- assert.deepEqual(
- { a: undefined, b: null },
- keys(["a", "b", "c"], { a: undefined, b: null }),
- "falsy values",
- );
- });
-};
-
-const tests = [
- test_eq,
- test_keys,
-];
-
-runTests(tests);
diff --git a/tests/js/utils.mjs b/tests/js/utils.mjs
new file mode 100644
index 0000000..1875ce5
--- /dev/null
+++ b/tests/js/utils.mjs
@@ -0,0 +1,225 @@
+import assert from "node:assert/strict";
+
+import { runTests } from "../runner.mjs";
+import {
+ eq,
+ keys,
+ assocIn,
+ getIn,
+ first,
+ log,
+} from "../../src/utils.mjs";
+
+const test_eq = t => {
+ t.start("eq()");
+ t.test("scalar values equality", () => {
+ assert(eq(0, 0));
+ assert(eq(100, 100));
+ assert(eq(1.5, 1.5));
+ assert(eq(-9, -9));
+
+ assert(!eq(0, 1));
+ assert(!eq(100, 99));
+ assert(!eq(1.5, 1.4));
+ assert(!eq(-9, 9));
+
+
+ assert(eq(null, null));
+ assert(eq(undefined, undefined));
+ assert(eq("", ""));
+ assert(eq("a string", "a string"));
+
+ assert(!eq(null, undefined));
+ assert(!eq(undefined, 0));
+ assert(!eq("", "0"));
+ assert(!eq("a string", "another string"));
+
+
+ assert(eq(true, true));
+ assert(eq(false, false));
+
+ assert(!eq(true, false));
+
+
+ assert(eq(1n, 1n));
+ assert(eq(99999999999999n, 99999999999999n));
+ });
+
+ t.test("array equality", () => {
+ assert(eq([], []));
+
+
+ assert(eq([0, 1, 2], [0, 1, 2]));
+ assert(eq([0, 1, 2], new Array(0, 1, 2)));
+
+ assert(!eq([0, 1, 2], [0, 1]));
+ assert(!eq([0, 1], new Array(0, 1, 2)));
+
+
+ assert(eq([undefined], [undefined]));
+ assert(eq([null, 0, "", true], [null, 0, "", true]));
+
+
+ assert(eq([[[[0]]]], [[[[0]]]]));
+
+ assert(!eq([[[[0]]]], [0]));
+ });
+
+ t.test("object equality", () => {
+ assert(eq({}, {}));
+ assert(eq({ a: 1 }, { a: 1 }));
+
+ assert(!eq({ a: 1, b: undefined }, { a: 1 }));
+
+
+ assert(eq(
+ { a: 1, b: { c: { d: "e" } } },
+ { a: 1, b: { c: { d: "e" } } },
+ ));
+ });
+
+ t.test("mixed values", () => {
+ assert(eq(
+ {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]},
+ {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]},
+ ));
+
+ assert(!eq(null, {}));
+
+ assert(!eq([], {}));
+ });
+};
+
+const test_keys = t => {
+ t.start("keys()");
+ t.test("happy paths", () => {
+ assert.deepEqual(
+ { a: 1, b: 2 },
+ keys(["a", "b"], { a: 1, b: 2, c: 3 }),
+ );
+ });
+
+ t.test("stress scenarios", () => {
+ assert.deepEqual(
+ {},
+ keys([], {}),
+ "empty selection of empty object",
+ );
+
+ assert.deepEqual(
+ {},
+ keys([], {a: 1}),
+ "empty selection of non-empty object",
+ );
+
+ assert.deepEqual(
+ {},
+ keys(["a"], {}),
+ "non-empty selection of empty object",
+ );
+
+ assert.deepEqual(
+ { a: undefined, b: null },
+ keys(["a", "b", "c"], { a: undefined, b: null }),
+ "falsy values",
+ );
+ });
+};
+
+const test_assocIn = t => {
+ t.start("assocIn()");
+
+ t.test("empty values", () => {
+ assert.deepEqual(assocIn({}, [], null), {});
+ assert.deepEqual(assocIn({ k: "v" }, [], null), { k: "v" });
+ });
+
+ t.test("adding values", () => {
+ assert.deepEqual(assocIn({}, ["k"], "v"), { k: "v" });
+ assert.deepEqual(assocIn({}, ["k1", "k2"], "v"), { k1: { k2: "v" }});
+ assert.deepEqual(assocIn({}, ["k1", "k2", "k3"], "v"), { k1: { k2: { k3: "v" }}});
+ assert.deepEqual(assocIn({ k: "v" }, ["k1", "k2"], "v"), { k: "v", k1: { k2: "v" }});
+ });
+
+ t.test("replacing values", () => {
+ assert.deepEqual(
+ assocIn(
+ { k1: { k2: { k3: "before" }}},
+ ["k1", "k2", "k3"],
+ "after"
+ ),
+ { k1: { k2: { k3: "after" }}}
+ );
+ });
+};
+
+const test_getIn = t => {
+ t.start("getIn()");
+
+ t.test("empty values", () => {
+ assert.deepEqual(getIn({}, []), {});
+ assert.deepEqual(getIn({ k: "v" }, []), { k: "v" });
+ });
+
+ t.test("missing values", () => {
+ assert.deepEqual(getIn({}, ["a", "b", "c"]), undefined);
+ assert.deepEqual(getIn({ a: {}}, ["a", "b", "c"]), undefined);
+ assert.deepEqual(getIn({ a: { b: {}}}, ["a", "b", "c"]), undefined);
+ assert.deepEqual(getIn({ a: { b: {}, c: {}}}, ["a", "b", "c"]), undefined);
+ });
+
+ t.test("nested valeues", () => {
+ assert.deepEqual(getIn({ a: { b: { c: { d: "e" }}}}, ["a", "b", "c", "d"]), "e");
+ });
+};
+
+const test_first = t => {
+ t.start("first()");
+
+ t.test("empty values", () => {
+ assert.equal(first([], () => {}), null);
+ });
+
+ t.test("when function doesn't transform, it behaves similarly to [].find()", () => {
+ const arr1 = [ 0, null, undefined, "", 1, 2 ];
+ assert.equal(first(arr1, x => x), 1);
+ assert.equal(arr1.find(x => x), 1);
+
+ const arr2 = [ 0, null, undefined, "", false ];
+ assert.equal(first(arr2, x => x), null);
+ assert.equal(arr2.find(x => x), undefined);
+ });
+
+ t.test("when it does transform, we return the transformed value", () => {
+ const arr = [ 1, 3, 5, 6 ];
+
+ assert.equal(
+ first(arr, x => x % 2 === 0 && "a brand new value"),
+ "a brand new value",
+ );
+ });
+};
+
+const test_log = t => {
+ t.start("log()");
+
+ t.test("we can log data", () => {
+ log({ a: 1, type: "log-test" });
+ });
+
+ t.test("we can't log unserializable things", () => {
+ const obj = { self: null };
+ obj.self = obj;
+ assert.throws(() => log(obj), TypeError);
+ });
+};
+
+
+await runTests([
+ test_eq,
+ test_keys,
+ test_assocIn,
+ test_getIn,
+ test_first,
+ test_log,
+]);
diff --git a/tests/js/web.js b/tests/js/web.js
deleted file mode 100644
index 6562433..0000000
--- a/tests/js/web.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const { runTests } = require("../runner.js");
-const { } = require("../../src/web.js");
-
-const tests = [
-];
-
-runTests(tests);
diff --git a/tests/js/web.mjs b/tests/js/web.mjs
new file mode 100644
index 0000000..00cce70
--- /dev/null
+++ b/tests/js/web.mjs
@@ -0,0 +1,7 @@
+import { runTests } from "../runner.mjs";
+import { } from "../../src/web.mjs";
+
+
+
+await runTests([
+]);
diff --git a/tests/runner.js b/tests/runner.mjs
index 4772a28..79bda8c 100644
--- a/tests/runner.js
+++ b/tests/runner.mjs
@@ -1,4 +1,4 @@
-const { eq } = require("../src/utils.js");
+import { eq } from "../src/utils.mjs";
class AssertionError extends Error {}
@@ -25,12 +25,8 @@ const t = {
},
};
-const runTests = async tests => {
+export const runTests = async tests => {
for (const testFn of tests) {
await testFn(t);
}
};
-
-module.exports = {
- runTests,
-};