@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.
- package/dist/runtime/adapters/deno.js +30 -0
- package/dist/runtime/adapters/node.js +31 -1
- package/dist/runtime/core/host.d.ts +21 -0
- package/dist/runtime/core/modules/pages.d.ts +17 -0
- package/dist/runtime/core/modules/pages.js +54 -10
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +32 -1
- package/src/runtime/adapters/node.test.ts +153 -0
- package/src/runtime/adapters/node.ts +27 -1
- package/src/runtime/core/glob.test.ts +6 -0
- package/src/runtime/core/host.ts +19 -0
- package/src/runtime/core/modules/actions.test.ts +2 -0
- package/src/runtime/core/modules/agent.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +2 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.test.ts +1 -0
- package/src/runtime/core/modules/mcp.test.ts +1 -0
- package/src/runtime/core/modules/pages.test.ts +136 -1
- package/src/runtime/core/modules/pages.ts +75 -11
- package/src/runtime/core/modules/surfaces.test.ts +1 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/runtime.test.ts +27 -0
- package/src/runtime/devserver.test.ts +1 -0
|
@@ -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
|
-
|
|
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:
|
|
192
|
-
routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler:
|
|
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: () =>
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
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.
|
|
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<
|
|
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
|
},
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
274
|
-
routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler:
|
|
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: () =>
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
680
736
|
}
|
|
681
737
|
|
|
682
|
-
function rendererShell(
|
|
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
|
-
|
|
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
|
},
|
|
@@ -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
|
},
|