@nanobpm/urban 0.32.1 → 0.33.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/api.d.ts +1 -2
- package/dist/runtime/core/modules/api.js +41 -22
- package/dist/runtime/core/modules/pages.js +90 -54
- package/dist/runtime/core/modules/surfaces.js +1 -1
- package/package.json +2 -2
- package/src/runtime/core/modules/api.test.ts +13 -9
- package/src/runtime/core/modules/api.ts +43 -24
- package/src/runtime/core/modules/pages.test.ts +39 -15
- package/src/runtime/core/modules/pages.ts +91 -54
- package/src/runtime/core/modules/surfaces.ts +1 -1
|
@@ -6,10 +6,9 @@ import { type Route } from "../router.ts";
|
|
|
6
6
|
export interface ApiBinding {
|
|
7
7
|
spec: string;
|
|
8
8
|
dir?: string;
|
|
9
|
-
base?: string;
|
|
10
9
|
validateResponses?: "dev" | "always" | "never";
|
|
11
10
|
eject?: boolean;
|
|
12
|
-
/** Human API docs (Swagger UI). `true`/omitted → on at
|
|
11
|
+
/** Human API docs (Swagger UI). `true`/omitted → on at `/app/api-docs` (ADR 0058); `false` →
|
|
13
12
|
* off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
|
|
14
13
|
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
15
14
|
docs?: boolean | string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
|
|
3
3
|
// writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
|
|
4
4
|
// toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
|
|
5
|
-
//
|
|
5
|
+
// fixed `/app/api` namespace, and for each request: parses → validates params/query/body against the spec →
|
|
6
6
|
// 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
|
|
7
7
|
// the injected AppApi, exactly like a worker/action handler) → serializes the result.
|
|
8
8
|
//
|
|
@@ -15,6 +15,10 @@ import { errorMessage } from "../guards.js";
|
|
|
15
15
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
16
16
|
import { resolveAppPath } from "./datasource.js";
|
|
17
17
|
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
|
|
18
|
+
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
19
|
+
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
20
|
+
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
21
|
+
const API_BASE = "/app/api";
|
|
18
22
|
/** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
|
|
19
23
|
* Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
|
|
20
24
|
* ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
|
|
@@ -76,7 +80,6 @@ function readApiBinding(manifest) {
|
|
|
76
80
|
if (typeof spec !== "string" || spec.trim().length === 0)
|
|
77
81
|
return undefined;
|
|
78
82
|
const dir = Reflect.get(raw, "dir");
|
|
79
|
-
const base = Reflect.get(raw, "base");
|
|
80
83
|
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
81
84
|
const eject = Reflect.get(raw, "eject");
|
|
82
85
|
const docs = Reflect.get(raw, "docs");
|
|
@@ -84,7 +87,6 @@ function readApiBinding(manifest) {
|
|
|
84
87
|
return {
|
|
85
88
|
spec: spec.trim(),
|
|
86
89
|
dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
|
|
87
|
-
base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
|
|
88
90
|
validateResponses: validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
|
|
89
91
|
? validateResponses
|
|
90
92
|
: undefined,
|
|
@@ -94,14 +96,15 @@ function readApiBinding(manifest) {
|
|
|
94
96
|
docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
|
-
/** Resolve the api `base` + docs routing
|
|
98
|
-
*
|
|
99
|
+
/** Resolve the api `base` + docs routing, in ONE place, so `mountApi` (which serves the docs) and
|
|
100
|
+
* `apiDocsPath` (which the pages surface links to) can never drift. The operation base is the fixed
|
|
101
|
+
* `/app/api` constant; only the docs route remains manifest-configurable. */
|
|
99
102
|
function resolveApiRoutes(binding) {
|
|
100
|
-
const base =
|
|
103
|
+
const base = API_BASE;
|
|
101
104
|
const docsEnabled = binding.docs !== false;
|
|
102
|
-
// Swagger UI at a SIBLING of the base (
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
+
// Swagger UI at a SIBLING of the base (`/app/api-docs`), never a subpath of it — so the docs
|
|
106
|
+
// routes can't collide with, or be shadowed by, the operation dispatcher that owns the
|
|
107
|
+
// `${base}/` prefix (a string `docs` overrides the whole route).
|
|
105
108
|
const docsBase = normalizeRoutePath(typeof binding.docs === "string" ? binding.docs : `${base}-docs`, `${base}-docs`);
|
|
106
109
|
return { base, docsEnabled, docsBase };
|
|
107
110
|
}
|
|
@@ -143,19 +146,34 @@ export function mountApi(ctx, app) {
|
|
|
143
146
|
const binding = readApiBinding(ctx.manifest);
|
|
144
147
|
if (!binding)
|
|
145
148
|
return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
|
|
152
|
-
// shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
|
|
153
|
-
// copy of that logic here. The spec JSON is served under the docs path so the operations
|
|
154
|
-
// namespace stays purely operations.
|
|
149
|
+
// The api `base` is the fixed `/app/api` constant; only the docs routing is derived here. Both
|
|
150
|
+
// come from the shared helper (ADR 0058) so this module (which serves the docs) and `apiDocsPath`
|
|
151
|
+
// (which links to them from the pages shell) stay in lockstep — no second, drifting copy of that
|
|
152
|
+
// logic here. The spec JSON is served under the docs path so the operations namespace stays
|
|
153
|
+
// purely operations.
|
|
155
154
|
const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
156
155
|
const dir = binding.dir ?? "operations";
|
|
157
156
|
const surfaceEject = binding.eject === true;
|
|
158
157
|
const specRoutePath = `${docsBase}/openapi.json`;
|
|
158
|
+
// The Swagger UI page is served at `docsBase` (no trailing slash — the slash variant 308-
|
|
159
|
+
// redirects onto it), so its browser-relative base is the PARENT directory. Hand Swagger UI a
|
|
160
|
+
// DOCUMENT-relative spec URL and `servers` entry so both rebase onto the app's mount root:
|
|
161
|
+
// correct at the origin root (CLI :3000) AND under the Nano console reverse proxy
|
|
162
|
+
// (/console/app-view/<name>/), where a root-absolute "/app/…" escapes the prefix and hits the
|
|
163
|
+
// console origin instead of the app (issue #151).
|
|
164
|
+
const lastSegment = (p) => p.split("/").filter(Boolean).pop() ?? "";
|
|
165
|
+
const parentPath = (p) => {
|
|
166
|
+
const parts = p.split("/").filter(Boolean);
|
|
167
|
+
parts.pop();
|
|
168
|
+
return `/${parts.join("/")}`;
|
|
169
|
+
};
|
|
170
|
+
// `${docsBase}/openapi.json` reached relatively from the docs page = `<docs-segment>/openapi.json`.
|
|
171
|
+
const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
|
|
172
|
+
// `servers` can only be relativized when the operations `base` shares the docs page's parent
|
|
173
|
+
// directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
|
|
174
|
+
// different parent, keep the root-absolute base (works direct; try-it-out through the proxy is
|
|
175
|
+
// the documented limitation in #151).
|
|
176
|
+
const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
|
|
159
177
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
160
178
|
// pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
|
|
161
179
|
// than failing app boot.
|
|
@@ -427,12 +445,13 @@ export function mountApi(ctx, app) {
|
|
|
427
445
|
return json({ error: errorMessage(e) }, 500);
|
|
428
446
|
}
|
|
429
447
|
// Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
|
|
430
|
-
// (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
|
|
431
|
-
// makes the interactive console call the real Urban routes
|
|
432
|
-
|
|
448
|
+
// (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers` with a
|
|
449
|
+
// document-relative URL (issue #151) makes the interactive console call the real Urban routes
|
|
450
|
+
// both at the origin root and under the Nano console reverse proxy.
|
|
451
|
+
return json({ ...d, servers: [{ url: serversUrl }] });
|
|
433
452
|
};
|
|
434
453
|
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
435
|
-
const serveDocs = () => html(swaggerUiPage(docsTitle,
|
|
454
|
+
const serveDocs = () => html(swaggerUiPage(docsTitle, specDocUrl));
|
|
436
455
|
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
437
456
|
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
438
457
|
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
15
15
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
16
16
|
// POST /app/actions/message → engine.publishMessage
|
|
17
|
+
//
|
|
18
|
+
// Page actions are ROUTE-DRIVEN: an `actionForm`/rowAction/detail-form `action` is just
|
|
19
|
+
// `{ path, method?, body?, successLabel? }`. The client POSTs the resolved body (default
|
|
20
|
+
// `{ variables: <form> }`) to `path`, templating `{{form.KEY}}` / `{{row.KEY}}` (and the
|
|
21
|
+
// whole-object `{{form}}` / `{{row}}`) tokens. There are NO bespoke action kinds — a form
|
|
22
|
+
// reaches start/cancel/message above, an OpenAPI operation under /app/api, or any app route
|
|
23
|
+
// uniformly by naming its path.
|
|
17
24
|
import { errorMessage, isRecord } from "../guards.js";
|
|
18
25
|
import { html, json } from "../router.js";
|
|
19
26
|
import { cancelInstanceReconciling } from "./cancel.js";
|
|
@@ -369,8 +376,19 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
369
376
|
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
370
377
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
371
378
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
379
|
+
//
|
|
380
|
+
// The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
|
|
381
|
+
// the shell is served at the app's mount root (always a trailing "/"), which is the origin
|
|
382
|
+
// root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
|
|
383
|
+
// proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
|
|
384
|
+
// and open the CONSOLE origin (e.g. :8080/app/api-docs) instead of the app's docs. Strip the
|
|
385
|
+
// single leading slash so it rebases onto the mount root; leave a protocol-relative "//host"
|
|
386
|
+
// (which apiDocsPath never emits) untouched.
|
|
387
|
+
const badgeHref = apiDocsPath && apiDocsPath.startsWith("/") && !apiDocsPath.startsWith("//")
|
|
388
|
+
? `./${apiDocsPath.slice(1)}`
|
|
389
|
+
: apiDocsPath;
|
|
372
390
|
const apiDocsBadge = apiDocsPath
|
|
373
|
-
? `\n <a class="pc-apidocs" href="${escapeAttr(
|
|
391
|
+
? `\n <a class="pc-apidocs" href="${escapeAttr(badgeHref)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
|
|
374
392
|
: "";
|
|
375
393
|
return `<!doctype html>
|
|
376
394
|
<html lang="en">
|
|
@@ -575,6 +593,59 @@ async function getJSON(url, opts) {
|
|
|
575
593
|
return body;
|
|
576
594
|
}
|
|
577
595
|
|
|
596
|
+
// ── route-driven actions ────────────────────────────────────────────────
|
|
597
|
+
// Every page action is a single primitive: POST (or any method) the resolved
|
|
598
|
+
// body to an app route path. There are NO bespoke action kinds — a form/row
|
|
599
|
+
// reaches process start, message publish, cancel, an OpenAPI operation, a
|
|
600
|
+
// webhook, or any app route uniformly by naming its path. Path + body may
|
|
601
|
+
// interpolate the current form fields and (for grid rows/detail forms) the row
|
|
602
|
+
// via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
|
|
603
|
+
// {{row}} splice the object itself (type-preserving), so a body of
|
|
604
|
+
// "variables": "{{form}}" sends the form object as the variable map.
|
|
605
|
+
function lookupToken(path, ctx) {
|
|
606
|
+
const segs = String(path).split(".");
|
|
607
|
+
const head = segs[0];
|
|
608
|
+
let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
|
|
609
|
+
for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
|
|
610
|
+
return base;
|
|
611
|
+
}
|
|
612
|
+
function resolveTemplate(node, ctx) {
|
|
613
|
+
if (typeof node === "string") {
|
|
614
|
+
const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
|
|
615
|
+
if (whole) return lookupToken(whole[1], ctx);
|
|
616
|
+
return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
617
|
+
const v = lookupToken(k, ctx);
|
|
618
|
+
return v == null ? "" : String(v);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
|
|
622
|
+
if (node && typeof node === "object") {
|
|
623
|
+
// Null-proto target so a template key like "__proto__" can't trigger prototype
|
|
624
|
+
// mutation (prototype-pollution class) when copied into the resolved object.
|
|
625
|
+
const out = Object.create(null);
|
|
626
|
+
for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
return node;
|
|
630
|
+
}
|
|
631
|
+
async function runRoute(action, ctx) {
|
|
632
|
+
if (!action || typeof action.path !== "string" || action.path === "") {
|
|
633
|
+
throw new Error("This action has no route configured (action.path is missing or blank)");
|
|
634
|
+
}
|
|
635
|
+
const path = action.path.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
636
|
+
const v = lookupToken(k, ctx);
|
|
637
|
+
return v == null ? "" : encodeURIComponent(String(v));
|
|
638
|
+
});
|
|
639
|
+
const method = String(action.method || "POST").toUpperCase();
|
|
640
|
+
let body;
|
|
641
|
+
if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
|
|
642
|
+
else if (ctx.form) body = { variables: ctx.form };
|
|
643
|
+
else body = {};
|
|
644
|
+
const opts = { method, headers: { "content-type": "application/json" } };
|
|
645
|
+
if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
|
|
646
|
+
return getJSON(path, opts);
|
|
647
|
+
}
|
|
648
|
+
|
|
578
649
|
function el(tag, attrs = {}, ...kids) {
|
|
579
650
|
const n = document.createElement(tag);
|
|
580
651
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -675,37 +746,14 @@ function renderActionForm(node) {
|
|
|
675
746
|
}
|
|
676
747
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
677
748
|
try {
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
// Send the trimmed key back as the variable too, so the value a worker
|
|
690
|
-
// reads matches the key the message was correlated on (no leading/trailing
|
|
691
|
-
// whitespace divergence between correlationKey and variables[field]).
|
|
692
|
-
variables[p.action.correlationKeyField] = correlationKey;
|
|
693
|
-
await getJSON("/app/actions/message", { method: "POST",
|
|
694
|
-
headers: { "content-type": "application/json" },
|
|
695
|
-
body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
|
|
696
|
-
msg.className = "pc-msg ok";
|
|
697
|
-
msg.textContent = "Message published";
|
|
698
|
-
} else {
|
|
699
|
-
if (!p.action || !p.action.process) {
|
|
700
|
-
msg.className = "pc-msg err";
|
|
701
|
-
msg.textContent = "This form has no action configured";
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
705
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
706
|
-
msg.className = "pc-msg ok";
|
|
707
|
-
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
708
|
-
}
|
|
749
|
+
// Route-driven: POST the form (as { variables } by default, or the action's
|
|
750
|
+
// own body template) to the action's route path. correlationKey / process /
|
|
751
|
+
// message are expressed by the path + body template the page author supplies,
|
|
752
|
+
// e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
|
|
753
|
+
const res = await runRoute(p.action, { form: variables });
|
|
754
|
+
msg.className = "pc-msg ok";
|
|
755
|
+
msg.textContent = (p.action && p.action.successLabel) ||
|
|
756
|
+
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
709
757
|
for (const input of inputs.values()) input.value = "";
|
|
710
758
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
711
759
|
} catch (e) {
|
|
@@ -780,23 +828,9 @@ function renderDataGrid(node) {
|
|
|
780
828
|
}
|
|
781
829
|
|
|
782
830
|
async function fireAction(action, row) {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
|
|
787
|
-
}
|
|
788
|
-
if (action.kind === "publishMessage") {
|
|
789
|
-
return getJSON("/app/actions/message", { method: "POST",
|
|
790
|
-
headers: { "content-type": "application/json" },
|
|
791
|
-
body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
|
|
792
|
-
variables: { ...(action.variables || {}) } }) });
|
|
793
|
-
}
|
|
794
|
-
if (action.kind === "startProcess") {
|
|
795
|
-
return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
|
|
796
|
-
headers: { "content-type": "application/json" },
|
|
797
|
-
body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
|
|
798
|
-
}
|
|
799
|
-
throw new Error("unknown action");
|
|
831
|
+
// Route-driven row action: POST the action's body template (default {})
|
|
832
|
+
// to its route path, with {{row.KEY}} tokens resolved from this row.
|
|
833
|
+
return runRoute(action, { row });
|
|
800
834
|
}
|
|
801
835
|
|
|
802
836
|
function rowActionButton(row, ra) {
|
|
@@ -831,11 +865,13 @@ function renderDataGrid(node) {
|
|
|
831
865
|
btn.addEventListener("click", async () => {
|
|
832
866
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
833
867
|
try {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
868
|
+
// The textarea value is the form's single field (f.inputKey); route + body
|
|
869
|
+
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
870
|
+
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
871
|
+
// Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
|
|
872
|
+
const form = Object.create(null); form[f.inputKey] = input.value;
|
|
873
|
+
await runRoute(f.action, { form, row });
|
|
874
|
+
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
839
875
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
840
876
|
} catch (e) {
|
|
841
877
|
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -95,7 +95,7 @@ export function mountSurfaces(ctx, app) {
|
|
|
95
95
|
enabled.push(`actions(${actions.routes.length})`);
|
|
96
96
|
}
|
|
97
97
|
// The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
|
|
98
|
-
// still shadows an operation) and BEFORE the generic pages routes. It owns the
|
|
98
|
+
// still shadows an operation) and BEFORE the generic pages routes. It owns the fixed `/app/api`
|
|
99
99
|
// namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
|
|
100
100
|
const api = mountApi(ctx, app);
|
|
101
101
|
if (api.routes.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
69
|
-
"create-urban-app": "^0.
|
|
69
|
+
"create-urban-app": "^0.9.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@types/node": "^22.20.1",
|
|
@@ -228,12 +228,13 @@ test("a malformed spec surfaces as a 500, not a boot failure", async () => {
|
|
|
228
228
|
assert.match(JSON.parse(res.body!).error, /failed to load/);
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
test("a manifest api.base
|
|
231
|
+
test("a leftover manifest api.base is ignored — operations always mount at the fixed /app/api", async () => {
|
|
232
232
|
let got: Record<string, string> | undefined;
|
|
233
233
|
const { router } = build(
|
|
234
|
-
{ spec: "openapi.json", base: "
|
|
234
|
+
{ spec: "openapi.json", base: "/somewhere/else" }, // base is no longer honoured
|
|
235
235
|
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
236
236
|
);
|
|
237
|
+
assert.equal((await router(req("GET", "/somewhere/else/invoices/42"))).status, 404);
|
|
237
238
|
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
238
239
|
assert.equal(res.status, 200);
|
|
239
240
|
assert.deepEqual(got, { id: "42" });
|
|
@@ -393,10 +394,10 @@ test("an op declaring no requestBody gets body:undefined at runtime (matches der
|
|
|
393
394
|
assert.equal(seenBody, undefined);
|
|
394
395
|
});
|
|
395
396
|
|
|
396
|
-
test("readApiBinding trims whitespace on
|
|
397
|
+
test("readApiBinding trims whitespace on dir/spec so benign formatting can't break resolution", async () => {
|
|
397
398
|
let got: Record<string, string> | undefined;
|
|
398
399
|
const { router, imported } = build(
|
|
399
|
-
{ spec: " openapi.json ",
|
|
400
|
+
{ spec: " openapi.json ", dir: " operations " },
|
|
400
401
|
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
401
402
|
);
|
|
402
403
|
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
@@ -458,9 +459,10 @@ test("serves the OpenAPI spec at {base}-docs/openapi.json, rebased to the mounte
|
|
|
458
459
|
const doc = JSON.parse(res.body ?? "{}");
|
|
459
460
|
// The doc is served intact...
|
|
460
461
|
assert.ok(doc.paths["/invoices"], "keeps the operation paths");
|
|
461
|
-
// ...but `servers` is rebased to `base` so Swagger UI "Try it
|
|
462
|
+
// ...but `servers` is rebased to `base` (document-relative, issue #151) so Swagger UI "Try it
|
|
463
|
+
// out" hits the real routes both at the origin root and under the Nano console reverse proxy
|
|
462
464
|
// (operations mount under /app/api, not the spec's bare /invoices).
|
|
463
|
-
assert.deepEqual(doc.servers, [{ url: "
|
|
465
|
+
assert.deepEqual(doc.servers, [{ url: "api" }]);
|
|
464
466
|
});
|
|
465
467
|
|
|
466
468
|
test("serves the Swagger UI page at {base}-docs by default", async () => {
|
|
@@ -470,8 +472,10 @@ test("serves the Swagger UI page at {base}-docs by default", async () => {
|
|
|
470
472
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
471
473
|
const body = res.body ?? "";
|
|
472
474
|
assert.match(body, /swagger-ui/);
|
|
473
|
-
// The UI reads the spec
|
|
474
|
-
|
|
475
|
+
// The UI reads the spec via a DOCUMENT-relative URL (issue #151) so it resolves through the
|
|
476
|
+
// Nano console reverse proxy, not a root-absolute path that escapes to the console origin.
|
|
477
|
+
assert.match(body, /url: "api-docs\/openapi\.json"/);
|
|
478
|
+
assert.doesNotMatch(body, /url: "\/app\/api-docs\/openapi\.json"/);
|
|
475
479
|
});
|
|
476
480
|
|
|
477
481
|
test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
|
|
@@ -512,7 +516,7 @@ test("a string docs overrides the UI route (and the spec route beneath it)", asy
|
|
|
512
516
|
|
|
513
517
|
test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
|
|
514
518
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
|
|
515
|
-
assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/
|
|
519
|
+
assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/app/api-docs"); // base ignored
|
|
516
520
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
|
|
517
521
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
|
|
518
522
|
assert.equal(apiDocsPath({}), undefined);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
|
|
3
3
|
// writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
|
|
4
4
|
// toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
|
|
5
|
-
//
|
|
5
|
+
// fixed `/app/api` namespace, and for each request: parses → validates params/query/body against the spec →
|
|
6
6
|
// 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
|
|
7
7
|
// the injected AppApi, exactly like a worker/action handler) → serializes the result.
|
|
8
8
|
//
|
|
@@ -38,15 +38,19 @@ import {
|
|
|
38
38
|
export interface ApiBinding {
|
|
39
39
|
spec: string;
|
|
40
40
|
dir?: string;
|
|
41
|
-
base?: string;
|
|
42
41
|
validateResponses?: "dev" | "always" | "never";
|
|
43
42
|
eject?: boolean;
|
|
44
|
-
/** Human API docs (Swagger UI). `true`/omitted → on at
|
|
43
|
+
/** Human API docs (Swagger UI). `true`/omitted → on at `/app/api-docs` (ADR 0058); `false` →
|
|
45
44
|
* off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
|
|
46
45
|
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
47
46
|
docs?: boolean | string;
|
|
48
47
|
}
|
|
49
48
|
|
|
49
|
+
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
50
|
+
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
51
|
+
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
52
|
+
const API_BASE = "/app/api";
|
|
53
|
+
|
|
50
54
|
/** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
|
|
51
55
|
* Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
|
|
52
56
|
* ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
|
|
@@ -152,7 +156,6 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
152
156
|
const spec = Reflect.get(raw, "spec");
|
|
153
157
|
if (typeof spec !== "string" || spec.trim().length === 0) return undefined;
|
|
154
158
|
const dir = Reflect.get(raw, "dir");
|
|
155
|
-
const base = Reflect.get(raw, "base");
|
|
156
159
|
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
157
160
|
const eject = Reflect.get(raw, "eject");
|
|
158
161
|
const docs = Reflect.get(raw, "docs");
|
|
@@ -160,7 +163,6 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
160
163
|
return {
|
|
161
164
|
spec: spec.trim(),
|
|
162
165
|
dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
|
|
163
|
-
base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
|
|
164
166
|
validateResponses:
|
|
165
167
|
validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
|
|
166
168
|
? validateResponses
|
|
@@ -172,18 +174,19 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
172
174
|
};
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
/** Resolve the api `base` + docs routing
|
|
176
|
-
*
|
|
177
|
+
/** Resolve the api `base` + docs routing, in ONE place, so `mountApi` (which serves the docs) and
|
|
178
|
+
* `apiDocsPath` (which the pages surface links to) can never drift. The operation base is the fixed
|
|
179
|
+
* `/app/api` constant; only the docs route remains manifest-configurable. */
|
|
177
180
|
function resolveApiRoutes(binding: ApiBinding): {
|
|
178
181
|
base: string;
|
|
179
182
|
docsEnabled: boolean;
|
|
180
183
|
docsBase: string;
|
|
181
184
|
} {
|
|
182
|
-
const base =
|
|
185
|
+
const base = API_BASE;
|
|
183
186
|
const docsEnabled = binding.docs !== false;
|
|
184
|
-
// Swagger UI at a SIBLING of the base (
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
+
// Swagger UI at a SIBLING of the base (`/app/api-docs`), never a subpath of it — so the docs
|
|
188
|
+
// routes can't collide with, or be shadowed by, the operation dispatcher that owns the
|
|
189
|
+
// `${base}/` prefix (a string `docs` overrides the whole route).
|
|
187
190
|
const docsBase = normalizeRoutePath(
|
|
188
191
|
typeof binding.docs === "string" ? binding.docs : `${base}-docs`,
|
|
189
192
|
`${base}-docs`,
|
|
@@ -239,19 +242,34 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
239
242
|
const binding = readApiBinding(ctx.manifest);
|
|
240
243
|
if (!binding) return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
|
|
241
244
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
// this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
|
|
248
|
-
// shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
|
|
249
|
-
// copy of that logic here. The spec JSON is served under the docs path so the operations
|
|
250
|
-
// namespace stays purely operations.
|
|
245
|
+
// The api `base` is the fixed `/app/api` constant; only the docs routing is derived here. Both
|
|
246
|
+
// come from the shared helper (ADR 0058) so this module (which serves the docs) and `apiDocsPath`
|
|
247
|
+
// (which links to them from the pages shell) stay in lockstep — no second, drifting copy of that
|
|
248
|
+
// logic here. The spec JSON is served under the docs path so the operations namespace stays
|
|
249
|
+
// purely operations.
|
|
251
250
|
const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
252
251
|
const dir = binding.dir ?? "operations";
|
|
253
252
|
const surfaceEject = binding.eject === true;
|
|
254
253
|
const specRoutePath = `${docsBase}/openapi.json`;
|
|
254
|
+
// The Swagger UI page is served at `docsBase` (no trailing slash — the slash variant 308-
|
|
255
|
+
// redirects onto it), so its browser-relative base is the PARENT directory. Hand Swagger UI a
|
|
256
|
+
// DOCUMENT-relative spec URL and `servers` entry so both rebase onto the app's mount root:
|
|
257
|
+
// correct at the origin root (CLI :3000) AND under the Nano console reverse proxy
|
|
258
|
+
// (/console/app-view/<name>/), where a root-absolute "/app/…" escapes the prefix and hits the
|
|
259
|
+
// console origin instead of the app (issue #151).
|
|
260
|
+
const lastSegment = (p: string): string => p.split("/").filter(Boolean).pop() ?? "";
|
|
261
|
+
const parentPath = (p: string): string => {
|
|
262
|
+
const parts = p.split("/").filter(Boolean);
|
|
263
|
+
parts.pop();
|
|
264
|
+
return `/${parts.join("/")}`;
|
|
265
|
+
};
|
|
266
|
+
// `${docsBase}/openapi.json` reached relatively from the docs page = `<docs-segment>/openapi.json`.
|
|
267
|
+
const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
|
|
268
|
+
// `servers` can only be relativized when the operations `base` shares the docs page's parent
|
|
269
|
+
// directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
|
|
270
|
+
// different parent, keep the root-absolute base (works direct; try-it-out through the proxy is
|
|
271
|
+
// the documented limitation in #151).
|
|
272
|
+
const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
|
|
255
273
|
|
|
256
274
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
257
275
|
// pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
|
|
@@ -538,12 +556,13 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
538
556
|
return json({ error: errorMessage(e) }, 500);
|
|
539
557
|
}
|
|
540
558
|
// Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
|
|
541
|
-
// (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
|
|
542
|
-
// makes the interactive console call the real Urban routes
|
|
543
|
-
|
|
559
|
+
// (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers` with a
|
|
560
|
+
// document-relative URL (issue #151) makes the interactive console call the real Urban routes
|
|
561
|
+
// both at the origin root and under the Nano console reverse proxy.
|
|
562
|
+
return json({ ...d, servers: [{ url: serversUrl }] });
|
|
544
563
|
};
|
|
545
564
|
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
546
|
-
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle,
|
|
565
|
+
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl));
|
|
547
566
|
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
548
567
|
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
549
568
|
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
@@ -98,6 +98,29 @@ test("GET / serves the renderer shell with the home marker", async () => {
|
|
|
98
98
|
assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\.js"/);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test("the API docs badge links via a document-relative href (proxy-safe)", async () => {
|
|
102
|
+
// With an `api` binding, the shell renders a persistent "API docs" badge. Its
|
|
103
|
+
// href must be DOCUMENT-relative (./app/api-docs), mirroring the runtime.js
|
|
104
|
+
// script tag, so it resolves against the shell's mount root. A root-absolute
|
|
105
|
+
// "/app/api-docs" would escape the Nano console's /console/app-view/<name>/
|
|
106
|
+
// reverse proxy and open the console origin (e.g. :8080/app/api-docs) instead
|
|
107
|
+
// of the app's own docs — the port-mismatch bug this guards against.
|
|
108
|
+
const { engine } = fakeEngine();
|
|
109
|
+
const routes = createPagesRoutes(
|
|
110
|
+
{ pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
|
|
111
|
+
{
|
|
112
|
+
db: fakeDb(),
|
|
113
|
+
engine,
|
|
114
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
115
|
+
listPages: async () => ["home"],
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
const res = await makeRouter(routes)(req("GET", "/"));
|
|
119
|
+
assert.match(res.body ?? "", /class="pc-apidocs"/);
|
|
120
|
+
assert.match(res.body ?? "", /href="\.\/app\/api-docs"/);
|
|
121
|
+
assert.doesNotMatch(res.body ?? "", /href="\/app\/api-docs"/);
|
|
122
|
+
});
|
|
123
|
+
|
|
101
124
|
test("the shell styles the app through the shared --nano-* token contract", async () => {
|
|
102
125
|
const res = await dispatch("GET", "/");
|
|
103
126
|
const html = res.body ?? "";
|
|
@@ -129,7 +152,7 @@ test("the shell renders an API-docs badge only when an apiDocsPath is provided",
|
|
|
129
152
|
);
|
|
130
153
|
const withBadge = (await makeRouter(routes)(req("GET", "/"))).body ?? "";
|
|
131
154
|
assert.match(withBadge, /class="pc-apidocs"/);
|
|
132
|
-
assert.match(withBadge, /href="
|
|
155
|
+
assert.match(withBadge, /href="\.\/app\/api-docs"/);
|
|
133
156
|
// Opens in a new tab with a hardened rel (no reverse-tabnabbing handle).
|
|
134
157
|
assert.match(withBadge, /rel="noopener noreferrer"/);
|
|
135
158
|
|
|
@@ -138,22 +161,23 @@ test("the shell renders an API-docs badge only when an apiDocsPath is provided",
|
|
|
138
161
|
assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
|
|
139
162
|
});
|
|
140
163
|
|
|
141
|
-
test("
|
|
164
|
+
test("page actions are route-driven: the runtime ships a single callRoute dispatcher, no bespoke kinds", async () => {
|
|
142
165
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
143
166
|
const js = res.body ?? "";
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
assert.match(js, /
|
|
147
|
-
assert.match(js, /
|
|
148
|
-
assert.match(js, /
|
|
149
|
-
// The
|
|
150
|
-
//
|
|
151
|
-
assert.match(js, /variables
|
|
152
|
-
//
|
|
153
|
-
assert.match(js, /
|
|
154
|
-
//
|
|
155
|
-
assert.
|
|
156
|
-
assert.
|
|
167
|
+
// A single primitive resolves { path, method?, body? } and POSTs it — there is no per-kind
|
|
168
|
+
// branching. The template resolver splices {{form}} / {{row}} and {{form.KEY}} / {{row.KEY}}.
|
|
169
|
+
assert.match(js, /async function runRoute\(action, ctx\)/);
|
|
170
|
+
assert.match(js, /function resolveTemplate\(node, ctx\)/);
|
|
171
|
+
assert.match(js, /function lookupToken\(path, ctx\)/);
|
|
172
|
+
// The form default body is the { variables } envelope, so plain forms hit start operations
|
|
173
|
+
// without any per-form config; row/detail forms carry an explicit body template instead.
|
|
174
|
+
assert.match(js, /body = \{ variables: ctx\.form \}/);
|
|
175
|
+
// Path tokens are URL-encoded (real keys like owner/repo#123 must not break the path).
|
|
176
|
+
assert.match(js, /encodeURIComponent\(String\(v\)\)/);
|
|
177
|
+
// The removed bespoke action kinds no longer appear anywhere in the client runtime.
|
|
178
|
+
assert.doesNotMatch(js, /p\.action\.kind/);
|
|
179
|
+
assert.doesNotMatch(js, /correlationKeyField/);
|
|
180
|
+
assert.doesNotMatch(js, /action\.kind === "(startProcess|publishMessage|cancelProcess)"/);
|
|
157
181
|
});
|
|
158
182
|
|
|
159
183
|
test("the renderer bridges the console theme over postMessage", async () => {
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
15
15
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
16
16
|
// POST /app/actions/message → engine.publishMessage
|
|
17
|
+
//
|
|
18
|
+
// Page actions are ROUTE-DRIVEN: an `actionForm`/rowAction/detail-form `action` is just
|
|
19
|
+
// `{ path, method?, body?, successLabel? }`. The client POSTs the resolved body (default
|
|
20
|
+
// `{ variables: <form> }`) to `path`, templating `{{form.KEY}}` / `{{row.KEY}}` (and the
|
|
21
|
+
// whole-object `{{form}}` / `{{row}}`) tokens. There are NO bespoke action kinds — a form
|
|
22
|
+
// reaches start/cancel/message above, an OpenAPI operation under /app/api, or any app route
|
|
23
|
+
// uniformly by naming its path.
|
|
17
24
|
|
|
18
25
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
19
26
|
import { errorMessage, isRecord } from "../guards.ts";
|
|
@@ -414,8 +421,20 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
414
421
|
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
415
422
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
416
423
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
424
|
+
//
|
|
425
|
+
// The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
|
|
426
|
+
// the shell is served at the app's mount root (always a trailing "/"), which is the origin
|
|
427
|
+
// root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
|
|
428
|
+
// proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
|
|
429
|
+
// and open the CONSOLE origin (e.g. :8080/app/api-docs) instead of the app's docs. Strip the
|
|
430
|
+
// single leading slash so it rebases onto the mount root; leave a protocol-relative "//host"
|
|
431
|
+
// (which apiDocsPath never emits) untouched.
|
|
432
|
+
const badgeHref =
|
|
433
|
+
apiDocsPath && apiDocsPath.startsWith("/") && !apiDocsPath.startsWith("//")
|
|
434
|
+
? `./${apiDocsPath.slice(1)}`
|
|
435
|
+
: apiDocsPath;
|
|
417
436
|
const apiDocsBadge = apiDocsPath
|
|
418
|
-
? `\n <a class="pc-apidocs" href="${escapeAttr(
|
|
437
|
+
? `\n <a class="pc-apidocs" href="${escapeAttr(badgeHref!)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
|
|
419
438
|
: "";
|
|
420
439
|
return `<!doctype html>
|
|
421
440
|
<html lang="en">
|
|
@@ -622,6 +641,59 @@ async function getJSON(url, opts) {
|
|
|
622
641
|
return body;
|
|
623
642
|
}
|
|
624
643
|
|
|
644
|
+
// ── route-driven actions ────────────────────────────────────────────────
|
|
645
|
+
// Every page action is a single primitive: POST (or any method) the resolved
|
|
646
|
+
// body to an app route path. There are NO bespoke action kinds — a form/row
|
|
647
|
+
// reaches process start, message publish, cancel, an OpenAPI operation, a
|
|
648
|
+
// webhook, or any app route uniformly by naming its path. Path + body may
|
|
649
|
+
// interpolate the current form fields and (for grid rows/detail forms) the row
|
|
650
|
+
// via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
|
|
651
|
+
// {{row}} splice the object itself (type-preserving), so a body of
|
|
652
|
+
// "variables": "{{form}}" sends the form object as the variable map.
|
|
653
|
+
function lookupToken(path, ctx) {
|
|
654
|
+
const segs = String(path).split(".");
|
|
655
|
+
const head = segs[0];
|
|
656
|
+
let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
|
|
657
|
+
for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
|
|
658
|
+
return base;
|
|
659
|
+
}
|
|
660
|
+
function resolveTemplate(node, ctx) {
|
|
661
|
+
if (typeof node === "string") {
|
|
662
|
+
const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
|
|
663
|
+
if (whole) return lookupToken(whole[1], ctx);
|
|
664
|
+
return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
665
|
+
const v = lookupToken(k, ctx);
|
|
666
|
+
return v == null ? "" : String(v);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
|
|
670
|
+
if (node && typeof node === "object") {
|
|
671
|
+
// Null-proto target so a template key like "__proto__" can't trigger prototype
|
|
672
|
+
// mutation (prototype-pollution class) when copied into the resolved object.
|
|
673
|
+
const out = Object.create(null);
|
|
674
|
+
for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
return node;
|
|
678
|
+
}
|
|
679
|
+
async function runRoute(action, ctx) {
|
|
680
|
+
if (!action || typeof action.path !== "string" || action.path === "") {
|
|
681
|
+
throw new Error("This action has no route configured (action.path is missing or blank)");
|
|
682
|
+
}
|
|
683
|
+
const path = action.path.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
684
|
+
const v = lookupToken(k, ctx);
|
|
685
|
+
return v == null ? "" : encodeURIComponent(String(v));
|
|
686
|
+
});
|
|
687
|
+
const method = String(action.method || "POST").toUpperCase();
|
|
688
|
+
let body;
|
|
689
|
+
if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
|
|
690
|
+
else if (ctx.form) body = { variables: ctx.form };
|
|
691
|
+
else body = {};
|
|
692
|
+
const opts = { method, headers: { "content-type": "application/json" } };
|
|
693
|
+
if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
|
|
694
|
+
return getJSON(path, opts);
|
|
695
|
+
}
|
|
696
|
+
|
|
625
697
|
function el(tag, attrs = {}, ...kids) {
|
|
626
698
|
const n = document.createElement(tag);
|
|
627
699
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -722,37 +794,14 @@ function renderActionForm(node) {
|
|
|
722
794
|
}
|
|
723
795
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
724
796
|
try {
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
// Send the trimmed key back as the variable too, so the value a worker
|
|
737
|
-
// reads matches the key the message was correlated on (no leading/trailing
|
|
738
|
-
// whitespace divergence between correlationKey and variables[field]).
|
|
739
|
-
variables[p.action.correlationKeyField] = correlationKey;
|
|
740
|
-
await getJSON("/app/actions/message", { method: "POST",
|
|
741
|
-
headers: { "content-type": "application/json" },
|
|
742
|
-
body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
|
|
743
|
-
msg.className = "pc-msg ok";
|
|
744
|
-
msg.textContent = "Message published";
|
|
745
|
-
} else {
|
|
746
|
-
if (!p.action || !p.action.process) {
|
|
747
|
-
msg.className = "pc-msg err";
|
|
748
|
-
msg.textContent = "This form has no action configured";
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
752
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
753
|
-
msg.className = "pc-msg ok";
|
|
754
|
-
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
755
|
-
}
|
|
797
|
+
// Route-driven: POST the form (as { variables } by default, or the action's
|
|
798
|
+
// own body template) to the action's route path. correlationKey / process /
|
|
799
|
+
// message are expressed by the path + body template the page author supplies,
|
|
800
|
+
// e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
|
|
801
|
+
const res = await runRoute(p.action, { form: variables });
|
|
802
|
+
msg.className = "pc-msg ok";
|
|
803
|
+
msg.textContent = (p.action && p.action.successLabel) ||
|
|
804
|
+
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
756
805
|
for (const input of inputs.values()) input.value = "";
|
|
757
806
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
758
807
|
} catch (e) {
|
|
@@ -827,23 +876,9 @@ function renderDataGrid(node) {
|
|
|
827
876
|
}
|
|
828
877
|
|
|
829
878
|
async function fireAction(action, row) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
|
|
834
|
-
}
|
|
835
|
-
if (action.kind === "publishMessage") {
|
|
836
|
-
return getJSON("/app/actions/message", { method: "POST",
|
|
837
|
-
headers: { "content-type": "application/json" },
|
|
838
|
-
body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
|
|
839
|
-
variables: { ...(action.variables || {}) } }) });
|
|
840
|
-
}
|
|
841
|
-
if (action.kind === "startProcess") {
|
|
842
|
-
return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
|
|
843
|
-
headers: { "content-type": "application/json" },
|
|
844
|
-
body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
|
|
845
|
-
}
|
|
846
|
-
throw new Error("unknown action");
|
|
879
|
+
// Route-driven row action: POST the action's body template (default {})
|
|
880
|
+
// to its route path, with {{row.KEY}} tokens resolved from this row.
|
|
881
|
+
return runRoute(action, { row });
|
|
847
882
|
}
|
|
848
883
|
|
|
849
884
|
function rowActionButton(row, ra) {
|
|
@@ -878,11 +913,13 @@ function renderDataGrid(node) {
|
|
|
878
913
|
btn.addEventListener("click", async () => {
|
|
879
914
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
880
915
|
try {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
916
|
+
// The textarea value is the form's single field (f.inputKey); route + body
|
|
917
|
+
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
918
|
+
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
919
|
+
// Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
|
|
920
|
+
const form = Object.create(null); form[f.inputKey] = input.value;
|
|
921
|
+
await runRoute(f.action, { form, row });
|
|
922
|
+
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
886
923
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
887
924
|
} catch (e) {
|
|
888
925
|
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -111,7 +111,7 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
// The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
|
|
114
|
-
// still shadows an operation) and BEFORE the generic pages routes. It owns the
|
|
114
|
+
// still shadows an operation) and BEFORE the generic pages routes. It owns the fixed `/app/api`
|
|
115
115
|
// namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
|
|
116
116
|
const api = mountApi(ctx, app);
|
|
117
117
|
if (api.routes.length > 0) {
|