@nanobpm/urban 0.94.2 → 0.96.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 +54 -11
- package/dist/runtime/core/modules/mcp.js +5 -1
- package/dist/runtime/core/modules/pages.d.ts +17 -0
- package/dist/runtime/core/modules/pages.js +54 -10
- package/dist/runtime/core/process-identity.d.ts +21 -0
- package/dist/runtime/core/process-identity.js +56 -0
- package/dist/runtime/engine/nanosdk.d.ts +3 -5
- package/dist/runtime/engine/nanosdk.js +12 -16
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +2 -2
- 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 +54 -8
- 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 +25 -0
- package/src/runtime/core/modules/mcp.ts +7 -2
- 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/process-identity.test.ts +72 -0
- package/src/runtime/core/process-identity.ts +66 -0
- package/src/runtime/core/runtime.test.ts +27 -0
- package/src/runtime/devserver.test.ts +1 -0
- package/src/runtime/engine/nanosdk.test.ts +82 -0
- package/src/runtime/engine/nanosdk.ts +18 -16
- package/src/runtime/index.ts +11 -0
|
@@ -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
|
},
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
pickProcessDefinitionIdentity,
|
|
5
|
+
presentEngineKey,
|
|
6
|
+
presentString,
|
|
7
|
+
} from "./process-identity.ts";
|
|
8
|
+
|
|
9
|
+
// The canonical, adapter-agnostic process-definition-identity normalizer. These cases pin the
|
|
10
|
+
// class the shared helper exists to keep consistent (No Drift Surfaces): a padded/numeric key is
|
|
11
|
+
// normalized, a blank/whitespace/non-string/non-finite value is *absent* and never coerced, and a
|
|
12
|
+
// version is kept only when a positive integer number. Scope today: only the SDK adapter
|
|
13
|
+
// (`engine/nanosdk.ts`) routes through this mapping; the WASM test adapter
|
|
14
|
+
// (`@nanobpm/urban-testkit`) runs a deliberately mirrored local copy until #585, pinned to this
|
|
15
|
+
// behaviour by its own identity cases (`wasm-engine.test.ts`).
|
|
16
|
+
|
|
17
|
+
test("presentEngineKey coerces a finite numeric key, trims a string, and drops blank/non-finite/non-string", () => {
|
|
18
|
+
assert.equal(presentEngineKey(7), "7");
|
|
19
|
+
assert.equal(presentEngineKey(" order "), "order");
|
|
20
|
+
assert.equal(presentEngineKey(" "), undefined);
|
|
21
|
+
assert.equal(presentEngineKey(""), undefined);
|
|
22
|
+
assert.equal(presentEngineKey(Number.NaN), undefined);
|
|
23
|
+
assert.equal(presentEngineKey(Number.POSITIVE_INFINITY), undefined);
|
|
24
|
+
assert.equal(presentEngineKey({}), undefined);
|
|
25
|
+
assert.equal(presentEngineKey(null), undefined);
|
|
26
|
+
assert.equal(presentEngineKey(undefined), undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("presentString keeps a trimmed non-empty string and drops blank/non-string (never coerces)", () => {
|
|
30
|
+
assert.equal(presentString(" human "), "human");
|
|
31
|
+
assert.equal(presentString(" "), undefined);
|
|
32
|
+
assert.equal(presentString(""), undefined);
|
|
33
|
+
// A number id is absent — never String(...)-coerced into a garbage id.
|
|
34
|
+
assert.equal(presentString(5), undefined);
|
|
35
|
+
assert.equal(presentString({}), undefined);
|
|
36
|
+
assert.equal(presentString(undefined), undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("pickProcessDefinitionIdentity normalizes present fields and omits blank/malformed ones", () => {
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
pickProcessDefinitionIdentity({
|
|
42
|
+
processDefinitionKey: 7,
|
|
43
|
+
processDefinitionId: " order ",
|
|
44
|
+
processDefinitionVersion: 2,
|
|
45
|
+
}),
|
|
46
|
+
{ processDefinitionKey: "7", processDefinitionId: "order", processDefinitionVersion: 2 },
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("pickProcessDefinitionIdentity drops a blank key, a non-string id, and a stringly version", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
pickProcessDefinitionIdentity({
|
|
53
|
+
processDefinitionKey: " ",
|
|
54
|
+
processDefinitionId: 5,
|
|
55
|
+
processDefinitionVersion: "2",
|
|
56
|
+
}),
|
|
57
|
+
{},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("pickProcessDefinitionIdentity keeps a version only when a positive integer number", () => {
|
|
62
|
+
assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 1.5 }), {});
|
|
63
|
+
assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 0 }), {});
|
|
64
|
+
assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: -1 }), {});
|
|
65
|
+
assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 3 }), {
|
|
66
|
+
processDefinitionVersion: 3,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("pickProcessDefinitionIdentity on an empty row returns an empty identity (fully absent)", () => {
|
|
71
|
+
assert.deepEqual(pickProcessDefinitionIdentity({}), {});
|
|
72
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The single, transport-agnostic normalizer for a {@link ProcessDefinitionIdentity}. Both engine
|
|
2
|
+
// adapters — the live SDK/REST adapter (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process
|
|
3
|
+
// WASM test adapter (`@nanobpm/urban-testkit`, `WasmEngineClient`) — report which deployed
|
|
4
|
+
// definition an instance runs on *both* `createInstance` and `searchProcessInstances`. Each used to
|
|
5
|
+
// carry its own hand-mirrored `pickProcessDefinitionIdentity` (plus a re-declared identity type), so
|
|
6
|
+
// the two could silently diverge the moment identity validation changed — exactly the drift class
|
|
7
|
+
// the shared form-contract normalizer exists to kill (`./form-contract.ts`, issue #252). Centralizing
|
|
8
|
+
// the mapper (and reusing the canonical {@link ProcessDefinitionIdentity}) here removes that surface:
|
|
9
|
+
// a created instance and its later snapshot describe the same definition identically wherever this
|
|
10
|
+
// normalizer runs, because they run the *same* code.
|
|
11
|
+
//
|
|
12
|
+
// Ownership today: only the SDK adapter runs this implementation. `WasmEngineClient` still defines
|
|
13
|
+
// and calls its own behaviourally identical copy (`urban-testkit/src/wasm-engine.ts`) — a deliberate,
|
|
14
|
+
// temporary fork: this module is introduced by the same change, so it exists in no published urban
|
|
15
|
+
// release, and a value import would force the testkit's peer floor up to an urban version that does
|
|
16
|
+
// not yet exist (AGENTS.md: depend on an unreleased sibling only after it is published). Follow-up
|
|
17
|
+
// #585 switches the WASM adapter to this canonical import once the urban release carrying it is
|
|
18
|
+
// published; until then the testkit's own identity cases pin its copy to this normalizer's behaviour.
|
|
19
|
+
|
|
20
|
+
import type { ProcessDefinitionIdentity } from "./host.ts";
|
|
21
|
+
|
|
22
|
+
export type { ProcessDefinitionIdentity };
|
|
23
|
+
|
|
24
|
+
/** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
|
|
25
|
+
* whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
|
|
26
|
+
* key either way) but never `String(...)`-coerces an arbitrary object into a garbage
|
|
27
|
+
* `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
|
|
28
|
+
* matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
|
|
29
|
+
export function presentEngineKey(value: unknown): string | undefined {
|
|
30
|
+
if (typeof value === "number") {
|
|
31
|
+
return Number.isFinite(value) ? String(value) : undefined;
|
|
32
|
+
}
|
|
33
|
+
if (typeof value !== "string") return undefined;
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
return trimmed === "" ? undefined : trimmed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
|
|
39
|
+
* non-string is absent and never `String(...)`-coerced. */
|
|
40
|
+
export function presentString(value: unknown): string | undefined {
|
|
41
|
+
if (typeof value !== "string") return undefined;
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
return trimmed === "" ? undefined : trimmed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
|
|
47
|
+
* search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
|
|
48
|
+
* so a created instance and its later snapshot describe the same definition identically. Each field
|
|
49
|
+
* passes the shared presence rule; a version that is not a positive integer number is omitted
|
|
50
|
+
* (never coerced). */
|
|
51
|
+
export function pickProcessDefinitionIdentity(row: {
|
|
52
|
+
processDefinitionKey?: unknown;
|
|
53
|
+
processDefinitionId?: unknown;
|
|
54
|
+
processDefinitionVersion?: unknown;
|
|
55
|
+
}): ProcessDefinitionIdentity {
|
|
56
|
+
const processDefinitionKey = presentEngineKey(row.processDefinitionKey);
|
|
57
|
+
const processDefinitionId = presentString(row.processDefinitionId);
|
|
58
|
+
const version = row.processDefinitionVersion;
|
|
59
|
+
const processDefinitionVersion =
|
|
60
|
+
typeof version === "number" && Number.isInteger(version) && version > 0 ? version : undefined;
|
|
61
|
+
return {
|
|
62
|
+
...(processDefinitionKey ? { processDefinitionKey } : {}),
|
|
63
|
+
...(processDefinitionId ? { processDefinitionId } : {}),
|
|
64
|
+
...(processDefinitionVersion !== undefined ? { processDefinitionVersion } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -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
|
},
|
|
@@ -200,6 +200,43 @@ test("createInstance routes through the SDK and coerces the key", async () => {
|
|
|
200
200
|
assert.ok(client.calls.includes("createProcessInstance"));
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("createInstance returns the started definition's identity (key, id, version)", async () => {
|
|
204
|
+
// The create response names exactly which deployed definition version the instance runs; the
|
|
205
|
+
// seam must surface it (normalized through the same presence rule as the search snapshot) so a
|
|
206
|
+
// caller need not race an eventually consistent search to learn it.
|
|
207
|
+
const client = fakeSdkClient({
|
|
208
|
+
createProcessInstance: async () => ({
|
|
209
|
+
processInstanceKey: 99,
|
|
210
|
+
processDefinitionKey: 2251799813685249,
|
|
211
|
+
processDefinitionId: " order ",
|
|
212
|
+
processDefinitionVersion: 3,
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
const engine = new SdkEngineClient(client);
|
|
216
|
+
const res = await engine.createInstance({ processDefinitionId: "order" });
|
|
217
|
+
assert.deepEqual(res, {
|
|
218
|
+
processInstanceKey: "99",
|
|
219
|
+
processDefinitionKey: "2251799813685249",
|
|
220
|
+
processDefinitionId: "order",
|
|
221
|
+
processDefinitionVersion: 3,
|
|
222
|
+
variables: undefined,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("createInstance omits definition identity fields the engine leaves blank or malformed", async () => {
|
|
227
|
+
const client = fakeSdkClient({
|
|
228
|
+
createProcessInstance: async () => ({
|
|
229
|
+
processInstanceKey: "99",
|
|
230
|
+
processDefinitionKey: " ",
|
|
231
|
+
processDefinitionId: "",
|
|
232
|
+
processDefinitionVersion: "3", // not a number → omitted, never coerced
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
const engine = new SdkEngineClient(client);
|
|
236
|
+
const res = await engine.createInstance({ processDefinitionId: "order" });
|
|
237
|
+
assert.deepEqual(res, { processInstanceKey: "99", variables: undefined });
|
|
238
|
+
});
|
|
239
|
+
|
|
203
240
|
test("createInstance throws when the SDK response omits the instance key", async () => {
|
|
204
241
|
const client = fakeSdkClient({
|
|
205
242
|
createProcessInstance: async () => ({ variables: { ok: true } }),
|
|
@@ -622,6 +659,51 @@ test("searchProcessInstances surfaces the parent/root linkage keys and drops bla
|
|
|
622
659
|
]);
|
|
623
660
|
});
|
|
624
661
|
|
|
662
|
+
test("searchProcessInstances forwards a processDefinitionId selector (trimmed, blank dropped)", async () => {
|
|
663
|
+
const seen: unknown[] = [];
|
|
664
|
+
const client = fakeSdkClient({
|
|
665
|
+
searchProcessInstances: async (input) => {
|
|
666
|
+
seen.push(input);
|
|
667
|
+
return { items: [] };
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
const engine = new SdkEngineClient(client);
|
|
671
|
+
await engine.searchProcessInstances({ processDefinitionId: " order ", state: "ACTIVE" });
|
|
672
|
+
await engine.searchProcessInstances({ processDefinitionId: " " });
|
|
673
|
+
assert.deepEqual(seen, [
|
|
674
|
+
{ filter: { state: "ACTIVE", processDefinitionId: "order" } },
|
|
675
|
+
{ filter: {} },
|
|
676
|
+
]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("searchProcessInstances surfaces each instance's definition identity", async () => {
|
|
680
|
+
const client = fakeSdkClient({
|
|
681
|
+
searchProcessInstances: async () => ({
|
|
682
|
+
items: [
|
|
683
|
+
{
|
|
684
|
+
processInstanceKey: 10,
|
|
685
|
+
state: "ACTIVE",
|
|
686
|
+
processDefinitionKey: 5,
|
|
687
|
+
processDefinitionId: "order",
|
|
688
|
+
processDefinitionVersion: 2,
|
|
689
|
+
},
|
|
690
|
+
{ processInstanceKey: 11, state: "ACTIVE", processDefinitionId: " ", processDefinitionVersion: null },
|
|
691
|
+
],
|
|
692
|
+
}),
|
|
693
|
+
});
|
|
694
|
+
const engine = new SdkEngineClient(client);
|
|
695
|
+
assert.deepEqual(await engine.searchProcessInstances(), [
|
|
696
|
+
{
|
|
697
|
+
processInstanceKey: "10",
|
|
698
|
+
state: "ACTIVE",
|
|
699
|
+
processDefinitionKey: "5",
|
|
700
|
+
processDefinitionId: "order",
|
|
701
|
+
processDefinitionVersion: 2,
|
|
702
|
+
},
|
|
703
|
+
{ processInstanceKey: "11", state: "ACTIVE" },
|
|
704
|
+
]);
|
|
705
|
+
});
|
|
706
|
+
|
|
625
707
|
test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
|
|
626
708
|
assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
|
|
627
709
|
assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");
|