@nanobpm/urban 0.72.2 → 0.74.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/core/modules/pages.d.ts +10 -0
- package/dist/runtime/core/modules/pages.js +125 -1
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/page-nodes.d.ts +12 -0
- package/dist/runtime/core/page-nodes.js +32 -0
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +101 -9
- package/src/runtime/browser/runtime.browser.test.ts +119 -2
- package/src/runtime/core/modules/pages.test.ts +133 -0
- package/src/runtime/core/modules/pages.ts +139 -1
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/page-nodes.ts +38 -0
|
@@ -30,6 +30,16 @@ export interface PagesDeps {
|
|
|
30
30
|
engine: EngineClient;
|
|
31
31
|
/** Read a page file; injectable for tests. */
|
|
32
32
|
readPage(path: string): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* Read a static asset file at an app-root-relative path (the `appView` sidecar
|
|
35
|
+
* documents under `pagesDir` — `embed.html`/`standalone.html`/`mount.js`/`cockpit.css`
|
|
36
|
+
* — and the app `dist/` bundle their import-maps reference). A thin adapter over the
|
|
37
|
+
* host's `readTextFile` seam, distinct from `readPage` (which is page.json-scoped) so
|
|
38
|
+
* the intent reads clearly at the call site. Optional; falls back to `readPage` when
|
|
39
|
+
* absent (both delegate to the same host seam), so existing tests need no change.
|
|
40
|
+
* Injectable for tests. Rejects (→ 404) when the file is missing.
|
|
41
|
+
*/
|
|
42
|
+
readAsset?(path: string): Promise<string>;
|
|
33
43
|
/**
|
|
34
44
|
* List the available page ids (the `*.page.json` basenames under `pagesDir`),
|
|
35
45
|
* powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// GET / (+ /app/runtime.js) → the schema-driven browser renderer
|
|
12
12
|
// GET /app/pages/<id> → the page's page.json
|
|
13
13
|
// GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
|
|
14
|
+
// GET /{embed,standalone}.html /mount.js /cockpit.css → appView sidecars (from pagesDir, #420)
|
|
15
|
+
// GET /dist/<path> → the app dist/ bundle the sidecars reference
|
|
14
16
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
15
17
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
16
18
|
// POST /app/actions/message → engine.publishMessage
|
|
@@ -36,6 +38,80 @@ function javascript(body, status = 200, extraHeaders) {
|
|
|
36
38
|
body,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
41
|
+
// The `appView` sidecar documents an app ships alongside its `*.page.json` under
|
|
42
|
+
// `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
|
|
43
|
+
// src is the node's base-relative `embed` (`./embed.html`), which — resolved against
|
|
44
|
+
// the shell served at the app mount root — the browser fetches at `/embed.html`; that
|
|
45
|
+
// document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
|
|
46
|
+
// `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
|
|
47
|
+
// iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
|
|
48
|
+
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. The `dist/` tree the
|
|
49
|
+
// import-map references is served separately under the `/dist/` prefix below.
|
|
50
|
+
const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"];
|
|
51
|
+
// The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
|
|
52
|
+
// resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
|
|
53
|
+
// resolves to `/dist` regardless of `pagesDir`, so the URL→disk mapping is fixed.
|
|
54
|
+
const DIST_DIR = "dist";
|
|
55
|
+
/** Content type for a served static web asset, by extension. The sidecars + the ESM/CSS/JSON
|
|
56
|
+
* the import-maps reference are all UTF-8 text (the only channel `readTextFile` offers); an
|
|
57
|
+
* unknown extension falls back to `application/octet-stream` rather than mislabelling. This is
|
|
58
|
+
* a distinct concern from deploy's `contentTypeFor` (engine resource types: bpmn/dmn/form/…),
|
|
59
|
+
* so the two maps do not share a source. */
|
|
60
|
+
function assetContentType(path) {
|
|
61
|
+
const dot = path.lastIndexOf(".");
|
|
62
|
+
const ext = dot >= 0 ? path.slice(dot + 1).toLowerCase() : "";
|
|
63
|
+
switch (ext) {
|
|
64
|
+
case "html":
|
|
65
|
+
return "text/html; charset=utf-8";
|
|
66
|
+
case "js":
|
|
67
|
+
case "mjs":
|
|
68
|
+
return "text/javascript; charset=utf-8";
|
|
69
|
+
case "css":
|
|
70
|
+
return "text/css; charset=utf-8";
|
|
71
|
+
case "json":
|
|
72
|
+
case "map": // source maps are JSON
|
|
73
|
+
return "application/json; charset=utf-8";
|
|
74
|
+
case "svg":
|
|
75
|
+
return "image/svg+xml; charset=utf-8";
|
|
76
|
+
case "txt":
|
|
77
|
+
return "text/plain; charset=utf-8";
|
|
78
|
+
default:
|
|
79
|
+
return "application/octet-stream";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// A URL sub-path (the `<rest>` of `/dist/<rest>`) is safe to map onto the filesystem
|
|
83
|
+
// only when it is a plain, forward, relative descent: no `..`/`.` segment (path
|
|
84
|
+
// traversal out of `dist/`), no empty segment (a leading/doubled slash), no absolute
|
|
85
|
+
// path, and no percent-escape or backslash (the request `path` is `URL.pathname`, which
|
|
86
|
+
// is NOT percent-decoded — so a `%2e%2e`/`%2f` smuggled traversal never reaches the FS;
|
|
87
|
+
// we reject any `%` outright rather than decode-then-recheck). Returns the normalised
|
|
88
|
+
// relative path, or null to 404. Mirrors the `[A-Za-z0-9_-]` guards the page/data routes
|
|
89
|
+
// already apply, widened to the `/` `.` a nested asset path needs.
|
|
90
|
+
function safeAssetSubpath(rest) {
|
|
91
|
+
if (rest === "" || rest.includes("%") || rest.includes("\\") || rest.includes("\0"))
|
|
92
|
+
return null;
|
|
93
|
+
if (rest.startsWith("/"))
|
|
94
|
+
return null;
|
|
95
|
+
const segs = rest.split("/");
|
|
96
|
+
for (const s of segs) {
|
|
97
|
+
if (s === "" || s === "." || s === "..")
|
|
98
|
+
return null;
|
|
99
|
+
if (!/^[A-Za-z0-9._-]+$/.test(s))
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return segs.join("/");
|
|
103
|
+
}
|
|
104
|
+
/** Serve a static asset file at an app-root-relative path: 200 with its content type, or 404 when
|
|
105
|
+
* the file is missing/unreadable. Used by both the root sidecar routes and the `/dist/` tree. */
|
|
106
|
+
async function serveAsset(read, filePath) {
|
|
107
|
+
try {
|
|
108
|
+
const body = await read(filePath);
|
|
109
|
+
return { status: 200, headers: { "content-type": assetContentType(filePath) }, body };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
39
115
|
/** A SQL identifier guard — a table name must be a bare identifier *and* a known table
|
|
40
116
|
* (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
|
|
41
117
|
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -59,6 +135,10 @@ export function createPagesRoutes(opts, deps) {
|
|
|
59
135
|
const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
|
|
60
136
|
const sourceName = opts.sourceName ?? "app";
|
|
61
137
|
const { db, engine, readPage, listPages, cancel } = deps;
|
|
138
|
+
// Static assets (the `appView` sidecars + the `dist/` bundle) read through the host seam.
|
|
139
|
+
// Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
|
|
140
|
+
// `host.readTextFile`, so there is one source of file bytes, not two.
|
|
141
|
+
const readAsset = deps.readAsset ?? readPage;
|
|
62
142
|
// The table-name whitelist is memoised: an Urban app runs its migrations at boot
|
|
63
143
|
// (before serving), so the schema is stable for the process lifetime, and the renderer
|
|
64
144
|
// refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
|
|
@@ -114,7 +194,40 @@ export function createPagesRoutes(opts, deps) {
|
|
|
114
194
|
source: "surface:pages",
|
|
115
195
|
handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
|
|
116
196
|
});
|
|
117
|
-
// ──
|
|
197
|
+
// ── appView sidecar assets (#420) ───────────────────────────────────────
|
|
198
|
+
// The `appView` renderer (#419) mounts an <iframe> at the node's base-relative `embed`
|
|
199
|
+
// (`./embed.html`), which — against the shell served at the mount root — the browser
|
|
200
|
+
// fetches at `/embed.html`; that document then pulls `./cockpit.css`, `./mount.js`, and
|
|
201
|
+
// its import-map's `../dist/…`. A DEPLOYED app served none of these, so the iframe src
|
|
202
|
+
// 404'd and the agent cockpit loaded nothing (#416 was necessary but not sufficient
|
|
203
|
+
// without this static-serving surface). Serve each sidecar at its root URL, mapped 1:1
|
|
204
|
+
// onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
|
|
205
|
+
// `/healthz` liveness route the runtime appends after the pages surface).
|
|
206
|
+
for (const name of SIDECAR_ASSETS) {
|
|
207
|
+
routes.push({
|
|
208
|
+
method: "GET",
|
|
209
|
+
path: `/${name}`,
|
|
210
|
+
source: "surface:pages",
|
|
211
|
+
handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
|
|
215
|
+
// `/dist/cockpit/index.js`). A prefix route over an app-root `dist/` directory; the
|
|
216
|
+
// sub-path is strictly validated (no traversal/escape) before it reaches the FS.
|
|
217
|
+
routes.push({
|
|
218
|
+
method: "GET",
|
|
219
|
+
path: `/${DIST_DIR}/`,
|
|
220
|
+
prefix: true,
|
|
221
|
+
source: "surface:pages",
|
|
222
|
+
handler: (req) => {
|
|
223
|
+
const rest = req.path.slice(`/${DIST_DIR}/`.length);
|
|
224
|
+
const safe = safeAssetSubpath(rest);
|
|
225
|
+
if (safe === null) {
|
|
226
|
+
return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
|
|
227
|
+
}
|
|
228
|
+
return serveAsset(readAsset, `${DIST_DIR}/${safe}`);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
118
231
|
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
119
232
|
// page here, and it lets any client enumerate the app's screens. Each entry
|
|
120
233
|
// carries the page's own `title` (best-effort — an unreadable/!JSON page still
|
|
@@ -400,6 +513,7 @@ export function mountPages(ctx, app) {
|
|
|
400
513
|
engine: app.engine,
|
|
401
514
|
cancel: (key) => cancelInstanceReconciling(app, bindings, key),
|
|
402
515
|
readPage: (p) => ctx.host.readTextFile(p),
|
|
516
|
+
readAsset: (p) => ctx.host.readTextFile(p),
|
|
403
517
|
listPages: async () => {
|
|
404
518
|
const dir = opts.pagesDir ?? "pages";
|
|
405
519
|
const names = await ctx.host.listDir(dir).catch(() => []);
|
|
@@ -632,6 +746,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
632
746
|
.pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
633
747
|
.pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
|
|
634
748
|
.pc-prose-body a { color:var(--nano-accent-strong); }
|
|
749
|
+
/* Embedded App View (renderAppView, #416 / ADR 0057). An <iframe> hosting a
|
|
750
|
+
self-contained view document (e.g. the agent cockpit's embed.html) served
|
|
751
|
+
alongside the page. A plain node sizes to a comfortable default; a "fill" node
|
|
752
|
+
grows to a tall flex column so the embed (a live dashboard/terminal) gets the
|
|
753
|
+
viewport height it needs instead of a squat fixed box. */
|
|
754
|
+
.pc-appview { margin:1rem 0; }
|
|
755
|
+
.pc-appview-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
|
|
756
|
+
.pc-appview-frame { display:block; width:100%; min-height:24rem; border:1px solid var(--nano-edge); border-radius:.6rem; background:var(--nano-inset); }
|
|
757
|
+
.pc-appview-fill { display:flex; flex-direction:column; min-height:min(80vh,48rem); }
|
|
758
|
+
.pc-appview-fill .pc-appview-frame { flex:1 1 auto; min-height:0; }
|
|
635
759
|
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
636
760
|
.pc-buttonrow { margin:1rem 0; }
|
|
637
761
|
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|