@nanobpm/urban 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +93 -16
  2. package/package.json +16 -11
  3. package/src/cli.ts +6 -5
  4. package/src/index.ts +46 -0
  5. package/src/runtime/adapters/deno.ts +124 -0
  6. package/src/runtime/adapters/detect.ts +22 -0
  7. package/src/runtime/adapters/node.ts +119 -0
  8. package/src/runtime/core/context.ts +35 -0
  9. package/src/runtime/core/glob.test.ts +45 -0
  10. package/src/runtime/core/glob.ts +63 -0
  11. package/src/runtime/core/host.ts +130 -0
  12. package/src/runtime/core/manifest.test.ts +31 -0
  13. package/src/runtime/core/manifest.ts +112 -0
  14. package/src/runtime/core/modules/datasource.ts +220 -0
  15. package/src/runtime/core/modules/deploy.ts +41 -0
  16. package/src/runtime/core/modules/security.ts +53 -0
  17. package/src/runtime/core/modules/surfaces.test.ts +42 -0
  18. package/src/runtime/core/modules/surfaces.ts +103 -0
  19. package/src/runtime/core/modules/triggers.ts +143 -0
  20. package/src/runtime/core/modules/units.test.ts +22 -0
  21. package/src/runtime/core/modules/workers.ts +96 -0
  22. package/src/runtime/core/purity.test.ts +31 -0
  23. package/src/runtime/core/router.test.ts +16 -0
  24. package/src/runtime/core/router.ts +55 -0
  25. package/src/runtime/core/runtime.test.ts +258 -0
  26. package/src/runtime/core/runtime.ts +213 -0
  27. package/src/runtime/core/validate.test.ts +73 -0
  28. package/src/runtime/core/validate.ts +129 -0
  29. package/src/runtime/engine/nanosdk.test.ts +124 -0
  30. package/src/runtime/engine/nanosdk.ts +123 -0
  31. package/src/runtime/engine/rest.ts +243 -0
  32. package/src/runtime/index.ts +68 -0
  33. package/src/runtime/run.ts +91 -0
  34. package/src/runtime/schema/nano-app.schema.json +471 -0
  35. package/src/toolkit/artifact.ts +29 -0
  36. package/src/toolkit/derivers/migrations.test.ts +50 -0
  37. package/src/toolkit/derivers/migrations.ts +99 -0
  38. package/src/toolkit/derivers/worker-io.test.ts +75 -0
  39. package/src/toolkit/derivers/worker-io.ts +176 -0
  40. package/src/toolkit/fsio.test.ts +21 -0
  41. package/src/toolkit/fsio.ts +36 -0
  42. package/src/toolkit/gen.test.ts +85 -0
  43. package/src/toolkit/gen.ts +109 -0
  44. package/src/toolkit/index.ts +37 -0
@@ -0,0 +1,42 @@
1
+ // Security regression tests for the hosted surfaces: manifest-provided values
2
+ // (surface paths, chat agent) must not be able to inject markup/script.
3
+
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { mountSurfaces } from "./surfaces.ts";
7
+ import type { HttpRequest, HttpResponse } from "../host.ts";
8
+
9
+ function ctxWith(surfaces: Record<string, unknown>) {
10
+ return {
11
+ manifest: { id: "app", surfaces } as unknown,
12
+ host: { log: () => {} },
13
+ } as unknown as Parameters<typeof mountSurfaces>[0];
14
+ }
15
+
16
+ const fakeApp = { engine: { async searchUserTasks() { return []; } } } as unknown as Parameters<
17
+ typeof mountSurfaces
18
+ >[1];
19
+
20
+ async function render(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<string> {
21
+ const res = await route.handler({ method: "GET", url: "/", query: new URLSearchParams(), text: async () => "" } as unknown as HttpRequest);
22
+ return String(res.body);
23
+ }
24
+
25
+ test("chat agent is HTML-escaped in the mount page", async () => {
26
+ const s = mountSurfaces(ctxWith({ chat: { enabled: true, agent: "<img src=x onerror=alert(1)>" } }), fakeApp);
27
+ const chatRoute = s.routes.find((r) => r.source === "surface:chat" && r.method === "GET")!;
28
+ const body = await render(chatRoute);
29
+ assert.ok(!body.includes("<img src=x"), "raw markup must not appear");
30
+ assert.ok(body.includes("&lt;img src=x onerror=alert(1)&gt;"), "agent is escaped");
31
+ });
32
+
33
+ test("task-inbox path is injected as a quoted JS literal (no script breakout)", async () => {
34
+ const evil = "/tasks'});alert(1);//x";
35
+ const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: evil } }), fakeApp);
36
+ const page = s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && !r.path.endsWith("/api/tasks"))!;
37
+ const body = await render(page);
38
+ // The path is embedded via JSON.stringify, so the raw breakout sequence must
39
+ // not appear unescaped inside the <script>.
40
+ assert.ok(!body.includes("'});alert(1);//'"), "no raw single-quoted breakout");
41
+ assert.ok(body.includes(JSON.stringify(evil)), "path embedded as a JSON string literal");
42
+ });
@@ -0,0 +1,103 @@
1
+ // surfaces — the hosted human surfaces (ADR 0026). Implements the `taskInbox` surface: a
2
+ // small JSON+HTML task list backed by the engine's user-task search, that renders the
3
+ // linked `.form` and posts completion back. The `chat` surface is stubbed as a mount point
4
+ // (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
5
+
6
+ import type { AppApi, RuntimeContext } from "../context.ts";
7
+ import { html, json, normalizeRoutePath, type Route } from "../router.ts";
8
+
9
+ /** Escape HTML-significant characters before embedding a value in markup. */
10
+ function escapeHtml(s: string): string {
11
+ return s.replace(/[&<>"']/g, (c) =>
12
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
13
+ );
14
+ }
15
+
16
+ export interface SurfacesHandle {
17
+ readonly name: string;
18
+ routes: Route[];
19
+ describe(): Record<string, unknown>;
20
+ }
21
+
22
+ function inboxPage(basePath: string): string {
23
+ return `<!doctype html><meta charset="utf-8"><title>Task inbox</title>
24
+ <style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:52rem}
25
+ li{margin:.4rem 0}code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}</style>
26
+ <h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
27
+ <script>
28
+ fetch(${JSON.stringify(basePath)}+'/api/tasks').then(r=>r.json()).then(ts=>{
29
+ const ul=document.getElementById('tasks');
30
+ ul.replaceChildren();
31
+ if(!ts.length){const li=document.createElement('li');li.textContent='No open tasks.';ul.appendChild(li);return;}
32
+ for(const t of ts){const li=document.createElement('li');
33
+ const code=document.createElement('code');
34
+ code.textContent=t.elementId||t.userTaskKey;
35
+ li.appendChild(code);
36
+ li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
37
+ ul.appendChild(li);}
38
+ });
39
+ </script>`;
40
+ }
41
+
42
+ /** Mount the enabled surfaces and return their routes. */
43
+ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle {
44
+ const routes: Route[] = [];
45
+ const surfaces = ctx.manifest.surfaces ?? {};
46
+ const enabled: string[] = [];
47
+
48
+ const inbox = surfaces.taskInbox;
49
+ if (inbox?.enabled) {
50
+ const base = normalizeRoutePath(inbox.path, "/tasks");
51
+ enabled.push(`taskInbox@${base}`);
52
+ routes.push({
53
+ method: "GET",
54
+ path: base,
55
+ source: "surface:taskInbox",
56
+ handler: () => html(inboxPage(base)),
57
+ });
58
+ routes.push({
59
+ method: "GET",
60
+ path: `${base}/api/tasks`,
61
+ source: "surface:taskInbox",
62
+ handler: async (req) => {
63
+ const pik = req.query.get("processInstanceKey") ?? undefined;
64
+ const tasks = await app.engine.searchUserTasks(pik ? { processInstanceKey: pik } : undefined);
65
+ return json(tasks);
66
+ },
67
+ });
68
+ routes.push({
69
+ method: "POST",
70
+ path: `${base}/api/complete`,
71
+ source: "surface:taskInbox",
72
+ handler: async (req) => {
73
+ let body: { userTaskKey?: string; variables?: Record<string, unknown> };
74
+ try {
75
+ body = JSON.parse((await req.text()) || "{}");
76
+ } catch {
77
+ return json({ error: "invalid JSON body" }, 400);
78
+ }
79
+ if (!body.userTaskKey) return json({ error: "userTaskKey required" }, 400);
80
+ await app.engine.completeUserTask(body.userTaskKey, body.variables);
81
+ return json({ ok: true });
82
+ },
83
+ });
84
+ }
85
+
86
+ const chat = surfaces.chat;
87
+ if (chat?.enabled) {
88
+ const base = normalizeRoutePath(chat.path, "/chat");
89
+ enabled.push(`chat@${base}`);
90
+ routes.push({
91
+ method: "GET",
92
+ path: base,
93
+ source: "surface:chat",
94
+ handler: () =>
95
+ html(`<!doctype html><meta charset="utf-8"><title>Chat</title>
96
+ <body style="font:14px system-ui;margin:2rem"><h1>Chat</h1>
97
+ <p>Chat surface mount point (agent: <code>${escapeHtml(chat.agent ?? "?")}</code>). LLM wiring pending.</p>`),
98
+ });
99
+ }
100
+
101
+ ctx.host.log("info", "surfaces mounted", { enabled });
102
+ return { name: "surfaces", routes, describe: () => ({ enabled }) };
103
+ }
@@ -0,0 +1,143 @@
1
+ // triggers — the inbound I/O edge (ADR 0025). Turns each `webhook` trigger in the manifest
2
+ // into an HTTP route that verifies the request, maps the payload, and publishes the declared
3
+ // message to the engine for correlation. HMAC verification uses Web Crypto (global in both
4
+ // Node 18+ and Deno), so this stays runtime-agnostic.
5
+
6
+ import type { AppApi, RuntimeContext } from "../context.ts";
7
+ import type { HttpRequest } from "../host.ts";
8
+ import { json, normalizeRoutePath, type Route } from "../router.ts";
9
+ import type { TriggerDecl } from "../manifest.ts";
10
+
11
+ export interface TriggersHandle {
12
+ readonly name: string;
13
+ routes: Route[];
14
+ describe(): Record<string, unknown>;
15
+ }
16
+
17
+ /** Evaluate a tiny correlation expression: `= body.a.b`, or a literal string. */
18
+ export function evalCorrelation(
19
+ expr: string | undefined,
20
+ scope: { body: unknown; headers: Record<string, string>; query: Record<string, string> },
21
+ ): string | undefined {
22
+ if (!expr) return undefined;
23
+ const trimmed = expr.trim();
24
+ if (!trimmed.startsWith("=")) return trimmed; // literal
25
+ const pathExpr = trimmed.slice(1).trim(); // e.g. "body.taskId"
26
+ let cur: unknown = scope;
27
+ for (const seg of pathExpr.split(".")) {
28
+ if (cur && typeof cur === "object" && seg in (cur as Record<string, unknown>)) {
29
+ cur = (cur as Record<string, unknown>)[seg];
30
+ } else {
31
+ return undefined;
32
+ }
33
+ }
34
+ return cur == null ? undefined : String(cur);
35
+ }
36
+
37
+ async function verifyHmac(secret: string, body: string, signature: string): Promise<boolean> {
38
+ const enc = new TextEncoder();
39
+ const key = await globalThis.crypto.subtle.importKey(
40
+ "raw",
41
+ enc.encode(secret),
42
+ { name: "HMAC", hash: "SHA-256" },
43
+ false,
44
+ ["sign"],
45
+ );
46
+ const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(body));
47
+ const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
48
+ const provided = signature.replace(/^sha256=/, "").trim().toLowerCase();
49
+ // Constant-time-ish compare.
50
+ if (provided.length !== hex.length) return false;
51
+ let diff = 0;
52
+ for (let i = 0; i < hex.length; i++) diff |= hex.charCodeAt(i) ^ provided.charCodeAt(i);
53
+ return diff === 0;
54
+ }
55
+
56
+ /** Env var holding the secret for an `hmac:<connection>` auth ref. */
57
+ function secretEnvName(connection: string): string {
58
+ return `URBAN_TRIGGER_SECRET_${connection.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
59
+ }
60
+
61
+ function headerRecord(req: HttpRequest): Record<string, string> {
62
+ const out: Record<string, string> = {};
63
+ req.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
64
+ return out;
65
+ }
66
+
67
+ /** Mount webhook triggers and return their routes. */
68
+ export function mountTriggers(ctx: RuntimeContext, app: AppApi): TriggersHandle {
69
+ const routes: Route[] = [];
70
+ const mounted: string[] = [];
71
+ const seenDeliveries = new Set<string>();
72
+
73
+ for (const trig of (ctx.manifest.triggers ?? []) as TriggerDecl[]) {
74
+ if (trig.type !== "webhook") {
75
+ ctx.host.log("warn", `trigger "${trig.id}": type "${trig.type}" not implemented, skipped`);
76
+ continue;
77
+ }
78
+ const path = normalizeRoutePath(trig.path, `/hooks/${trig.id}`);
79
+ mounted.push(`${trig.id}@${path}`);
80
+
81
+ routes.push({
82
+ method: "POST",
83
+ path,
84
+ source: `trigger:${trig.id}`,
85
+ handler: async (req) => {
86
+ const raw = await req.text();
87
+ const headers = headerRecord(req);
88
+
89
+ // Auth (hmac:<connection>).
90
+ if (trig.auth?.startsWith("hmac:")) {
91
+ const conn = trig.auth.slice("hmac:".length);
92
+ const secret = app.env(secretEnvName(conn));
93
+ if (!secret) {
94
+ app.log("error", `trigger "${trig.id}": missing secret ${secretEnvName(conn)}`);
95
+ return json({ error: "server not configured for hmac" }, 500);
96
+ }
97
+ const sig = headers["x-signature"] ?? headers["x-hub-signature-256"] ?? "";
98
+ if (!sig || !(await verifyHmac(secret, raw, sig))) {
99
+ return json({ error: "invalid signature" }, 401);
100
+ }
101
+ }
102
+
103
+ // Idempotency by delivery id (bounded set).
104
+ const delivery = headers["x-delivery-id"] ?? headers["x-github-delivery"];
105
+ if (delivery) {
106
+ if (seenDeliveries.has(delivery)) return json({ ok: true, deduped: true });
107
+ seenDeliveries.add(delivery);
108
+ if (seenDeliveries.size > 10_000) {
109
+ seenDeliveries.delete(seenDeliveries.values().next().value as string);
110
+ }
111
+ }
112
+
113
+ let body: unknown = {};
114
+ try {
115
+ body = raw ? JSON.parse(raw) : {};
116
+ } catch {
117
+ return json({ error: "body must be JSON" }, 400);
118
+ }
119
+
120
+ const message = trig.action?.message;
121
+ if (!message) {
122
+ app.log("warn", `trigger "${trig.id}": no action.message; nothing published`);
123
+ return json({ ok: true, published: false });
124
+ }
125
+ const correlationKey = evalCorrelation(trig.action?.correlationKey, {
126
+ body,
127
+ headers,
128
+ query: Object.fromEntries(req.query),
129
+ });
130
+ await app.engine.publishMessage({
131
+ name: message,
132
+ correlationKey,
133
+ variables: (body && typeof body === "object" ? (body as Record<string, unknown>) : {}),
134
+ });
135
+ app.log("info", `trigger "${trig.id}" published`, { message, correlationKey });
136
+ return json({ ok: true, published: true, message, correlationKey });
137
+ },
138
+ });
139
+ }
140
+
141
+ ctx.host.log("info", "triggers mounted", { mounted });
142
+ return { name: "triggers", routes, describe: () => ({ mounted }) };
143
+ }
@@ -0,0 +1,22 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveHandler } from "./workers.ts";
4
+ import { evalCorrelation } from "./triggers.ts";
5
+
6
+ test("resolveHandler prefers handlers[jobType], then segment, then default", () => {
7
+ const fn = () => {};
8
+ assert.equal(resolveHandler({ handlers: { "a.b": fn } }, "a.b"), fn);
9
+ assert.equal(resolveHandler({ "a.b": fn }, "a.b"), fn);
10
+ assert.equal(resolveHandler({ handlers: { b: fn } }, "a.b"), fn); // last segment
11
+ assert.equal(resolveHandler({ default: fn }, "a.b"), fn);
12
+ assert.equal(resolveHandler({}, "a.b"), undefined);
13
+ });
14
+
15
+ test("evalCorrelation resolves = body.path, literals, and misses", () => {
16
+ const scope = { body: { taskId: "T1", nested: { k: 7 } }, headers: {}, query: {} };
17
+ assert.equal(evalCorrelation("= body.taskId", scope), "T1");
18
+ assert.equal(evalCorrelation("=body.nested.k", scope), "7");
19
+ assert.equal(evalCorrelation("literal", scope), "literal");
20
+ assert.equal(evalCorrelation("= body.missing", scope), undefined);
21
+ assert.equal(evalCorrelation(undefined, scope), undefined);
22
+ });
@@ -0,0 +1,96 @@
1
+ // workers — load each declared worker's handler module and register it as a push worker
2
+ // on the engine. Handlers are resolved by a small, documented contract and are injected
3
+ // with the app's runtime API (datasource + engine + host utils).
4
+
5
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
6
+ import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
7
+ import { workerJobType, type WorkerDecl } from "../manifest.ts";
8
+
9
+ /** A handler as authored by an app: the job plus the injected app API. */
10
+ export type AppJobHandler = (
11
+ job: EngineJob,
12
+ app: AppApi,
13
+ ) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
14
+
15
+ /**
16
+ * Resolve a handler for `jobType` from a loaded module, in priority order:
17
+ * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
18
+ * 2. a named export matching jobType (or its last dotted segment)
19
+ * 3. `default` (when it is a function)
20
+ */
21
+ export function resolveHandler(
22
+ mod: Record<string, unknown>,
23
+ jobType: string,
24
+ ): AppJobHandler | undefined {
25
+ const map = mod.handlers as Record<string, unknown> | undefined;
26
+ if (map && typeof map[jobType] === "function") return map[jobType] as AppJobHandler;
27
+ const seg = jobType.includes(".") ? jobType.slice(jobType.lastIndexOf(".") + 1) : jobType;
28
+ if (typeof mod[jobType] === "function") return mod[jobType] as AppJobHandler;
29
+ if (map && typeof map[seg] === "function") return map[seg] as AppJobHandler;
30
+ if (typeof mod[seg] === "function") return mod[seg] as AppJobHandler;
31
+ if (typeof mod.default === "function") return mod.default as AppJobHandler;
32
+ return undefined;
33
+ }
34
+
35
+ export interface WorkersHandle extends Mounted {
36
+ readonly jobTypes: string[];
37
+ }
38
+
39
+ /** Load handler modules and register a push worker per declared worker. */
40
+ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<WorkersHandle> {
41
+ const decls = ctx.manifest.workers ?? [];
42
+ const subs: WorkerSubscription[] = [];
43
+ const jobTypes: string[] = [];
44
+
45
+ // Cache module loads so a multi-type handler module is imported once.
46
+ const moduleCache = new Map<string, Record<string, unknown>>();
47
+ const joinRoot = (p: string): string =>
48
+ p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
49
+ const loadModule = async (path: string): Promise<Record<string, unknown>> => {
50
+ const key = joinRoot(path);
51
+ const cached = moduleCache.get(key);
52
+ if (cached) return cached;
53
+ const mod = await ctx.host.importModule(key);
54
+ moduleCache.set(key, mod);
55
+ return mod;
56
+ };
57
+
58
+ for (const decl of decls as WorkerDecl[]) {
59
+ const jobType = workerJobType(decl);
60
+ if (!jobType) continue;
61
+ if (!decl.handler) {
62
+ // llm-backed workers (schema `oneOf` handler|llm) are validated but not yet
63
+ // executed by the runtime. Skip with a clear warning rather than crashing.
64
+ ctx.host.log("warn", "skipping worker: llm-backed workers are not yet implemented", {
65
+ jobType,
66
+ llm: decl.llm,
67
+ });
68
+ continue;
69
+ }
70
+ const mod = await loadModule(decl.handler);
71
+ const handler = resolveHandler(mod, jobType);
72
+ if (!handler) {
73
+ throw new Error(
74
+ `worker "${jobType}": ${decl.handler} exports no handler for it ` +
75
+ `(expected handlers["${jobType}"], a named export, or a default function)`,
76
+ );
77
+ }
78
+ const wrapped: JobHandler = (job) => handler(job, app);
79
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
80
+ workerName: `${ctx.manifest.id}:${jobType}`,
81
+ });
82
+ subs.push(sub);
83
+ jobTypes.push(jobType);
84
+ ctx.host.log("info", "worker registered", { jobType, handler: decl.handler });
85
+ }
86
+
87
+ return {
88
+ name: "workers",
89
+ jobTypes,
90
+ async stop() {
91
+ // Graceful unsubscribe on exit (defensive against the Falcon dead-subscriber stall).
92
+ await Promise.allSettled(subs.map((s) => s.unsubscribe()));
93
+ },
94
+ describe: () => ({ jobTypes }),
95
+ };
96
+ }
@@ -0,0 +1,31 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readdir, readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+
7
+ // Architectural guard (ADR 0052): core/ is runtime-agnostic. It must not import `node:*`
8
+ // modules or reference the `Deno` global. The only runtime-coupled code lives in adapters/.
9
+
10
+ const coreDir = dirname(fileURLToPath(import.meta.url));
11
+
12
+ async function tsFiles(dir: string): Promise<string[]> {
13
+ const out: string[] = [];
14
+ for (const e of await readdir(dir, { withFileTypes: true })) {
15
+ const p = join(dir, e.name);
16
+ if (e.isDirectory()) out.push(...(await tsFiles(p)));
17
+ else if (e.name.endsWith(".ts") && !e.name.endsWith(".test.ts")) out.push(p);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ test("core/ has zero runtime-specific imports", async () => {
23
+ const offenders: string[] = [];
24
+ for (const file of await tsFiles(coreDir)) {
25
+ const src = await readFile(file, "utf8");
26
+ if (/from\s+["']node:/.test(src)) offenders.push(`${file}: imports node:*`);
27
+ // `Deno` as an identifier (not inside a comment word like "Deno adapter")
28
+ if (/\bDeno\s*\./.test(src)) offenders.push(`${file}: references Deno.*`);
29
+ }
30
+ assert.deepEqual(offenders, [], `core purity violations:\n${offenders.join("\n")}`);
31
+ });
@@ -0,0 +1,16 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { normalizeRoutePath } from "./router.ts";
4
+
5
+ test("normalizeRoutePath ensures a leading slash and trims trailing slashes", () => {
6
+ assert.equal(normalizeRoutePath("hooks/x", "/fallback"), "/hooks/x");
7
+ assert.equal(normalizeRoutePath("/hooks/x", "/fallback"), "/hooks/x");
8
+ assert.equal(normalizeRoutePath("/tasks/", "/tasks"), "/tasks");
9
+ assert.equal(normalizeRoutePath(" /chat ", "/chat"), "/chat");
10
+ });
11
+
12
+ test("normalizeRoutePath falls back when value is missing or collapses to root", () => {
13
+ assert.equal(normalizeRoutePath(undefined, "/tasks"), "/tasks");
14
+ assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
15
+ assert.equal(normalizeRoutePath("", "/tasks"), "/tasks");
16
+ });
@@ -0,0 +1,55 @@
1
+ // A minimal path router shared by the surfaces and triggers modules so the runtime serves
2
+ // a single HTTP port. Matching is exact path or, for a route registered with `prefix`,
3
+ // a path-prefix match. First match wins.
4
+
5
+ import type { HttpHandler, HttpRequest, HttpResponse } from "./host.ts";
6
+
7
+ export interface Route {
8
+ method: string;
9
+ path: string;
10
+ /** When true, match any path that starts with `path`. */
11
+ prefix?: boolean;
12
+ handler: HttpHandler;
13
+ /** For diagnostics / inspect(). */
14
+ source?: string;
15
+ }
16
+
17
+ export function json(body: unknown, status = 200): HttpResponse {
18
+ return { status, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
19
+ }
20
+
21
+ export function html(body: string, status = 200): HttpResponse {
22
+ return { status, headers: { "content-type": "text/html; charset=utf-8" }, body };
23
+ }
24
+
25
+ export function text(body: string, status = 200): HttpResponse {
26
+ return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
27
+ }
28
+
29
+ /**
30
+ * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
31
+ * slashes. Incoming request paths always start with "/", so a manifest path
32
+ * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
33
+ * `fallback` when the value is missing or collapses to empty ("/").
34
+ */
35
+ export function normalizeRoutePath(value: string | undefined, fallback: string): string {
36
+ const raw = (value ?? fallback).trim();
37
+ const withLead = raw.startsWith("/") ? raw : `/${raw}`;
38
+ const noTrail = withLead.replace(/\/+$/, "");
39
+ return noTrail || fallback;
40
+ }
41
+
42
+ function matches(route: Route, req: HttpRequest): boolean {
43
+ if (route.method !== "*" && route.method.toUpperCase() !== req.method.toUpperCase()) return false;
44
+ return route.prefix ? req.path.startsWith(route.path) : req.path === route.path;
45
+ }
46
+
47
+ /** Build a single HttpHandler that dispatches across the given routes. */
48
+ export function makeRouter(routes: Route[]): HttpHandler {
49
+ return async (req: HttpRequest): Promise<HttpResponse> => {
50
+ for (const route of routes) {
51
+ if (matches(route, req)) return route.handler(req);
52
+ }
53
+ return text("not found", 404);
54
+ };
55
+ }