@nanobpm/urban 0.94.2 → 0.95.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.
@@ -94,6 +94,36 @@ export function createDenoHost(opts = {}) {
94
94
  return false;
95
95
  }
96
96
  },
97
+ async statFile(p) {
98
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
99
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
100
+ // waiting for a writer, so opening before the type check would hang every shell request on
101
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
102
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
103
+ // open/stat still answers the real serving condition — a file `stat` reports as regular can
104
+ // be unreadable (ACLs / permissions), and the shell must not link an asset that then 404s.
105
+ // Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
106
+ try {
107
+ const meta = await Deno.stat(abs(p));
108
+ if (!meta.isFile)
109
+ return { isFile: false };
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ let file;
115
+ try {
116
+ file = await Deno.open(abs(p), { read: true });
117
+ const s = await file.stat();
118
+ return { isFile: s.isFile };
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ finally {
124
+ file?.close();
125
+ }
126
+ },
97
127
  openSqlite(path) {
98
128
  const db = new DatabaseSync(abs(path));
99
129
  return wrapSqlite(db);
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  }
9
9
  return path;
10
10
  };
11
- import { readFile, readdir, stat } from "node:fs/promises";
11
+ import { open, readFile, readdir, stat } from "node:fs/promises";
12
12
  import { existsSync } from "node:fs";
13
13
  import { watch as fsWatch } from "node:fs";
14
14
  import { createServer } from "node:http";
@@ -133,6 +133,36 @@ export function createNodeHost(opts = {}) {
133
133
  return false;
134
134
  }
135
135
  },
136
+ async statFile(p) {
137
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
138
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
139
+ // waiting for a writer, so opening before the type check would hang every shell request on
140
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
141
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
142
+ // handle open/stat still answers the real serving condition — a file `stat` reports as
143
+ // regular can be unreadable (ACLs / permissions), and the shell must not link an asset that
144
+ // then 404s. Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
145
+ try {
146
+ const meta = await stat(abs(p));
147
+ if (!meta.isFile())
148
+ return { isFile: false };
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ let handle;
154
+ try {
155
+ handle = await open(abs(p), "r");
156
+ const s = await handle.stat();
157
+ return { isFile: s.isFile() };
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ finally {
163
+ await handle?.close();
164
+ }
165
+ },
136
166
  openSqlite(path) {
137
167
  const db = new DatabaseSync(abs(path));
138
168
  return wrapNodeSqlite(db);
@@ -80,6 +80,27 @@ export interface HostContext {
80
80
  listSubdirs?(dir: string): Promise<string[]>;
81
81
  /** True if the path exists (file or directory). */
82
82
  exists(path: string): Promise<boolean>;
83
+ /**
84
+ * Probe a path as a READABLE REGULAR FILE without reading its bytes: `{ isFile }` when it
85
+ * can be opened for reading, or `null` when it is absent or unreadable. The pages surface
86
+ * uses this to probe the app-owned `app.css`/`app.js` convention on the per-request shell
87
+ * hot path: `exists` is too broad (true for a DIRECTORY, which the shell would then link and
88
+ * the browser would 404 on), and probing via `readTextFile` reads and allocates a
89
+ * potentially large file just to answer "is it there?". A bare metadata `stat` is not
90
+ * enough either — it can succeed for a regular file that `readTextFile` cannot open (ACLs /
91
+ * permissions), so the shell would link an asset that then 404s on serving. Implementations
92
+ * therefore OPEN the file for reading (consuming no bytes) and stat the open handle, so this
93
+ * answers the same success condition as serving.
94
+ *
95
+ * OPTIONAL: `HostContext` is a public, externally-implementable seam (re-exported from
96
+ * `runtime/index.ts`, documented in the README as a supported custom-host extension point),
97
+ * so a new required member would be source-breaking. The pages surface falls back to a
98
+ * read-based probe when a host does not provide it; the built-in adapters do, as an
99
+ * allocation-free optimization on the hot path.
100
+ */
101
+ statFile?(path: string): Promise<{
102
+ isFile: boolean;
103
+ } | null>;
83
104
  /** Open (creating if needed) a SQLite database at a filesystem path. */
84
105
  openSqlite(path: string): SqliteDb;
85
106
  /**
@@ -50,6 +50,21 @@ export interface PagesDeps {
50
50
  * Injectable for tests. Rejects (→ 404) when the file is missing.
51
51
  */
52
52
  readAsset?(path: string): Promise<string>;
53
+ /**
54
+ * Probe whether a static asset exists as a READABLE REGULAR FILE at an app-root-relative
55
+ * path, WITHOUT reading its bytes. The per-request shell build uses this for the app-owned
56
+ * `app.css`/`app.js` convention (#578): those files can be large and the shell is revalidated
57
+ * on every load, so probing via a full `readAsset` would put an avoidable read + allocation
58
+ * on the HTML hot path (and read the file twice — once here, once when the browser fetches
59
+ * it). Must answer the same question the `readAsset` fallback answers — "would serving this
60
+ * path succeed?" — so a directory (or unreadable entry) at the path is a NO. `mountPages`
61
+ * wires this to the optional `HostContext.statFile` (which opens the file for reading to
62
+ * confirm a readable regular file — never `HostContext.exists`, which is true for directories
63
+ * too, and a `pages/app.css/` DIRECTORY would link an asset the browser then 404s on).
64
+ * Optional; falls back to a read-based probe when absent.
65
+ * Injectable for tests.
66
+ */
67
+ existsAsset?(path: string): Promise<boolean>;
53
68
  /**
54
69
  * List the available page ids (the `*.page.json` basenames under `pagesDir`),
55
70
  * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
@@ -64,6 +79,7 @@ export interface PagesDeps {
64
79
  */
65
80
  cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
66
81
  }
82
+ export declare const APP_ASSETS: readonly ["app.css", "app.js"];
67
83
  /**
68
84
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
69
85
  * so it is unit-testable without a real host/server (see pages.test.ts).
@@ -80,3 +96,4 @@ export interface PagesHandle {
80
96
  * Returns no routes when the surface is disabled or absent.
81
97
  */
82
98
  export declare function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle;
99
+ export declare const RUNTIME_JS_PATH: string;
@@ -46,6 +46,12 @@ function javascript(body, status = 200, extraHeaders) {
46
46
  body,
47
47
  };
48
48
  }
49
+ // App-owned shell customisation by convention (nano-ide#578): an app MAY ship `pagesDir/app.css`
50
+ // (linked AFTER the built-in renderer CSS, so its rules win — themes, restyling) and/or
51
+ // `pagesDir/app.js` (an ES module loaded after the runtime — e.g. a theme picker). Each is served
52
+ // as a root sidecar and referenced from the shell ONLY when the file exists, so apps without them
53
+ // see no change and no 404.
54
+ export const APP_ASSETS = ["app.css", "app.js"];
49
55
  // The `appView` sidecar documents an app ships alongside its `*.page.json` under
50
56
  // `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
51
57
  // src is the node's base-relative `embed` (`./embed.html`), which — resolved against
@@ -58,7 +64,7 @@ function javascript(body, status = 200, extraHeaders) {
58
64
  // `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
59
65
  // this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
60
66
  // import-map references is served separately under the `/dist/` prefix below.
61
- const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"];
67
+ const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css", ...APP_ASSETS];
62
68
  // The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
63
69
  // resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
64
70
  // resolves to `/dist` regardless of `pagesDir`, so the URL→disk mapping is fixed.
@@ -151,6 +157,9 @@ export function createPagesRoutes(opts, deps) {
151
157
  // Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
152
158
  // `host.readTextFile`, so there is one source of file bytes, not two.
153
159
  const readAsset = deps.readAsset ?? readPage;
160
+ // Existence probe for the app-owned shell assets (#578): a dedicated `existsAsset` when the
161
+ // host offers one (a metadata stat — no file bytes cross the seam), else a read-based probe.
162
+ const existsAsset = deps.existsAsset ?? ((path) => readAsset(path).then(() => true, () => false));
154
163
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
155
164
  // (before serving), so the schema is stable for the process lifetime, and the renderer
156
165
  // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
@@ -181,15 +190,24 @@ export function createPagesRoutes(opts, deps) {
181
190
  return p;
182
191
  };
183
192
  const routes = [];
184
- const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
185
193
  // The shell must never be pinned by the browser: it carries the *current* fingerprinted
186
194
  // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
187
195
  // a revalidation on every load; the shell body is tiny, and the expensive module it references
188
196
  // is what gets the long-lived immutable caching (via its content-hashed URL).
189
- shell.headers = { ...shell.headers, "cache-control": "no-cache" };
197
+ // Built per request: it probes for the app-owned `app.css`/`app.js` (#578) so adding or removing
198
+ // one takes effect without a restart.
199
+ const shell = async () => {
200
+ const [appCss, appJs] = await Promise.all([
201
+ existsAsset(`${pagesDir}/app.css`),
202
+ existsAsset(`${pagesDir}/app.js`),
203
+ ]);
204
+ const res = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto", { appCss, appJs }));
205
+ res.headers = { ...res.headers, "cache-control": "no-cache" };
206
+ return res;
207
+ };
190
208
  // ── the renderer shell + module ─────────────────────────────────────────
191
- routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
192
- routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
209
+ routes.push({ method: "GET", path: "/", source: "surface:pages", handler: shell });
210
+ routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: shell });
193
211
  // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
194
212
  routes.push({
195
213
  method: "GET",
@@ -216,11 +234,22 @@ export function createPagesRoutes(opts, deps) {
216
234
  // onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
217
235
  // `/healthz` liveness route the runtime appends after the pages surface).
218
236
  for (const name of SIDECAR_ASSETS) {
237
+ // The app-owned `app.css`/`app.js` are MUTABLE at stable URLs — the whole point of the
238
+ // convention is that an edit (or a remove/re-add) takes effect without a restart, and the
239
+ // shell re-probes on every load. Serve them `no-cache` (like `/app/runtime.js`) so a
240
+ // browser/intermediary must revalidate instead of replaying a heuristically-cached 200/404.
241
+ // The other sidecars are deploy-time fixtures, so they keep the generic response.
242
+ const mutable = APP_ASSETS.some((a) => a === name);
219
243
  routes.push({
220
244
  method: "GET",
221
245
  path: `/${name}`,
222
246
  source: "surface:pages",
223
- handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
247
+ handler: async () => {
248
+ const res = await serveAsset(readAsset, `${pagesDir}/${name}`);
249
+ if (mutable)
250
+ res.headers = { ...res.headers, "cache-control": "no-cache" };
251
+ return res;
252
+ },
224
253
  });
225
254
  }
226
255
  // The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
@@ -580,12 +609,24 @@ export function mountPages(ctx, app) {
580
609
  };
581
610
  const sourceName = opts.sourceName ?? "app";
582
611
  const bindings = ctx.manifest.instanceTracking ?? [];
612
+ // Probe "would serving this app-owned asset succeed?" — a READABLE REGULAR FILE, not the
613
+ // broader `HostContext.exists` (which is true for directories too, and a `pages/app.css/`
614
+ // DIRECTORY would link an asset the browser then 404s on). `statFile` is an OPTIONAL public
615
+ // host capability (a custom host need not implement it), and it opens the file for reading so
616
+ // no bytes cross the seam on the per-request shell hot path. Only wire the optimized probe
617
+ // when the host provides it; otherwise `createPagesRoutes` falls back to a read-based probe
618
+ // that answers the identical question.
619
+ const statFile = ctx.host.statFile?.bind(ctx.host);
620
+ const existsAsset = statFile
621
+ ? async (p) => (await statFile(p))?.isFile === true
622
+ : undefined;
583
623
  const routes = createPagesRoutes(opts, {
584
624
  db: app.data.open(sourceName),
585
625
  engine: app.engine,
586
626
  cancel: (key) => cancelInstanceReconciling(app, bindings, key),
587
627
  readPage: (p) => ctx.host.readTextFile(p),
588
628
  readAsset: (p) => ctx.host.readTextFile(p),
629
+ existsAsset,
589
630
  listPages: async () => {
590
631
  const dir = opts.pagesDir ?? "pages";
591
632
  const names = await ctx.host.listDir(dir).catch(() => []);
@@ -604,7 +645,7 @@ export function mountPages(ctx, app) {
604
645
  function escapeAttr(s) {
605
646
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
606
647
  }
607
- function rendererShell(homePage, apiDocsPath, gridLayout) {
648
+ function rendererShell(homePage, apiDocsPath, gridLayout, app = { appCss: false, appJs: false }) {
608
649
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
609
650
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
610
651
  // tab a handle back to this window (reverse-tabnabbing).
@@ -628,12 +669,12 @@ function rendererShell(homePage, apiDocsPath, gridLayout) {
628
669
  <meta charset="utf-8" />
629
670
  <meta name="viewport" content="width=device-width, initial-scale=1" />
630
671
  <title>Urban App</title>
631
- <style>${RENDERER_CSS}</style>
672
+ <style>${RENDERER_CSS}</style>${app.appCss ? '\n <link rel="stylesheet" href="./app.css" />' : ""}
632
673
  </head>
633
674
  <body data-grid-layout="${gridLayout}">${apiDocsBadge}
634
675
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
635
676
  <script>${FORMJS_JS}</script>
636
- <script type="module" src=".${RUNTIME_JS_PATH}"></script>
677
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>${app.appJs ? '\n <script type="module" src="./app.js"></script>' : ""}
637
678
  </body>
638
679
  </html>`;
639
680
  }
@@ -1015,5 +1056,8 @@ function fnv1aHex(s) {
1015
1056
  // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1016
1057
  // per content, the response itself is served `immutable` with a one-year max-age.
1017
1058
  const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1018
- const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1059
+ // Exported so tests can locate the actual fingerprinted runtime <script> tag in the shell
1060
+ // (asserting against the bare word "runtime" is unsound — it also appears in the embedded
1061
+ // renderer CSS, so such a test passes even if the app script were moved before the runtime).
1062
+ export const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1019
1063
  const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.94.2",
3
+ "version": "0.95.0",
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",
@@ -45,12 +45,17 @@ interface DenoHttpServer {
45
45
  interface DenoFsWatcher extends AsyncIterable<{ kind: string; paths: string[] }> {
46
46
  close(): void;
47
47
  }
48
+ interface DenoFsFile {
49
+ stat(): Promise<{ isFile: boolean }>;
50
+ close(): void;
51
+ }
48
52
  interface DenoGlobal {
49
53
  env: { get(name: string): string | undefined };
50
54
  cwd(): string;
51
55
  readTextFile(path: string): Promise<string>;
52
56
  readDir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>;
53
- stat(path: string): Promise<unknown>;
57
+ stat(path: string): Promise<{ isFile: boolean }>;
58
+ open(path: string, options?: { read?: boolean }): Promise<DenoFsFile>;
54
59
  watchFs(paths: string | string[], options?: { recursive?: boolean }): DenoFsWatcher;
55
60
  serve(
56
61
  opts: { port: number; hostname?: string; onListen?: (a: { port: number }) => void },
@@ -124,6 +129,32 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
124
129
  return false;
125
130
  }
126
131
  },
132
+ async statFile(p) {
133
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
134
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
135
+ // waiting for a writer, so opening before the type check would hang every shell request on
136
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
137
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
138
+ // open/stat still answers the real serving condition — a file `stat` reports as regular can
139
+ // be unreadable (ACLs / permissions), and the shell must not link an asset that then 404s.
140
+ // Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
141
+ try {
142
+ const meta = await Deno.stat(abs(p));
143
+ if (!meta.isFile) return { isFile: false };
144
+ } catch {
145
+ return null;
146
+ }
147
+ let file: DenoFsFile | undefined;
148
+ try {
149
+ file = await Deno.open(abs(p), { read: true });
150
+ const s = await file.stat();
151
+ return { isFile: s.isFile };
152
+ } catch {
153
+ return null;
154
+ } finally {
155
+ file?.close();
156
+ }
157
+ },
127
158
  openSqlite(path) {
128
159
  const db = new DatabaseSync(abs(path));
129
160
  return wrapSqlite(db);
@@ -1,9 +1,18 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import * as http from "node:http";
4
+ import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { execFile, fork } from "node:child_process";
8
+ import { promisify } from "node:util";
4
9
  import { createNodeHost } from "./node.ts";
10
+ import { isDeno } from "./detect.ts";
5
11
  import type { HttpServer } from "../core/host.ts";
6
12
 
13
+ const execFileAsync = promisify(execFile);
14
+ const nodeImportsTs = Number(process.versions.node.split(".")[0]) >= 22;
15
+
7
16
  // Issue #235: the node adapter must bind the interface it is handed so the manifest's
8
17
  // `network.bind` setting actually controls off-box reachability. We assert the *listen host*
9
18
  // the socket bound to for each setting by reading the native server's address.
@@ -65,3 +74,147 @@ test("serveHttp rejects when the port is already in use (does not hang)", async
65
74
  await first.stop();
66
75
  }
67
76
  });
77
+
78
+ // PR #579: `statFile` is the shell's readable-regular-file probe for app assets (pages/app.css,
79
+ // pages/app.js). It MUST classify the entry's type with a non-blocking `stat` BEFORE opening the
80
+ // path for reading: a POSIX read-only open of a FIFO waits forever for a writer, so probing a FIFO
81
+ // at the asset path would hang every shell request. This guards that a special-file entry returns
82
+ // a non-regular verdict promptly instead of blocking. (POSIX-only: `mkfifo` isn't on Windows.)
83
+ const hasMkfifo = process.platform !== "win32";
84
+ // A blocked read-only `open` of a FIFO sits in libuv and CANNOT be cancelled from within the
85
+ // process: `Promise.race` against a timeout would leave the losing probe running, and unlinking
86
+ // the FIFO does not release it — the suite process would never exit, so the regression would not
87
+ // actually fail fast. Run the probe in a killable CHILD process instead: the parent races the
88
+ // child's verdict message against a timeout and SIGKILLs the child on timeout, so a blocking-open
89
+ // regression fails this test fast (child startup + a ~2s probe budget) instead of wedging the runner.
90
+ //
91
+ // Two separate budgets, deliberately: the child first strip-types `node.ts` and its transitive
92
+ // imports on a cold `--experimental-strip-types` start, which is HUNDREDS of ms and — crucially —
93
+ // load-sensitive (observed >2s under the full parallel suite, #579). If a single timeout covered
94
+ // both startup AND the `statFile` call, that cold-start cost would contaminate the probe budget and
95
+ // the test would fail spuriously under load. So the child emits a `ready` message AFTER its imports
96
+ // resolve; the parent only arms the (short) probe timeout once ready. Startup gets its own generous,
97
+ // load-insensitive guard, and the probe budget then measures ONLY `statFile` — the blocking-open
98
+ // regression (which manifests inside `statFile`, after `ready`) still fails fast.
99
+ //
100
+ // Node-only: the child is forked from `process.execPath` with Node-only flags
101
+ // (`--experimental-strip-types`, `--input-type=module`). Under Deno, `child_process.fork` spawns
102
+ // the DENO binary, which wraps the `--eval` source in a CJS shim (`$deno$eval.mts` →
103
+ // `runInThisContext`) — the ESM `import` in the probe source is then a SyntaxError and the child
104
+ // dies before `ready`. The probe is a guard for the NODE host adapter's blocking-open regression,
105
+ // and the node-based suite (`npm test`, run by the `build` CI job) exercises it on every PR; the
106
+ // Deno job runs this same file, so it must skip there rather than fail on the runtime mismatch.
107
+ const PROBE_STARTUP_TIMEOUT_MS = 30_000;
108
+ type FifoReadyMsg = { ready: true };
109
+ type FifoVerdictMsg = { verdict: { isFile: boolean } | null };
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return typeof value === "object" && value !== null;
112
+ }
113
+ function isFifoReadyMsg(value: unknown): value is FifoReadyMsg {
114
+ return isRecord(value) && value.ready === true;
115
+ }
116
+ function isFifoVerdictMsg(value: unknown): value is FifoVerdictMsg {
117
+ if (!isRecord(value) || !("verdict" in value)) return false;
118
+ const verdict = value.verdict;
119
+ if (verdict === null) return true;
120
+ return isRecord(verdict) && typeof verdict.isFile === "boolean";
121
+ }
122
+ function probeInChild(cwd: string, asset: string, probeTimeoutMs: number): Promise<FifoVerdictMsg | "timeout"> {
123
+ // `node --eval` places the first user argument at process.argv[1] (argv[0] is the node
124
+ // binary), so cwd is argv[1] and the asset path is argv[2] — NOT [2]/[3]. `ready` is sent once
125
+ // the host module is imported and built, BEFORE the (possibly blocking) `statFile` probe.
126
+ const source = `import { createNodeHost } from ${JSON.stringify(new URL("./node.ts", import.meta.url).href)};
127
+ const host = createNodeHost({ cwd: process.argv[1], log: () => {} });
128
+ process.send({ ready: true });
129
+ const verdict = await host.statFile(process.argv[2]);
130
+ process.send({ verdict });
131
+ process.exit(0);`;
132
+ return new Promise((resolve, reject) => {
133
+ const child = fork(
134
+ "--input-type=module",
135
+ ["--experimental-strip-types", "--no-warnings", "--eval", source, cwd, asset],
136
+ { stdio: ["ignore", "ignore", "inherit", "ipc"] },
137
+ );
138
+ let probeTimer: ReturnType<typeof setTimeout> | undefined;
139
+ // Generous, load-insensitive guard: the child must finish importing and emit `ready` within
140
+ // this. A startup slower than 30s means a genuine hang in module load, not the FIFO probe.
141
+ const startupTimer = setTimeout(() => {
142
+ child.kill("SIGKILL");
143
+ reject(new Error("probe child did not become ready (startup) in time"));
144
+ }, PROBE_STARTUP_TIMEOUT_MS);
145
+ const clearTimers = () => {
146
+ clearTimeout(startupTimer);
147
+ if (probeTimer !== undefined) clearTimeout(probeTimer);
148
+ };
149
+ child.on("message", (msg: unknown) => {
150
+ if (isFifoReadyMsg(msg)) {
151
+ // Startup done — now time ONLY the statFile call. A blocking-open regression blocks here
152
+ // and this short budget SIGKILLs the child, failing the test fast.
153
+ clearTimeout(startupTimer);
154
+ probeTimer = setTimeout(() => {
155
+ child.kill("SIGKILL");
156
+ resolve("timeout");
157
+ }, probeTimeoutMs);
158
+ return;
159
+ }
160
+ clearTimers();
161
+ child.disconnect();
162
+ if (isFifoVerdictMsg(msg)) resolve(msg);
163
+ else reject(new Error(`unexpected probe message: ${JSON.stringify(msg)}`));
164
+ });
165
+ child.once("error", (err) => {
166
+ clearTimers();
167
+ reject(err);
168
+ });
169
+ // Fail fast with the real cause when the child dies without messaging (e.g. a
170
+ // syntax/runtime crash during module load) instead of waiting out the startup
171
+ // timeout and reporting a misleading "did not become ready".
172
+ child.once("exit", (code, signal) => {
173
+ clearTimers();
174
+ reject(
175
+ new Error(
176
+ `probe child exited before reporting (code=${String(code)}, signal=${String(signal)})`,
177
+ ),
178
+ );
179
+ });
180
+ });
181
+ }
182
+ test(
183
+ "statFile classifies a FIFO as non-regular without blocking (does not hang)",
184
+ { skip: !hasMkfifo || !nodeImportsTs || isDeno() },
185
+ async () => {
186
+ const dir = await mkdtemp(join(tmpdir(), "urban-fifo-"));
187
+ try {
188
+ const fifo = join(dir, "app.css");
189
+ await execFileAsync("mkfifo", [fifo]);
190
+ const msg = await probeInChild(dir, "app.css", 2000);
191
+ assert.notEqual(msg, "timeout", "statFile must not block on a FIFO");
192
+ // Require a POSITIVE non-regular verdict. Accepting `null` (the probe-error path) would
193
+ // let the test pass vacuously without ever probing the FIFO — e.g. if the child crashed
194
+ // or read the wrong argv index and statFile swallowed the resulting path error.
195
+ assert.ok(
196
+ msg !== "timeout" && msg.verdict !== null && msg.verdict.isFile === false,
197
+ `a FIFO must positively probe as a non-regular file (got ${JSON.stringify(msg)})`,
198
+ );
199
+ } finally {
200
+ await rm(dir, { recursive: true, force: true });
201
+ }
202
+ },
203
+ );
204
+
205
+ // Companion to the FIFO guard: a real regular file still probes as `isFile: true`, and a directory
206
+ // at the asset path probes as non-regular — the two verdicts `mountPages` relies on to link only
207
+ // genuinely servable assets.
208
+ test("statFile reports a regular file as isFile and a directory as non-regular", async () => {
209
+ const dir = await mkdtemp(join(tmpdir(), "urban-stat-"));
210
+ try {
211
+ await writeFile(join(dir, "app.js"), "export {};");
212
+ await mkdir(join(dir, "app.css"), { recursive: true });
213
+ const host = createNodeHost({ cwd: dir, log: () => {} });
214
+ assert.deepEqual(await host.statFile!("app.js"), { isFile: true });
215
+ assert.deepEqual(await host.statFile!("app.css"), { isFile: false });
216
+ assert.equal(await host.statFile!("missing.css"), null);
217
+ } finally {
218
+ await rm(dir, { recursive: true, force: true });
219
+ }
220
+ });
@@ -1,7 +1,7 @@
1
1
  // Node adapter — implements HostContext against `node:*`. This is one of only two files
2
2
  // (with deno.ts) allowed to touch a concrete runtime.
3
3
 
4
- import { readFile, readdir, stat } from "node:fs/promises";
4
+ import { open, readFile, readdir, stat } from "node:fs/promises";
5
5
  import { existsSync } from "node:fs";
6
6
  import { watch as fsWatch, type FSWatcher } from "node:fs";
7
7
  import { createServer } from "node:http";
@@ -154,6 +154,32 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
154
154
  return false;
155
155
  }
156
156
  },
157
+ async statFile(p) {
158
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
159
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
160
+ // waiting for a writer, so opening before the type check would hang every shell request on
161
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
162
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
163
+ // handle open/stat still answers the real serving condition — a file `stat` reports as
164
+ // regular can be unreadable (ACLs / permissions), and the shell must not link an asset that
165
+ // then 404s. Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
166
+ try {
167
+ const meta = await stat(abs(p));
168
+ if (!meta.isFile()) return { isFile: false };
169
+ } catch {
170
+ return null;
171
+ }
172
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
173
+ try {
174
+ handle = await open(abs(p), "r");
175
+ const s = await handle.stat();
176
+ return { isFile: s.isFile() };
177
+ } catch {
178
+ return null;
179
+ } finally {
180
+ await handle?.close();
181
+ }
182
+ },
157
183
  openSqlite(path) {
158
184
  const db = new DatabaseSync(abs(path));
159
185
  return wrapNodeSqlite(db);
@@ -16,6 +16,12 @@ function fakeHost(files: Record<string, string[]>): HostContext {
16
16
  const name = slash >= 0 ? p.slice(slash + 1) : p;
17
17
  return (files[dir] ?? []).includes(name);
18
18
  },
19
+ statFile: async (p) => {
20
+ const slash = p.lastIndexOf("/");
21
+ const dir = slash >= 0 ? p.slice(0, slash) : "";
22
+ const name = slash >= 0 ? p.slice(slash + 1) : p;
23
+ return (files[dir] ?? []).includes(name) ? { isFile: true } : null;
24
+ },
19
25
  openSqlite: () => {
20
26
  throw new Error("n/a");
21
27
  },
@@ -101,6 +101,25 @@ export interface HostContext {
101
101
  listSubdirs?(dir: string): Promise<string[]>;
102
102
  /** True if the path exists (file or directory). */
103
103
  exists(path: string): Promise<boolean>;
104
+ /**
105
+ * Probe a path as a READABLE REGULAR FILE without reading its bytes: `{ isFile }` when it
106
+ * can be opened for reading, or `null` when it is absent or unreadable. The pages surface
107
+ * uses this to probe the app-owned `app.css`/`app.js` convention on the per-request shell
108
+ * hot path: `exists` is too broad (true for a DIRECTORY, which the shell would then link and
109
+ * the browser would 404 on), and probing via `readTextFile` reads and allocates a
110
+ * potentially large file just to answer "is it there?". A bare metadata `stat` is not
111
+ * enough either — it can succeed for a regular file that `readTextFile` cannot open (ACLs /
112
+ * permissions), so the shell would link an asset that then 404s on serving. Implementations
113
+ * therefore OPEN the file for reading (consuming no bytes) and stat the open handle, so this
114
+ * answers the same success condition as serving.
115
+ *
116
+ * OPTIONAL: `HostContext` is a public, externally-implementable seam (re-exported from
117
+ * `runtime/index.ts`, documented in the README as a supported custom-host extension point),
118
+ * so a new required member would be source-breaking. The pages surface falls back to a
119
+ * read-based probe when a host does not provide it; the built-in adapters do, as an
120
+ * allocation-free optimization on the hot path.
121
+ */
122
+ statFile?(path: string): Promise<{ isFile: boolean } | null>;
104
123
  /** Open (creating if needed) a SQLite database at a filesystem path. */
105
124
  openSqlite(path: string): SqliteDb;
106
125
  /**
@@ -82,6 +82,7 @@ function build(
82
82
  readTextFile: async () => "",
83
83
  listDir: async () => [],
84
84
  exists: async () => false,
85
+ statFile: async () => null,
85
86
  openSqlite: () => {
86
87
  throw new Error("sqlite not used in this test");
87
88
  },
@@ -262,6 +263,7 @@ test("a rejected import is evicted from the cache so a later load can succeed",
262
263
  readTextFile: async () => "",
263
264
  listDir: async () => [],
264
265
  exists: async () => false,
266
+ statFile: async () => null,
265
267
  openSqlite: () => {
266
268
  throw new Error("sqlite not used in this test");
267
269
  },
@@ -47,6 +47,7 @@ function ctxWithFiles(files: Record<string, string>): RuntimeContext {
47
47
  },
48
48
  listDir: async () => [],
49
49
  exists: async (p: string) => p in files,
50
+ statFile: async (p: string) => (p in files ? { isFile: true } : null),
50
51
  openSqlite: () => {
51
52
  throw new Error("sqlite not used in this test");
52
53
  },
@@ -115,6 +115,7 @@ function build(
115
115
  readTextFile: async () => specText,
116
116
  listDir: async () => [],
117
117
  exists: async (p: string) => existsSet.has(p),
118
+ statFile: async (p: string) => (existsSet.has(p) ? { isFile: true } : null),
118
119
  openSqlite: () => {
119
120
  throw new Error("sqlite not used in this test");
120
121
  },
@@ -645,6 +646,7 @@ test("a failed initial spec load is retried, not cached as a permanent 500", asy
645
646
  },
646
647
  listDir: async () => [],
647
648
  exists: async () => false,
649
+ statFile: async () => null,
648
650
  openSqlite: () => {
649
651
  throw new Error("sqlite not used in this test");
650
652
  },
@@ -67,6 +67,7 @@ function makeCtx(
67
67
  env: (name) => opts.env?.[name],
68
68
  log: (level: string, msg: string) => logs.push({ level, msg }),
69
69
  exists: async (p: string) => p in files,
70
+ statFile: async (p: string) => (p in files ? { isFile: true } : null),
70
71
  readTextFile: async (p: string) => {
71
72
  if (!(p in files)) throw new Error(`ENOENT ${p}`);
72
73
  return files[p];
@@ -98,6 +98,7 @@ test("applyMigrations joins each migration file onto its dir without reintroduci
98
98
  env: () => undefined,
99
99
  now: () => 0,
100
100
  exists: async () => true,
101
+ statFile: async () => ({ isFile: true }),
101
102
  listDir: async () => ["002_b.sql", "001_a.sql"],
102
103
  readTextFile: async (path: string) => {
103
104
  readPaths.push(path);
@@ -144,6 +144,7 @@ function buildHarness(opts: {
144
144
  },
145
145
  listDir: async () => [],
146
146
  exists: async (p: string) => (p === BRIEF_PATH ? briefExists : false),
147
+ statFile: async (p: string) => (p === BRIEF_PATH && briefExists ? { isFile: true } : null),
147
148
  openSqlite: () => {
148
149
  throw new Error("sqlite not used in this harness");
149
150
  },
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
8
8
  import { makeRouter } from "../router.ts";
9
- import { createPagesRoutes, type GridLayout, type PagesDataSource, type PagesDeps, parseGridLayout } from "./pages.ts";
9
+ import { createPagesRoutes, type GridLayout, type PagesDataSource, type PagesDeps, parseGridLayout, RUNTIME_JS_PATH } from "./pages.ts";
10
10
 
11
11
  function req(
12
12
  method: string,
@@ -2123,3 +2123,138 @@ test("#572: parseGridLayout accepts only the declared layouts (anything else is
2123
2123
  assert.equal(parseGridLayout("grid"), undefined);
2124
2124
  assert.equal(parseGridLayout(1), undefined);
2125
2125
  });
2126
+
2127
+ // nano-ide#578 — an app MAY ship `pages/app.css` / `pages/app.js`; the shell links them only when present.
2128
+ function appAssetRoutes(files: Record<string, string>) {
2129
+ return makeRouter(
2130
+ createPagesRoutes(
2131
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
2132
+ {
2133
+ db: fakeDb(),
2134
+ engine: fakeEngine().engine,
2135
+ readPage: async () => "{}",
2136
+ readAsset: async (p) => {
2137
+ const body = files[p];
2138
+ if (body === undefined) throw new Error(`ENOENT ${p}`);
2139
+ return body;
2140
+ },
2141
+ },
2142
+ ),
2143
+ );
2144
+ }
2145
+
2146
+ test("#578: no app.css/app.js → the shell references neither (no 404 noise)", async () => {
2147
+ const body = (await appAssetRoutes({})(req("GET", "/"))).body ?? "";
2148
+ assert.doesNotMatch(body, /app\.css/);
2149
+ assert.doesNotMatch(body, /app\.js/);
2150
+ });
2151
+
2152
+ test("#578: app.css is linked AFTER the built-in CSS, and app.js loads after the runtime", async () => {
2153
+ const router = appAssetRoutes({ "pages/app.css": "body{color:red}", "pages/app.js": "export {};" });
2154
+ const body = (await router(req("GET", "/"))).body ?? "";
2155
+ const css = body.indexOf('<link rel="stylesheet" href="./app.css" />');
2156
+ assert.ok(css > body.indexOf("</style>"), "app.css must follow the renderer <style> so its rules win");
2157
+ // Compare against the actual fingerprinted runtime <script> tag — the bare word "runtime"
2158
+ // also appears in the embedded renderer CSS, so a substring match would pass even if the
2159
+ // app script were moved before the runtime module.
2160
+ const runtimeTag = `<script type="module" src=".${RUNTIME_JS_PATH}"></script>`;
2161
+ const runtimeAt = body.indexOf(runtimeTag);
2162
+ assert.ok(runtimeAt >= 0, "the shell references the fingerprinted runtime module");
2163
+ const js = body.indexOf('<script type="module" src="./app.js"></script>');
2164
+ assert.ok(js > runtimeAt, "app.js must load after the runtime module");
2165
+ const cssRes = await router(req("GET", "/app.css"));
2166
+ assert.equal(cssRes.status, 200);
2167
+ assert.equal(cssRes.body, "body{color:red}");
2168
+ assert.match(String(cssRes.headers?.["content-type"]), /text\/css/);
2169
+ // Mutable at a stable URL: the browser must revalidate on every load so an edit (or a
2170
+ // remove/re-add) takes effect without a restart — same policy as `/app/runtime.js`.
2171
+ assert.equal(cssRes.headers?.["cache-control"], "no-cache");
2172
+ const jsRes = await router(req("GET", "/app.js"));
2173
+ assert.equal(jsRes.status, 200);
2174
+ assert.match(String(jsRes.headers?.["content-type"]), /javascript/);
2175
+ assert.equal(jsRes.headers?.["cache-control"], "no-cache");
2176
+ // A missing app asset 404s no-cache too, so a cached 404 can't mask a later re-add.
2177
+ const missing = await appAssetRoutes({})(req("GET", "/app.css"));
2178
+ assert.equal(missing.status, 404);
2179
+ assert.equal(missing.headers?.["cache-control"], "no-cache");
2180
+ });
2181
+
2182
+ test("#578: only the present asset is referenced", async () => {
2183
+ const body = (await appAssetRoutes({ "pages/app.css": "x" })(req("GET", "/"))).body ?? "";
2184
+ assert.match(body, /href="\.\/app\.css"/);
2185
+ assert.doesNotMatch(body, /src="\.\/app\.js"/);
2186
+ });
2187
+
2188
+ test("#578: the shell probes existence WITHOUT reading the assets when the host offers an exists seam", async () => {
2189
+ const files: Record<string, string> = { "pages/app.js": "export {};" };
2190
+ const reads: string[] = [];
2191
+ const probes: string[] = [];
2192
+ const router = makeRouter(
2193
+ createPagesRoutes(
2194
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
2195
+ {
2196
+ db: fakeDb(),
2197
+ engine: fakeEngine().engine,
2198
+ readPage: async () => "{}",
2199
+ readAsset: async (p) => {
2200
+ reads.push(p);
2201
+ const body = files[p];
2202
+ if (body === undefined) throw new Error(`ENOENT ${p}`);
2203
+ return body;
2204
+ },
2205
+ existsAsset: async (p) => {
2206
+ probes.push(p);
2207
+ return p in files;
2208
+ },
2209
+ },
2210
+ ),
2211
+ );
2212
+ const body = (await router(req("GET", "/"))).body ?? "";
2213
+ assert.match(body, /src="\.\/app\.js"/, "a present app.js is linked");
2214
+ assert.doesNotMatch(body, /href="\.\/app\.css"/, "an absent app.css is not");
2215
+ assert.deepEqual([...probes].sort(), ["pages/app.css", "pages/app.js"], "both assets are probed per shell request");
2216
+ assert.deepEqual(reads, [], "no asset bytes are read to build the shell");
2217
+
2218
+ // Per-request, NOT memoized (#578 no-restart): swap which asset exists and re-request. A shell
2219
+ // that cached the first probe result would keep linking app.js (and omit app.css); a genuine
2220
+ // per-request probe must reflect the removal/addition AND re-probe BOTH paths on the next load.
2221
+ probes.length = 0;
2222
+ delete files["pages/app.js"];
2223
+ files["pages/app.css"] = "body{color:red}";
2224
+ const body2 = (await router(req("GET", "/"))).body ?? "";
2225
+ assert.match(body2, /href="\.\/app\.css"/, "a newly-added app.css is linked on the next request");
2226
+ assert.doesNotMatch(body2, /src="\.\/app\.js"/, "a removed app.js is dropped on the next request");
2227
+ assert.deepEqual(
2228
+ [...probes].sort(),
2229
+ ["pages/app.css", "pages/app.js"],
2230
+ "both assets are re-probed on the second request (no memoization)",
2231
+ );
2232
+ assert.deepEqual(reads, [], "still no asset bytes are read to build the shell");
2233
+ });
2234
+
2235
+ test("#578: a DIRECTORY at an asset path is not linked (the probe is file-only, like serving)", async () => {
2236
+ // `HostContext.exists` is true for files AND directories; the probe contract must answer
2237
+ // "would serving this path succeed?" — a `pages/app.css/` directory 404s when read, so the
2238
+ // shell must not link it. The injected probe here is exactly what `mountPages` builds from
2239
+ // `HostContext.statFile` (`(await statFile(p))?.isFile === true`).
2240
+ const files: Record<string, string> = { "pages/app.js": "export {};" };
2241
+ const dirs = new Set(["pages/app.css"]);
2242
+ const router = makeRouter(
2243
+ createPagesRoutes(
2244
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
2245
+ {
2246
+ db: fakeDb(),
2247
+ engine: fakeEngine().engine,
2248
+ readPage: async () => "{}",
2249
+ readAsset: async (p) => {
2250
+ if (dirs.has(p)) throw new Error(`EISDIR ${p}`);
2251
+ throw new Error(`ENOENT ${p}`);
2252
+ },
2253
+ existsAsset: async (p) => !dirs.has(p) && p in files,
2254
+ },
2255
+ ),
2256
+ );
2257
+ const body = (await router(req("GET", "/"))).body ?? "";
2258
+ assert.doesNotMatch(body, /href="\.\/app\.css"/, "a directory named app.css is not linked");
2259
+ assert.match(body, /src="\.\/app\.js"/, "a real app.js file still is");
2260
+ });
@@ -27,7 +27,7 @@
27
27
 
28
28
  import type { AppApi, RuntimeContext } from "../context.ts";
29
29
  import { errorMessage, isRecord } from "../guards.ts";
30
- import type { EngineClient } from "../host.ts";
30
+ import type { EngineClient, HttpResponse } from "../host.ts";
31
31
  import { html, json, type Route } from "../router.ts";
32
32
  import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
33
33
  import { apiDocsPath } from "./api.ts";
@@ -89,6 +89,21 @@ export interface PagesDeps {
89
89
  * Injectable for tests. Rejects (→ 404) when the file is missing.
90
90
  */
91
91
  readAsset?(path: string): Promise<string>;
92
+ /**
93
+ * Probe whether a static asset exists as a READABLE REGULAR FILE at an app-root-relative
94
+ * path, WITHOUT reading its bytes. The per-request shell build uses this for the app-owned
95
+ * `app.css`/`app.js` convention (#578): those files can be large and the shell is revalidated
96
+ * on every load, so probing via a full `readAsset` would put an avoidable read + allocation
97
+ * on the HTML hot path (and read the file twice — once here, once when the browser fetches
98
+ * it). Must answer the same question the `readAsset` fallback answers — "would serving this
99
+ * path succeed?" — so a directory (or unreadable entry) at the path is a NO. `mountPages`
100
+ * wires this to the optional `HostContext.statFile` (which opens the file for reading to
101
+ * confirm a readable regular file — never `HostContext.exists`, which is true for directories
102
+ * too, and a `pages/app.css/` DIRECTORY would link an asset the browser then 404s on).
103
+ * Optional; falls back to a read-based probe when absent.
104
+ * Injectable for tests.
105
+ */
106
+ existsAsset?(path: string): Promise<boolean>;
92
107
  /**
93
108
  * List the available page ids (the `*.page.json` basenames under `pagesDir`),
94
109
  * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
@@ -118,6 +133,13 @@ function javascript(
118
133
  };
119
134
  }
120
135
 
136
+ // App-owned shell customisation by convention (nano-ide#578): an app MAY ship `pagesDir/app.css`
137
+ // (linked AFTER the built-in renderer CSS, so its rules win — themes, restyling) and/or
138
+ // `pagesDir/app.js` (an ES module loaded after the runtime — e.g. a theme picker). Each is served
139
+ // as a root sidecar and referenced from the shell ONLY when the file exists, so apps without them
140
+ // see no change and no 404.
141
+ export const APP_ASSETS = ["app.css", "app.js"] as const;
142
+
121
143
  // The `appView` sidecar documents an app ships alongside its `*.page.json` under
122
144
  // `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
123
145
  // src is the node's base-relative `embed` (`./embed.html`), which — resolved against
@@ -130,7 +152,7 @@ function javascript(
130
152
  // `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
131
153
  // this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
132
154
  // import-map references is served separately under the `/dist/` prefix below.
133
- const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"] as const;
155
+ const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css", ...APP_ASSETS] as const;
134
156
 
135
157
  // The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
136
158
  // resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
@@ -226,6 +248,9 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
226
248
  // Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
227
249
  // `host.readTextFile`, so there is one source of file bytes, not two.
228
250
  const readAsset = deps.readAsset ?? readPage;
251
+ // Existence probe for the app-owned shell assets (#578): a dedicated `existsAsset` when the
252
+ // host offers one (a metadata stat — no file bytes cross the seam), else a read-based probe.
253
+ const existsAsset = deps.existsAsset ?? ((path: string) => readAsset(path).then(() => true, () => false));
229
254
 
230
255
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
231
256
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -262,16 +287,25 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
262
287
  };
263
288
 
264
289
  const routes: Route[] = [];
265
- const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
266
290
  // The shell must never be pinned by the browser: it carries the *current* fingerprinted
267
291
  // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
268
292
  // a revalidation on every load; the shell body is tiny, and the expensive module it references
269
293
  // is what gets the long-lived immutable caching (via its content-hashed URL).
270
- shell.headers = { ...shell.headers, "cache-control": "no-cache" };
294
+ // Built per request: it probes for the app-owned `app.css`/`app.js` (#578) so adding or removing
295
+ // one takes effect without a restart.
296
+ const shell = async (): Promise<HttpResponse> => {
297
+ const [appCss, appJs] = await Promise.all([
298
+ existsAsset(`${pagesDir}/app.css`),
299
+ existsAsset(`${pagesDir}/app.js`),
300
+ ]);
301
+ const res = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto", { appCss, appJs }));
302
+ res.headers = { ...res.headers, "cache-control": "no-cache" };
303
+ return res;
304
+ };
271
305
 
272
306
  // ── the renderer shell + module ─────────────────────────────────────────
273
- routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
274
- routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
307
+ routes.push({ method: "GET", path: "/", source: "surface:pages", handler: shell });
308
+ routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: shell });
275
309
  // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
276
310
  routes.push({
277
311
  method: "GET",
@@ -299,11 +333,21 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
299
333
  // onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
300
334
  // `/healthz` liveness route the runtime appends after the pages surface).
301
335
  for (const name of SIDECAR_ASSETS) {
336
+ // The app-owned `app.css`/`app.js` are MUTABLE at stable URLs — the whole point of the
337
+ // convention is that an edit (or a remove/re-add) takes effect without a restart, and the
338
+ // shell re-probes on every load. Serve them `no-cache` (like `/app/runtime.js`) so a
339
+ // browser/intermediary must revalidate instead of replaying a heuristically-cached 200/404.
340
+ // The other sidecars are deploy-time fixtures, so they keep the generic response.
341
+ const mutable = APP_ASSETS.some((a) => a === name);
302
342
  routes.push({
303
343
  method: "GET",
304
344
  path: `/${name}`,
305
345
  source: "surface:pages",
306
- handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
346
+ handler: async () => {
347
+ const res = await serveAsset(readAsset, `${pagesDir}/${name}`);
348
+ if (mutable) res.headers = { ...res.headers, "cache-control": "no-cache" };
349
+ return res;
350
+ },
307
351
  });
308
352
  }
309
353
  // The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
@@ -653,12 +697,24 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
653
697
  };
654
698
  const sourceName = opts.sourceName ?? "app";
655
699
  const bindings = ctx.manifest.instanceTracking ?? [];
700
+ // Probe "would serving this app-owned asset succeed?" — a READABLE REGULAR FILE, not the
701
+ // broader `HostContext.exists` (which is true for directories too, and a `pages/app.css/`
702
+ // DIRECTORY would link an asset the browser then 404s on). `statFile` is an OPTIONAL public
703
+ // host capability (a custom host need not implement it), and it opens the file for reading so
704
+ // no bytes cross the seam on the per-request shell hot path. Only wire the optimized probe
705
+ // when the host provides it; otherwise `createPagesRoutes` falls back to a read-based probe
706
+ // that answers the identical question.
707
+ const statFile = ctx.host.statFile?.bind(ctx.host);
708
+ const existsAsset = statFile
709
+ ? async (p: string) => (await statFile(p))?.isFile === true
710
+ : undefined;
656
711
  const routes = createPagesRoutes(opts, {
657
712
  db: app.data.open(sourceName),
658
713
  engine: app.engine,
659
714
  cancel: (key) => cancelInstanceReconciling(app, bindings, key),
660
715
  readPage: (p) => ctx.host.readTextFile(p),
661
716
  readAsset: (p) => ctx.host.readTextFile(p),
717
+ existsAsset,
662
718
  listPages: async () => {
663
719
  const dir = opts.pagesDir ?? "pages";
664
720
  const names = await ctx.host.listDir(dir).catch(() => []);
@@ -679,7 +735,12 @@ function escapeAttr(s: string): string {
679
735
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
680
736
  }
681
737
 
682
- function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLayout: GridLayout): string {
738
+ function rendererShell(
739
+ homePage: string,
740
+ apiDocsPath: string | undefined,
741
+ gridLayout: GridLayout,
742
+ app: { appCss: boolean; appJs: boolean } = { appCss: false, appJs: false },
743
+ ): string {
683
744
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
684
745
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
685
746
  // tab a handle back to this window (reverse-tabnabbing).
@@ -704,12 +765,12 @@ function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLa
704
765
  <meta charset="utf-8" />
705
766
  <meta name="viewport" content="width=device-width, initial-scale=1" />
706
767
  <title>Urban App</title>
707
- <style>${RENDERER_CSS}</style>
768
+ <style>${RENDERER_CSS}</style>${app.appCss ? '\n <link rel="stylesheet" href="./app.css" />' : ""}
708
769
  </head>
709
770
  <body data-grid-layout="${gridLayout}">${apiDocsBadge}
710
771
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
711
772
  <script>${FORMJS_JS}</script>
712
- <script type="module" src=".${RUNTIME_JS_PATH}"></script>
773
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>${app.appJs ? '\n <script type="module" src="./app.js"></script>' : ""}
713
774
  </body>
714
775
  </html>`;
715
776
  }
@@ -1095,5 +1156,8 @@ function fnv1aHex(s: string): string {
1095
1156
  // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1096
1157
  // per content, the response itself is served `immutable` with a one-year max-age.
1097
1158
  const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1098
- const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1159
+ // Exported so tests can locate the actual fingerprinted runtime <script> tag in the shell
1160
+ // (asserting against the bare word "runtime" is unsound — it also appears in the embedded
1161
+ // renderer CSS, so such a test passes even if the app script were moved before the runtime).
1162
+ export const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1099
1163
  const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
@@ -16,6 +16,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
16
16
  readTextFile: async () => "",
17
17
  listDir: async () => [],
18
18
  exists: async () => false,
19
+ statFile: async () => null,
19
20
  openSqlite: () => {
20
21
  throw new Error("sqlite not used in this test");
21
22
  },
@@ -80,6 +80,7 @@ function fakeCtx(triggers: Trigger[]): { ctx: RuntimeContext; hostLogs: Array<{
80
80
  readTextFile: async () => "",
81
81
  listDir: async () => [],
82
82
  exists: async () => false,
83
+ statFile: async () => null,
83
84
  openSqlite: () => {
84
85
  throw new Error("sqlite not used in this test");
85
86
  },
@@ -68,6 +68,7 @@ function makeCtx(
68
68
  readTextFile: async () => "",
69
69
  listDir: async () => [],
70
70
  exists: async () => false,
71
+ statFile: async () => null,
71
72
  openSqlite: () => {
72
73
  throw new Error("sqlite not used in this test");
73
74
  },
@@ -757,3 +757,30 @@ test('#572: an omitted surfaces.pages.gridLayout falls back to the "auto" layout
757
757
  test('#572: an invalid surfaces.pages.gridLayout falls back to the "auto" layout', async () => {
758
758
  assert.equal(await shellGridLayout({ gridLayout: "grid" }), "auto");
759
759
  });
760
+
761
+ // #578: app-owned `pages/app.css`/`pages/app.js` by convention, driven end-to-end through the real
762
+ // node host (createUrbanApp → mountPages → HostContext.statFile), not an injected `existsAsset`.
763
+ // This guards the production wiring the focused pages.test.ts probe tests can't: that `mountPages`
764
+ // forwards the host's file-only probe (so a DIRECTORY at an asset path is NOT linked — it would
765
+ // 404 on serving) and that a real regular file IS linked.
766
+ test("#578: the shell links a real pages/app.js file but not a pages/app.css DIRECTORY (real host)", async () => {
767
+ const dir = await makeFixture({ surfaces: { pages: { enabled: true } } });
768
+ // A regular file at pages/app.js (should be linked) and a DIRECTORY at pages/app.css (should not).
769
+ await mkdir(join(dir, "pages"), { recursive: true });
770
+ await writeFile(join(dir, "pages", "app.js"), "export {};");
771
+ await mkdir(join(dir, "pages", "app.css"), { recursive: true });
772
+ const host = createNodeHost({ cwd: dir, log: () => {} });
773
+ const engine = new FakeEngine();
774
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
775
+ await app.start();
776
+ try {
777
+ const res = await fetch(`http://localhost:${app.httpPort!}/`);
778
+ assert.equal(res.status, 200);
779
+ const body = await res.text();
780
+ assert.match(body, /src="\.\/app\.js"/, "a real app.js file is linked");
781
+ assert.doesNotMatch(body, /href="\.\/app\.css"/, "a DIRECTORY named app.css is not linked");
782
+ } finally {
783
+ await app.stop();
784
+ await rm(dir, { recursive: true, force: true });
785
+ }
786
+ });
@@ -205,6 +205,7 @@ function fakeHost(): { host: HostContext; fire: (p: string) => void; closed: ()
205
205
  readTextFile: async () => "",
206
206
  listDir: async () => [],
207
207
  exists: async () => false,
208
+ statFile: async () => null,
208
209
  openSqlite: () => {
209
210
  throw new Error("sqlite not used in this test");
210
211
  },