@nanobpm/urban 0.40.2 → 0.42.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/cli.js +7 -0
- package/dist/openapi/spec.d.ts +13 -0
- package/dist/openapi/spec.js +97 -5
- package/dist/runtime/core/modules/api.d.ts +27 -0
- package/dist/runtime/core/modules/api.js +67 -13
- package/dist/runtime/core/modules/pages.js +77 -10
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +3 -0
- package/dist/toolkit/gen.d.ts +3 -0
- package/dist/toolkit/gen.js +14 -5
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/openapi/spec.test.ts +94 -0
- package/src/openapi/spec.ts +95 -5
- package/src/runtime/core/logger.test.ts +11 -0
- package/src/runtime/core/modules/api.test.ts +129 -4
- package/src/runtime/core/modules/api.ts +72 -13
- package/src/runtime/core/modules/pages.test.ts +50 -6
- package/src/runtime/core/modules/pages.ts +83 -10
- package/src/runtime/index.ts +4 -2
- package/src/toolkit/gen.test.ts +32 -0
- package/src/toolkit/gen.ts +20 -6
|
@@ -73,9 +73,18 @@ export interface PagesDeps {
|
|
|
73
73
|
cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/** A JavaScript-body response (the renderer module served at
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
77
|
+
* `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
|
|
78
|
+
function javascript(
|
|
79
|
+
body: string,
|
|
80
|
+
status = 200,
|
|
81
|
+
extraHeaders?: Record<string, string>,
|
|
82
|
+
): { status: number; headers: Record<string, string>; body: string } {
|
|
83
|
+
return {
|
|
84
|
+
status,
|
|
85
|
+
headers: { "content-type": "text/javascript; charset=utf-8", ...extraHeaders },
|
|
86
|
+
body,
|
|
87
|
+
};
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/** A SQL identifier guard — a table name must be a bare identifier *and* a known table
|
|
@@ -130,15 +139,30 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
130
139
|
|
|
131
140
|
const routes: Route[] = [];
|
|
132
141
|
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
142
|
+
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
143
|
+
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
144
|
+
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
145
|
+
// is what gets the long-lived immutable caching (via its content-hashed URL).
|
|
146
|
+
shell.headers = { ...shell.headers, "cache-control": "no-cache" };
|
|
133
147
|
|
|
134
148
|
// ── the renderer shell + module ─────────────────────────────────────────
|
|
135
149
|
routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
|
|
136
150
|
routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
|
|
151
|
+
// The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
|
|
152
|
+
routes.push({
|
|
153
|
+
method: "GET",
|
|
154
|
+
path: RUNTIME_JS_PATH,
|
|
155
|
+
source: "surface:pages",
|
|
156
|
+
handler: () => javascript(RENDERER_JS, 200, { "cache-control": IMMUTABLE_CACHE }),
|
|
157
|
+
});
|
|
158
|
+
// Back-compat / defence in depth: the legacy unhashed URL keeps working, but is served
|
|
159
|
+
// `no-cache` so any client (or cached shell) that still requests it always revalidates and
|
|
160
|
+
// gets the current bytes instead of replaying a heuristically-cached older copy.
|
|
137
161
|
routes.push({
|
|
138
162
|
method: "GET",
|
|
139
163
|
path: "/app/runtime.js",
|
|
140
164
|
source: "surface:pages",
|
|
141
|
-
handler: () => javascript(RENDERER_JS),
|
|
165
|
+
handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
|
|
142
166
|
});
|
|
143
167
|
|
|
144
168
|
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
@@ -422,7 +446,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
422
446
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
423
447
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
424
448
|
//
|
|
425
|
-
// The href must be DOCUMENT-relative, exactly like the `./app/runtime
|
|
449
|
+
// The href must be DOCUMENT-relative, exactly like the `./app/runtime.<hash>.js` script tag below:
|
|
426
450
|
// the shell is served at the app's mount root (always a trailing "/"), which is the origin
|
|
427
451
|
// root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
|
|
428
452
|
// proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
|
|
@@ -446,7 +470,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
446
470
|
</head>
|
|
447
471
|
<body>${apiDocsBadge}
|
|
448
472
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
449
|
-
<script type="module" src="
|
|
473
|
+
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
450
474
|
</body>
|
|
451
475
|
</html>`;
|
|
452
476
|
}
|
|
@@ -501,7 +525,9 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
501
525
|
.pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
|
|
502
526
|
.pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
|
|
503
527
|
.pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
504
|
-
.pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
|
|
528
|
+
.pc-field input:not([type=checkbox]) { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
|
|
529
|
+
.pc-field-check label { display:flex; flex-direction:row; align-items:center; gap:.5rem; font-size:.9rem; color:var(--nano-text); cursor:pointer; }
|
|
530
|
+
.pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
|
|
505
531
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
506
532
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
507
533
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
@@ -809,8 +835,24 @@ function renderActionForm(node) {
|
|
|
809
835
|
const inputs = new Map();
|
|
810
836
|
const fieldTypes = new Map();
|
|
811
837
|
for (const f of p.fields || []) {
|
|
812
|
-
const kind = f.type === "number" ? "number" : "text";
|
|
838
|
+
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
813
839
|
fieldTypes.set(f.key, kind);
|
|
840
|
+
if (kind === "checkbox") {
|
|
841
|
+
// A boolean field renders a real checkbox; its default checked state comes
|
|
842
|
+
// from default/checked on the field (unset -> unchecked). We record that on
|
|
843
|
+
// the input's defaultChecked so a post-submit reset can restore it without a
|
|
844
|
+
// side map that could drift from the actual input.
|
|
845
|
+
const checked = f.default === true || f.checked === true;
|
|
846
|
+
const input = el("input", { type: "checkbox" });
|
|
847
|
+
input.defaultChecked = checked;
|
|
848
|
+
input.checked = checked;
|
|
849
|
+
inputs.set(f.key, input);
|
|
850
|
+
// Input-first, wrapped in the label so the whole row is a click target.
|
|
851
|
+
card.append(
|
|
852
|
+
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
|
|
853
|
+
);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
814
856
|
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
815
857
|
if (kind === "number") {
|
|
816
858
|
attrs.inputmode = "numeric";
|
|
@@ -827,7 +869,11 @@ function renderActionForm(node) {
|
|
|
827
869
|
btn.addEventListener("click", async () => {
|
|
828
870
|
const variables = Object.create(null);
|
|
829
871
|
for (const [k, input] of inputs) {
|
|
830
|
-
if (fieldTypes.get(k) === "
|
|
872
|
+
if (fieldTypes.get(k) === "checkbox") {
|
|
873
|
+
// Always emit a real boolean (checked -> true / false), never a string,
|
|
874
|
+
// so a strict equality check on the action side sees the intended value.
|
|
875
|
+
variables[k] = input.checked === true;
|
|
876
|
+
} else if (fieldTypes.get(k) === "number") {
|
|
831
877
|
const t = String(input.value).trim();
|
|
832
878
|
// Blank → omit so the action-side default applies; a non-finite parse
|
|
833
879
|
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
@@ -850,7 +896,12 @@ function renderActionForm(node) {
|
|
|
850
896
|
msg.className = "pc-msg ok";
|
|
851
897
|
msg.textContent = (p.action && p.action.successLabel) ||
|
|
852
898
|
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
853
|
-
for (const input of inputs
|
|
899
|
+
for (const [k, input] of inputs) {
|
|
900
|
+
// Text/number inputs clear; a checkbox resets to its declared default,
|
|
901
|
+
// read straight off the input's defaultChecked (no side map to drift).
|
|
902
|
+
if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
|
|
903
|
+
else input.value = "";
|
|
904
|
+
}
|
|
854
905
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
855
906
|
} catch (e) {
|
|
856
907
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -1328,3 +1379,25 @@ async function renderPage() {
|
|
|
1328
1379
|
window.addEventListener("hashchange", renderPage);
|
|
1329
1380
|
renderPage();
|
|
1330
1381
|
`;
|
|
1382
|
+
|
|
1383
|
+
/** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
|
|
1384
|
+
* module's URL so a content change busts the browser cache. Environment-agnostic (no
|
|
1385
|
+
* `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
|
|
1386
|
+
function fnv1aHex(s: string): string {
|
|
1387
|
+
let h = 0x811c9dc5;
|
|
1388
|
+
for (let i = 0; i < s.length; i++) {
|
|
1389
|
+
h ^= s.charCodeAt(i);
|
|
1390
|
+
h = Math.imul(h, 0x01000193);
|
|
1391
|
+
}
|
|
1392
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
|
|
1396
|
+
// path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
|
|
1397
|
+
// the URL changes with it and the browser fetches the new module instead of silently replaying a
|
|
1398
|
+
// heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
|
|
1399
|
+
// other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
|
|
1400
|
+
// per content, the response itself is served `immutable` with a one-year max-age.
|
|
1401
|
+
const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
|
|
1402
|
+
const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
|
|
1403
|
+
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
package/src/runtime/index.ts
CHANGED
|
@@ -54,8 +54,10 @@ export type {
|
|
|
54
54
|
WorkerSubscription,
|
|
55
55
|
} from "./core/host.ts";
|
|
56
56
|
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
57
|
-
// Structured logging surface (see core/logger.ts): the shape of `AppApi.log
|
|
58
|
-
|
|
57
|
+
// Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
|
|
58
|
+
// so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
|
|
59
|
+
export { createLogger } from "./core/logger.ts";
|
|
60
|
+
export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
|
|
59
61
|
|
|
60
62
|
// BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
|
|
61
63
|
// error routed to an error boundary (ADR 0050), instead of a retryable failure.
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -257,3 +257,35 @@ test("collectArtifacts resolves an ABSOLUTE api.spec at its own path, not root-r
|
|
|
257
257
|
const res = await collectArtifacts({ root: "/app", io });
|
|
258
258
|
assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
test("runGen warns when a requestBody schema is shared across operations, but never fails", async () => {
|
|
262
|
+
const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "openapi.json" } });
|
|
263
|
+
const openapi = JSON.stringify({
|
|
264
|
+
openapi: "3.1.0",
|
|
265
|
+
components: { schemas: { StartVariables: { type: "object", properties: { pr: { type: "string" }, url: { type: "string" }, issue: { type: "string" } } } } },
|
|
266
|
+
paths: {
|
|
267
|
+
"/convergence": { post: { operationId: "startConvergenceLoop", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/StartVariables" } } } }, responses: { "200": {} } } },
|
|
268
|
+
"/fanout": { post: { operationId: "startPlanFanout", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/StartVariables" } } } }, responses: { "200": {} } } },
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
const io = memIO({ "/app/nano.app.json": manifest, "/app/openapi.json": openapi });
|
|
272
|
+
const res = await runGen({ root: "/app", io });
|
|
273
|
+
|
|
274
|
+
assert.equal(res.incomplete, false); // advisory only — gen still succeeds
|
|
275
|
+
assert.equal(res.warnings.length, 1, `one shared-body warning: ${JSON.stringify(res.warnings)}`);
|
|
276
|
+
assert.match(res.warnings[0], /StartVariables/);
|
|
277
|
+
assert.match(res.warnings[0], /startConvergenceLoop, startPlanFanout/);
|
|
278
|
+
// A clean spec (distinct bodies) produces no warning.
|
|
279
|
+
const clean = memIO({
|
|
280
|
+
"/app/nano.app.json": manifest,
|
|
281
|
+
"/app/openapi.json": JSON.stringify({
|
|
282
|
+
openapi: "3.1.0",
|
|
283
|
+
components: { schemas: { A: { type: "object" }, B: { type: "object" } } },
|
|
284
|
+
paths: {
|
|
285
|
+
"/a": { post: { operationId: "opA", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/A" } } } }, responses: { "200": {} } } },
|
|
286
|
+
"/b": { post: { operationId: "opB", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/B" } } } }, responses: { "200": {} } } },
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
assert.deepEqual((await runGen({ root: "/app", io: clean })).warnings, []);
|
|
291
|
+
});
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts"
|
|
|
11
11
|
import { deriveMeta } from "./derivers/meta.ts";
|
|
12
12
|
import { deriveMessageBindings } from "./derivers/messages.ts";
|
|
13
13
|
import { deriveApi } from "./derivers/api.ts";
|
|
14
|
-
import { parseSpec } from "../openapi/spec.ts";
|
|
14
|
+
import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.ts";
|
|
15
15
|
import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
|
|
16
16
|
|
|
17
17
|
/** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
|
|
@@ -57,6 +57,9 @@ export interface GenResult {
|
|
|
57
57
|
incomplete: boolean;
|
|
58
58
|
/** Per-file model-derivation errors (empty on success). */
|
|
59
59
|
modelErrors: ModelError[];
|
|
60
|
+
/** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
|
|
61
|
+
* a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
|
|
62
|
+
warnings: string[];
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
function join(root: string, rel: string): string {
|
|
@@ -118,7 +121,7 @@ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifac
|
|
|
118
121
|
|
|
119
122
|
/** Internal: collect artifacts AND the model-derivation details (needed by `runGen` for the
|
|
120
123
|
* incomplete signal + the provenance-scoped stale sweep). */
|
|
121
|
-
async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels }> {
|
|
124
|
+
async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels; warnings: string[] }> {
|
|
122
125
|
const { root, io } = opts;
|
|
123
126
|
const emitModels = opts.emitModels ?? true;
|
|
124
127
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
@@ -128,6 +131,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
128
131
|
} = JSON.parse(await io.readText(manifestPath));
|
|
129
132
|
|
|
130
133
|
const artifacts: DerivedArtifact[] = [];
|
|
134
|
+
const warnings: string[] = [];
|
|
131
135
|
|
|
132
136
|
// 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
|
|
133
137
|
if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
|
|
@@ -164,15 +168,25 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
164
168
|
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
165
169
|
? manifest.api.dir.trim()
|
|
166
170
|
: undefined;
|
|
167
|
-
|
|
171
|
+
const doc = parseSpec(specText);
|
|
172
|
+
artifacts.push(...deriveApi(doc, opsDir, manifest.api?.eject === true));
|
|
173
|
+
// Non-fatal spec-hygiene: a requestBody schema reused by >1 operation can't be tightened into
|
|
174
|
+
// per-operation discriminated variants — flag it so the author can split it (never fail gen).
|
|
175
|
+
for (const { ref, operationIds } of sharedRequestBodySchemas(doc)) {
|
|
176
|
+
warnings.push(
|
|
177
|
+
`requestBody schema ${ref} is shared by ${operationIds.length} operations ` +
|
|
178
|
+
`(${operationIds.join(", ")}). A single schema can't model per-operation discriminants — ` +
|
|
179
|
+
"consider splitting it into named variants + oneOf (additionalProperties:false) per operation.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
return { artifacts: sortArtifacts(artifacts), derived };
|
|
184
|
+
return { artifacts: sortArtifacts(artifacts), derived, warnings };
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
|
174
188
|
export async function runGen(opts: GenOptions & { check?: boolean }): Promise<GenResult> {
|
|
175
|
-
const { artifacts, derived } = await collectAll(opts);
|
|
189
|
+
const { artifacts, derived, warnings } = await collectAll(opts);
|
|
176
190
|
const { root, io } = opts;
|
|
177
191
|
const emitModels = opts.emitModels ?? true;
|
|
178
192
|
const drift: string[] = [];
|
|
@@ -224,7 +238,7 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
|
|
|
224
238
|
}
|
|
225
239
|
}
|
|
226
240
|
|
|
227
|
-
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
|
|
241
|
+
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
|
|
228
242
|
}
|
|
229
243
|
|
|
230
244
|
export { dirOf, join as joinPath };
|