@nanobpm/urban 0.77.0 → 0.77.1
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.
- package/dist/runtime/core/modules/pages.js +45 -2
- package/dist/runtime/core/router.d.ts +14 -2
- package/dist/runtime/core/router.js +8 -2
- package/package.json +1 -1
- package/src/runtime/core/modules/agent.test.ts +6 -3
- package/src/runtime/core/modules/pages.test.ts +127 -0
- package/src/runtime/core/modules/pages.ts +44 -2
- package/src/runtime/core/modules/surfaces.test.ts +8 -4
- package/src/runtime/core/router.ts +21 -2
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// GET / (+ /app/runtime.js) → the schema-driven browser renderer
|
|
12
12
|
// GET /app/pages/<id> → the page's page.json
|
|
13
13
|
// GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
|
|
14
|
-
// GET /{embed,standalone}.html /mount.js /cockpit.css → appView sidecars (from pagesDir, #420)
|
|
14
|
+
// GET /{embed,standalone}.html /mount.js /cockpit.css → flat appView sidecars (from pagesDir, #420)
|
|
15
|
+
// GET /<subdir>/<asset> → nested appView sidecars (pages/<subdir>/…, #442)
|
|
15
16
|
// GET /dist/<path> → the app dist/ bundle the sidecars reference
|
|
16
17
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
17
18
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
@@ -45,7 +46,10 @@ function javascript(body, status = 200, extraHeaders) {
|
|
|
45
46
|
// document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
|
|
46
47
|
// `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
|
|
47
48
|
// iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
|
|
48
|
-
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`.
|
|
49
|
+
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. An app that ships
|
|
50
|
+
// MORE than one `appView` gives each its own SUBDIR sidecar (`./cockpit/embed.html`,
|
|
51
|
+
// `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
|
|
52
|
+
// this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
|
|
49
53
|
// import-map references is served separately under the `/dist/` prefix below.
|
|
50
54
|
const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"];
|
|
51
55
|
// The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
|
|
@@ -485,6 +489,45 @@ export function createPagesRoutes(opts, deps) {
|
|
|
485
489
|
}
|
|
486
490
|
},
|
|
487
491
|
});
|
|
492
|
+
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
493
|
+
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
494
|
+
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
495
|
+
// `./cockpit/embed.html`, `pages/board.page.json` → `./board/embed.html`. The renderer's
|
|
496
|
+
// `baseRelativeUrl(p.embed)` rebases those onto the mount root, so the browser fetches
|
|
497
|
+
// `/cockpit/embed.html`, `/board/mount.js`, … — every one of which #420's flat 4-name
|
|
498
|
+
// serve 404'd. Serve the `pagesDir` as a TREE, mapping `/<subpath>` onto
|
|
499
|
+
// `pagesDir/<subpath>` with the SAME traversal guard as the `/dist/` route (mirroring it).
|
|
500
|
+
// It is a `/`-prefix route, so to avoid a root catch-all silently swallowing `/healthz`,
|
|
501
|
+
// the app's own trigger routes, or any other route, the handler DECLINES (returns
|
|
502
|
+
// undefined → the router falls through to the next route, see `RouteHandler`) whenever it
|
|
503
|
+
// cannot serve a real nested asset:
|
|
504
|
+
// • a bare single-segment path (`/healthz`, the flat sidecars) — owned by its own exact
|
|
505
|
+
// route / the runtime's liveness route, never this tree;
|
|
506
|
+
// • an unsafe subpath (`..`/absolute/percent-escaped/backslash) — traversal-rejected
|
|
507
|
+
// before it reaches the filesystem;
|
|
508
|
+
// • a miss (no such file) — so a same-path route registered AFTER the pages surface (a
|
|
509
|
+
// trigger, the appended `/healthz`) still resolves instead of being masked by a 404.
|
|
510
|
+
// Registered LAST among the pages routes so every exact/prefix pages route above wins first.
|
|
511
|
+
routes.push({
|
|
512
|
+
method: "GET",
|
|
513
|
+
path: "/",
|
|
514
|
+
prefix: true,
|
|
515
|
+
source: "surface:pages",
|
|
516
|
+
handler: async (req) => {
|
|
517
|
+
const rest = req.path.slice(1); // drop the leading "/"
|
|
518
|
+
// Only a SUBDIR asset (has a `/`) belongs to this tree; a single-segment path is left
|
|
519
|
+
// to its own exact route / the appended `/healthz` liveness route.
|
|
520
|
+
if (!rest.includes("/"))
|
|
521
|
+
return undefined;
|
|
522
|
+
const safe = safeAssetSubpath(rest);
|
|
523
|
+
if (safe === null)
|
|
524
|
+
return undefined; // traversal/escape → decline (never touch the FS)
|
|
525
|
+
const res = await serveAsset(readAsset, `${pagesDir}/${safe}`);
|
|
526
|
+
// A miss declines rather than 404s, so a later same-path route (e.g. a trigger) can
|
|
527
|
+
// still claim it; the router's own default 404 covers a genuinely absent asset.
|
|
528
|
+
return res.status === 404 ? undefined : res;
|
|
529
|
+
},
|
|
530
|
+
});
|
|
488
531
|
return routes;
|
|
489
532
|
}
|
|
490
533
|
/**
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import type { HttpHandler, HttpResponse } from "./host.ts";
|
|
1
|
+
import type { HttpHandler, HttpRequest, HttpResponse } from "./host.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A route handler that may DECLINE a matched request by returning `undefined`
|
|
4
|
+
* (or a promise of it). The router then treats the route as if it had not matched
|
|
5
|
+
* and continues to the next route (fall-through). This lets a broad prefix route —
|
|
6
|
+
* e.g. the pages surface's nested static serve at `/` — claim only the requests it
|
|
7
|
+
* can actually satisfy (a real, safe asset) and let everything else (`/healthz`, an
|
|
8
|
+
* app's own trigger routes registered *after* it) resolve to their own handlers,
|
|
9
|
+
* instead of a root catch-all silently swallowing them. A handler that returns a
|
|
10
|
+
* concrete `HttpResponse` (including a 404) still short-circuits as before, so this
|
|
11
|
+
* is backwards-compatible with every existing handler (none return `undefined`).
|
|
12
|
+
*/
|
|
13
|
+
export type RouteHandler = (req: HttpRequest) => Promise<HttpResponse | undefined> | HttpResponse | undefined;
|
|
2
14
|
export interface Route {
|
|
3
15
|
method: string;
|
|
4
16
|
path: string;
|
|
5
17
|
/** When true, match any path that starts with `path`. */
|
|
6
18
|
prefix?: boolean;
|
|
7
|
-
handler:
|
|
19
|
+
handler: RouteHandler;
|
|
8
20
|
/** For diagnostics / inspect(). */
|
|
9
21
|
source?: string;
|
|
10
22
|
}
|
|
@@ -43,8 +43,14 @@ function matches(route, req) {
|
|
|
43
43
|
export function makeRouter(routes) {
|
|
44
44
|
return async (req) => {
|
|
45
45
|
for (const route of routes) {
|
|
46
|
-
if (matches(route, req))
|
|
47
|
-
|
|
46
|
+
if (matches(route, req)) {
|
|
47
|
+
// A handler may DECLINE by returning `undefined` (see RouteHandler): treat the
|
|
48
|
+
// route as unmatched and keep looking, so a broad prefix serve can't shadow a
|
|
49
|
+
// later exact route (`/healthz`, a trigger) it didn't actually satisfy.
|
|
50
|
+
const res = await route.handler(req);
|
|
51
|
+
if (res !== undefined)
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
48
54
|
}
|
|
49
55
|
return text("not found", 404);
|
|
50
56
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.1",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -5,7 +5,8 @@ import { test } from "node:test";
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { mountAgent } from "./agent.ts";
|
|
7
7
|
import type { RuntimeContext } from "../context.ts";
|
|
8
|
-
import type { EngineClient, HostContext,
|
|
8
|
+
import type { EngineClient, HostContext, HttpResponse } from "../host.ts";
|
|
9
|
+
import type { Route } from "../router.ts";
|
|
9
10
|
|
|
10
11
|
const fakeEngine: EngineClient = {
|
|
11
12
|
deployResources: async () => ({ deployed: 0 }),
|
|
@@ -44,14 +45,16 @@ function ctxWithFiles(files: Record<string, string>): RuntimeContext {
|
|
|
44
45
|
return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App" }, host, engine: fakeEngine };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
async function call(route:
|
|
48
|
-
|
|
48
|
+
async function call(route: Route): Promise<HttpResponse> {
|
|
49
|
+
const res = await route.handler({
|
|
49
50
|
method: "GET",
|
|
50
51
|
path: "/",
|
|
51
52
|
query: new URLSearchParams(),
|
|
52
53
|
headers: new Headers(),
|
|
53
54
|
text: async () => "",
|
|
54
55
|
});
|
|
56
|
+
assert.ok(res, "handler should not decline");
|
|
57
|
+
return res;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
const routeAt = (ctx: RuntimeContext, path: string) => {
|
|
@@ -1823,3 +1823,130 @@ test("the static asset routes do not shadow the app's own routes or /healthz", a
|
|
|
1823
1823
|
const home = await router(req("GET", "/"));
|
|
1824
1824
|
assert.match(home.body ?? "", /data-home="home"/);
|
|
1825
1825
|
});
|
|
1826
|
+
|
|
1827
|
+
// ── nested (multi-appView) sidecar serving (#442) ──────────────────────────
|
|
1828
|
+
// An app with two-or-more appViews ships each view's sidecar under its own subdir
|
|
1829
|
+
// (`./cockpit/embed.html`, `./board/embed.html`); #420's flat 4-name serve 404'd every
|
|
1830
|
+
// one. These pin the generalized nested prefix serve of `pages/<subpath>` (and that it
|
|
1831
|
+
// still declines — never a root catch-all — so `/healthz` / triggers keep resolving).
|
|
1832
|
+
|
|
1833
|
+
test("GET /<subdir>/embed.html serves the nested appView sidecar", async () => {
|
|
1834
|
+
const router = buildWithAssets({
|
|
1835
|
+
"pages/cockpit/embed.html": "<!doctype html><title>cockpit</title>",
|
|
1836
|
+
"pages/board/embed.html": "<!doctype html><title>board</title>",
|
|
1837
|
+
});
|
|
1838
|
+
const cockpit = await router(req("GET", "/cockpit/embed.html"));
|
|
1839
|
+
assert.equal(cockpit.status, 200);
|
|
1840
|
+
assert.match(cockpit.headers?.["content-type"] ?? "", /text\/html/);
|
|
1841
|
+
assert.match(cockpit.body ?? "", /<title>cockpit<\/title>/);
|
|
1842
|
+
const board = await router(req("GET", "/board/embed.html"));
|
|
1843
|
+
assert.equal(board.status, 200);
|
|
1844
|
+
assert.match(board.body ?? "", /<title>board<\/title>/);
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
test("all nested subdir sidecar assets are served with correct content types", async () => {
|
|
1848
|
+
const router = buildWithAssets({
|
|
1849
|
+
"pages/cockpit/embed.html": "<html>embed</html>",
|
|
1850
|
+
"pages/cockpit/standalone.html": "<html>standalone</html>",
|
|
1851
|
+
"pages/cockpit/mount.js": "export const mountCockpit = () => {};",
|
|
1852
|
+
"pages/cockpit/cockpit.css": "body{margin:0}",
|
|
1853
|
+
"pages/delivery-graphs/embed.html": "<html>dg</html>",
|
|
1854
|
+
});
|
|
1855
|
+
const cases: [string, RegExp][] = [
|
|
1856
|
+
["/cockpit/embed.html", /text\/html/],
|
|
1857
|
+
["/cockpit/standalone.html", /text\/html/],
|
|
1858
|
+
["/cockpit/mount.js", /text\/javascript/],
|
|
1859
|
+
["/cockpit/cockpit.css", /text\/css/],
|
|
1860
|
+
// A hyphenated multi-appView page id (the merlin repro) resolves too.
|
|
1861
|
+
["/delivery-graphs/embed.html", /text\/html/],
|
|
1862
|
+
];
|
|
1863
|
+
for (const [path, ct] of cases) {
|
|
1864
|
+
const res = await router(req("GET", path));
|
|
1865
|
+
assert.equal(res.status, 200, `${path} should 200`);
|
|
1866
|
+
assert.match(res.headers?.["content-type"] ?? "", ct, `${path} content-type`);
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
test("the flat single-appView sidecar back-compat still serves (#420) alongside nested", async () => {
|
|
1871
|
+
// The root `pages/embed.html` case #420 shipped must keep working: an app with exactly
|
|
1872
|
+
// one appView (no subdir) is unaffected by the nested generalization.
|
|
1873
|
+
const router = buildWithAssets({
|
|
1874
|
+
"pages/embed.html": "<html>flat</html>",
|
|
1875
|
+
"pages/cockpit/embed.html": "<html>nested</html>",
|
|
1876
|
+
});
|
|
1877
|
+
const flat = await router(req("GET", "/embed.html"));
|
|
1878
|
+
assert.equal(flat.status, 200);
|
|
1879
|
+
assert.match(flat.body ?? "", /flat/);
|
|
1880
|
+
const nested = await router(req("GET", "/cockpit/embed.html"));
|
|
1881
|
+
assert.equal(nested.status, 200);
|
|
1882
|
+
assert.match(nested.body ?? "", /nested/);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
test("a missing nested sidecar 404s instead of throwing", async () => {
|
|
1886
|
+
const router = buildWithAssets({ "pages/cockpit/embed.html": "<html>ok</html>" });
|
|
1887
|
+
const res = await router(req("GET", "/board/embed.html"));
|
|
1888
|
+
assert.equal(res.status, 404);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test("a nested-serve path traversal attempt is rejected (never reaches the filesystem)", async () => {
|
|
1892
|
+
// The `pages/` tree reuses the SAME sub-path guard as `/dist/`: any `..` / absolute /
|
|
1893
|
+
// percent-escaped / backslash / doubled-slash segment must be rejected before a read.
|
|
1894
|
+
let readAttempts = 0;
|
|
1895
|
+
const { engine } = fakeEngine();
|
|
1896
|
+
const router = makeRouter(
|
|
1897
|
+
createPagesRoutes(
|
|
1898
|
+
{ pagesDir: "pages", homePage: "home", sourceName: "app" },
|
|
1899
|
+
{
|
|
1900
|
+
db: fakeDb(),
|
|
1901
|
+
engine,
|
|
1902
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
1903
|
+
readAsset: async (path: string) => {
|
|
1904
|
+
readAttempts++;
|
|
1905
|
+
return `bytes of ${path}`;
|
|
1906
|
+
},
|
|
1907
|
+
listPages: async () => ["home"],
|
|
1908
|
+
},
|
|
1909
|
+
),
|
|
1910
|
+
);
|
|
1911
|
+
for (const evil of ["/cockpit/../secret.json", "/a/../../secret", "/cockpit/%2e%2e/secret", "/cockpit//etc"]) {
|
|
1912
|
+
const res = await router(req("GET", evil));
|
|
1913
|
+
assert.equal(res.status, 404, `${evil} must 404`);
|
|
1914
|
+
}
|
|
1915
|
+
assert.equal(readAttempts, 0, "no traversal path may reach the asset reader");
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
test("the nested serve declines (does not shadow) a same-path trigger route registered after it", async () => {
|
|
1919
|
+
// The nested serve is a `/`-prefix route registered before the triggers surface. A miss
|
|
1920
|
+
// must DECLINE (fall through) so a trigger at `/webhooks/deploy` still resolves, rather
|
|
1921
|
+
// than being masked by the pages tree's 404. A root catch-all would silently swallow it.
|
|
1922
|
+
const routes = createPagesRoutes(
|
|
1923
|
+
{ pagesDir: "pages", homePage: "home", sourceName: "app" },
|
|
1924
|
+
{
|
|
1925
|
+
db: fakeDb(),
|
|
1926
|
+
engine: fakeEngine().engine,
|
|
1927
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
1928
|
+
readAsset: async (path: string) => {
|
|
1929
|
+
// Only the real nested sidecar exists; every other path is a miss.
|
|
1930
|
+
if (path === "pages/cockpit/embed.html") return "<html>cockpit</html>";
|
|
1931
|
+
throw new Error(`no such file: ${path}`);
|
|
1932
|
+
},
|
|
1933
|
+
listPages: async () => ["home"],
|
|
1934
|
+
},
|
|
1935
|
+
);
|
|
1936
|
+
const router = makeRouter([
|
|
1937
|
+
...routes,
|
|
1938
|
+
{
|
|
1939
|
+
method: "GET",
|
|
1940
|
+
path: "/webhooks/deploy",
|
|
1941
|
+
handler: () => ({ status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify({ queued: true }) }),
|
|
1942
|
+
},
|
|
1943
|
+
]);
|
|
1944
|
+
// The real nested sidecar is served by the pages tree.
|
|
1945
|
+
const sidecar = await router(req("GET", "/cockpit/embed.html"));
|
|
1946
|
+
assert.equal(sidecar.status, 200);
|
|
1947
|
+
assert.match(sidecar.body ?? "", /cockpit/);
|
|
1948
|
+
// The multi-segment trigger path is NOT shadowed — the nested serve declines the miss.
|
|
1949
|
+
const hook = await router(req("GET", "/webhooks/deploy"));
|
|
1950
|
+
assert.equal(hook.status, 202);
|
|
1951
|
+
assert.deepEqual(JSON.parse(hook.body ?? "{}"), { queued: true });
|
|
1952
|
+
});
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// GET / (+ /app/runtime.js) → the schema-driven browser renderer
|
|
12
12
|
// GET /app/pages/<id> → the page's page.json
|
|
13
13
|
// GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
|
|
14
|
-
// GET /{embed,standalone}.html /mount.js /cockpit.css → appView sidecars (from pagesDir, #420)
|
|
14
|
+
// GET /{embed,standalone}.html /mount.js /cockpit.css → flat appView sidecars (from pagesDir, #420)
|
|
15
|
+
// GET /<subdir>/<asset> → nested appView sidecars (pages/<subdir>/…, #442)
|
|
15
16
|
// GET /dist/<path> → the app dist/ bundle the sidecars reference
|
|
16
17
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
17
18
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
@@ -107,7 +108,10 @@ function javascript(
|
|
|
107
108
|
// document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
|
|
108
109
|
// `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
|
|
109
110
|
// iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
|
|
110
|
-
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`.
|
|
111
|
+
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. An app that ships
|
|
112
|
+
// MORE than one `appView` gives each its own SUBDIR sidecar (`./cockpit/embed.html`,
|
|
113
|
+
// `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
|
|
114
|
+
// this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
|
|
111
115
|
// import-map references is served separately under the `/dist/` prefix below.
|
|
112
116
|
const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"] as const;
|
|
113
117
|
|
|
@@ -541,6 +545,44 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
541
545
|
},
|
|
542
546
|
});
|
|
543
547
|
|
|
548
|
+
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
549
|
+
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
550
|
+
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
551
|
+
// `./cockpit/embed.html`, `pages/board.page.json` → `./board/embed.html`. The renderer's
|
|
552
|
+
// `baseRelativeUrl(p.embed)` rebases those onto the mount root, so the browser fetches
|
|
553
|
+
// `/cockpit/embed.html`, `/board/mount.js`, … — every one of which #420's flat 4-name
|
|
554
|
+
// serve 404'd. Serve the `pagesDir` as a TREE, mapping `/<subpath>` onto
|
|
555
|
+
// `pagesDir/<subpath>` with the SAME traversal guard as the `/dist/` route (mirroring it).
|
|
556
|
+
// It is a `/`-prefix route, so to avoid a root catch-all silently swallowing `/healthz`,
|
|
557
|
+
// the app's own trigger routes, or any other route, the handler DECLINES (returns
|
|
558
|
+
// undefined → the router falls through to the next route, see `RouteHandler`) whenever it
|
|
559
|
+
// cannot serve a real nested asset:
|
|
560
|
+
// • a bare single-segment path (`/healthz`, the flat sidecars) — owned by its own exact
|
|
561
|
+
// route / the runtime's liveness route, never this tree;
|
|
562
|
+
// • an unsafe subpath (`..`/absolute/percent-escaped/backslash) — traversal-rejected
|
|
563
|
+
// before it reaches the filesystem;
|
|
564
|
+
// • a miss (no such file) — so a same-path route registered AFTER the pages surface (a
|
|
565
|
+
// trigger, the appended `/healthz`) still resolves instead of being masked by a 404.
|
|
566
|
+
// Registered LAST among the pages routes so every exact/prefix pages route above wins first.
|
|
567
|
+
routes.push({
|
|
568
|
+
method: "GET",
|
|
569
|
+
path: "/",
|
|
570
|
+
prefix: true,
|
|
571
|
+
source: "surface:pages",
|
|
572
|
+
handler: async (req) => {
|
|
573
|
+
const rest = req.path.slice(1); // drop the leading "/"
|
|
574
|
+
// Only a SUBDIR asset (has a `/`) belongs to this tree; a single-segment path is left
|
|
575
|
+
// to its own exact route / the appended `/healthz` liveness route.
|
|
576
|
+
if (!rest.includes("/")) return undefined;
|
|
577
|
+
const safe = safeAssetSubpath(rest);
|
|
578
|
+
if (safe === null) return undefined; // traversal/escape → decline (never touch the FS)
|
|
579
|
+
const res = await serveAsset(readAsset, `${pagesDir}/${safe}`);
|
|
580
|
+
// A miss declines rather than 404s, so a later same-path route (e.g. a trigger) can
|
|
581
|
+
// still claim it; the router's own default 404 covers a genuinely absent asset.
|
|
582
|
+
return res.status === 404 ? undefined : res;
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
|
|
544
586
|
return routes;
|
|
545
587
|
}
|
|
546
588
|
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { mountSurfaces } from "./surfaces.ts";
|
|
7
|
-
import type { EngineClient, HostContext,
|
|
7
|
+
import type { EngineClient, HostContext, HttpResponse } from "../host.ts";
|
|
8
8
|
import { DataLayer } from "./datasource.ts";
|
|
9
9
|
import { createLogger } from "../logger.ts";
|
|
10
|
+
import type { Route } from "../router.ts";
|
|
10
11
|
|
|
11
12
|
function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurfaces>[0] {
|
|
12
13
|
const host: HostContext = {
|
|
@@ -63,7 +64,7 @@ const fakeApp: Parameters<typeof mountSurfaces>[1] = {
|
|
|
63
64
|
log: createLogger(() => {}),
|
|
64
65
|
};
|
|
65
66
|
|
|
66
|
-
async function render(route:
|
|
67
|
+
async function render(route: Route): Promise<string> {
|
|
67
68
|
const res = await route.handler({
|
|
68
69
|
method: "GET",
|
|
69
70
|
path: "/",
|
|
@@ -71,6 +72,7 @@ async function render(route: { handler: (req: HttpRequest) => HttpResponse | Pro
|
|
|
71
72
|
headers: new Headers(),
|
|
72
73
|
text: async () => "",
|
|
73
74
|
});
|
|
75
|
+
assert.ok(res, "handler should not decline");
|
|
74
76
|
return String(res.body);
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -118,16 +120,18 @@ function appWith(engine: EngineClient): Parameters<typeof mountSurfaces>[1] {
|
|
|
118
120
|
|
|
119
121
|
/** Invoke a route handler with an optional query string and POST body. */
|
|
120
122
|
async function call(
|
|
121
|
-
route:
|
|
123
|
+
route: Route,
|
|
122
124
|
opts: { method?: string; query?: string; body?: string } = {},
|
|
123
125
|
): Promise<HttpResponse> {
|
|
124
|
-
|
|
126
|
+
const res = await route.handler({
|
|
125
127
|
method: opts.method ?? "GET",
|
|
126
128
|
path: "/",
|
|
127
129
|
query: new URLSearchParams(opts.query ?? ""),
|
|
128
130
|
headers: new Headers(),
|
|
129
131
|
text: async () => opts.body ?? "",
|
|
130
132
|
});
|
|
133
|
+
assert.ok(res, "handler should not decline");
|
|
134
|
+
return res;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
function inboxRoutes(engine: EngineClient) {
|
|
@@ -4,12 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import type { HttpHandler, HttpRequest, HttpResponse } from "./host.ts";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A route handler that may DECLINE a matched request by returning `undefined`
|
|
9
|
+
* (or a promise of it). The router then treats the route as if it had not matched
|
|
10
|
+
* and continues to the next route (fall-through). This lets a broad prefix route —
|
|
11
|
+
* e.g. the pages surface's nested static serve at `/` — claim only the requests it
|
|
12
|
+
* can actually satisfy (a real, safe asset) and let everything else (`/healthz`, an
|
|
13
|
+
* app's own trigger routes registered *after* it) resolve to their own handlers,
|
|
14
|
+
* instead of a root catch-all silently swallowing them. A handler that returns a
|
|
15
|
+
* concrete `HttpResponse` (including a 404) still short-circuits as before, so this
|
|
16
|
+
* is backwards-compatible with every existing handler (none return `undefined`).
|
|
17
|
+
*/
|
|
18
|
+
export type RouteHandler = (req: HttpRequest) => Promise<HttpResponse | undefined> | HttpResponse | undefined;
|
|
19
|
+
|
|
7
20
|
export interface Route {
|
|
8
21
|
method: string;
|
|
9
22
|
path: string;
|
|
10
23
|
/** When true, match any path that starts with `path`. */
|
|
11
24
|
prefix?: boolean;
|
|
12
|
-
handler:
|
|
25
|
+
handler: RouteHandler;
|
|
13
26
|
/** For diagnostics / inspect(). */
|
|
14
27
|
source?: string;
|
|
15
28
|
}
|
|
@@ -61,7 +74,13 @@ function matches(route: Route, req: HttpRequest): boolean {
|
|
|
61
74
|
export function makeRouter(routes: Route[]): HttpHandler {
|
|
62
75
|
return async (req: HttpRequest): Promise<HttpResponse> => {
|
|
63
76
|
for (const route of routes) {
|
|
64
|
-
if (matches(route, req))
|
|
77
|
+
if (matches(route, req)) {
|
|
78
|
+
// A handler may DECLINE by returning `undefined` (see RouteHandler): treat the
|
|
79
|
+
// route as unmatched and keep looking, so a broad prefix serve can't shadow a
|
|
80
|
+
// later exact route (`/healthz`, a trigger) it didn't actually satisfy.
|
|
81
|
+
const res = await route.handler(req);
|
|
82
|
+
if (res !== undefined) return res;
|
|
83
|
+
}
|
|
65
84
|
}
|
|
66
85
|
return text("not found", 404);
|
|
67
86
|
};
|