@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
package/dist/cli.js
CHANGED
|
@@ -142,6 +142,13 @@ async function cmdGen(f) {
|
|
|
142
142
|
console.error(` • ${e.path}: ${e.message}`);
|
|
143
143
|
return 1;
|
|
144
144
|
}
|
|
145
|
+
// Non-fatal spec-hygiene advisories (e.g. a requestBody schema shared by >1 operation). Printed
|
|
146
|
+
// in both gen and check; never change the exit code.
|
|
147
|
+
if (res.warnings.length > 0) {
|
|
148
|
+
console.warn(`⚠ ${res.warnings.length} spec warning(s):`);
|
|
149
|
+
for (const w of res.warnings)
|
|
150
|
+
console.warn(` • ${w}`);
|
|
151
|
+
}
|
|
145
152
|
if (f.check) {
|
|
146
153
|
if (res.drift.length === 0 && res.missingStubs.length === 0) {
|
|
147
154
|
console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
|
package/dist/openapi/spec.d.ts
CHANGED
|
@@ -152,6 +152,19 @@ export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
|
|
|
152
152
|
* fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
|
|
153
153
|
*/
|
|
154
154
|
export declare function assertUniqueOperationIds(doc: OpenApiDoc): void;
|
|
155
|
+
/** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
|
|
156
|
+
* keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
|
|
157
|
+
* request-body schema reused verbatim by multiple operations is the structural fingerprint of the
|
|
158
|
+
* "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
|
|
159
|
+
* operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
|
|
160
|
+
* `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
|
|
161
|
+
* tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
|
|
162
|
+
* split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
|
|
163
|
+
* only (pure, total) — the message is built by the caller. */
|
|
164
|
+
export declare function sharedRequestBodySchemas(doc: OpenApiDoc): {
|
|
165
|
+
ref: string;
|
|
166
|
+
operationIds: string[];
|
|
167
|
+
}[];
|
|
155
168
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
156
169
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
157
170
|
export declare function operationsWithoutId(doc: OpenApiDoc): string[];
|
package/dist/openapi/spec.js
CHANGED
|
@@ -200,6 +200,33 @@ export function assertUniqueOperationIds(doc) {
|
|
|
200
200
|
"it names the delegate module and the generated controller registry key.");
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
/** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
|
|
204
|
+
* keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
|
|
205
|
+
* request-body schema reused verbatim by multiple operations is the structural fingerprint of the
|
|
206
|
+
* "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
|
|
207
|
+
* operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
|
|
208
|
+
* `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
|
|
209
|
+
* tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
|
|
210
|
+
* split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
|
|
211
|
+
* only (pure, total) — the message is built by the caller. */
|
|
212
|
+
export function sharedRequestBodySchemas(doc) {
|
|
213
|
+
const byRef = new Map();
|
|
214
|
+
for (const op of collectOperations(doc)) {
|
|
215
|
+
const ref = op.requestBodySchema?.$ref;
|
|
216
|
+
if (typeof ref !== "string" || ref.length === 0)
|
|
217
|
+
continue;
|
|
218
|
+
const ids = byRef.get(ref) ?? [];
|
|
219
|
+
ids.push(op.operationId);
|
|
220
|
+
byRef.set(ref, ids);
|
|
221
|
+
}
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const [ref, ids] of byRef) {
|
|
224
|
+
if (ids.length > 1)
|
|
225
|
+
out.push({ ref, operationIds: [...ids].sort() });
|
|
226
|
+
}
|
|
227
|
+
out.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
203
230
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
204
231
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
205
232
|
export function operationsWithoutId(doc) {
|
|
@@ -532,6 +559,36 @@ function matchesType(declared, value) {
|
|
|
532
559
|
function matchesAnyType(declared, value) {
|
|
533
560
|
return declared.some((t) => matchesType(t, value));
|
|
534
561
|
}
|
|
562
|
+
/** A short, human-readable label for one `anyOf`/`oneOf` variant, so a composition failure can name
|
|
563
|
+
* the allowed shapes instead of an opaque "matched 0". Prefers the variant's discriminant — its
|
|
564
|
+
* `required` fields (e.g. `{pr}` vs `{url}`) — since that is exactly what a caller must choose
|
|
565
|
+
* between; falls back to the declared type(s), else "object". */
|
|
566
|
+
function variantLabel(doc, variant) {
|
|
567
|
+
const s = resolveSchema(doc, variant);
|
|
568
|
+
if (!s)
|
|
569
|
+
return "?";
|
|
570
|
+
const req = Array.isArray(s.required) ? s.required : [];
|
|
571
|
+
if (req.length > 0)
|
|
572
|
+
return `{${req.join(", ")}}`;
|
|
573
|
+
const types = schemaTypeList(s);
|
|
574
|
+
return types.length > 0 ? types.join("|") : "object";
|
|
575
|
+
}
|
|
576
|
+
/** The `(allowed: {pr} | {url})` suffix for a composition-failure summary message. */
|
|
577
|
+
function variantSummary(doc, variants) {
|
|
578
|
+
return ` (allowed: ${variants.map((v) => variantLabel(doc, v)).join(" | ")})`;
|
|
579
|
+
}
|
|
580
|
+
/** When a `oneOf`/`anyOf` matched no variant, surface actionable detail: the issues of the variant
|
|
581
|
+
* the value was CLOSEST to (fewest problems). For the discriminant case this is the shape the
|
|
582
|
+
* caller meant, so its issues read like `pr: is required` / `url: is not an allowed property`
|
|
583
|
+
* instead of a bare "matched 0". Ties resolve to the earliest variant (declaration order). */
|
|
584
|
+
function closestVariantIssues(perVariant) {
|
|
585
|
+
let best;
|
|
586
|
+
for (const iss of perVariant) {
|
|
587
|
+
if (best === undefined || iss.length < best.length)
|
|
588
|
+
best = iss;
|
|
589
|
+
}
|
|
590
|
+
return best ?? [];
|
|
591
|
+
}
|
|
535
592
|
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
536
593
|
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
537
594
|
export function validateValue(doc, schemaIn, value, path = "") {
|
|
@@ -578,13 +635,48 @@ export function validateValue(doc, schemaIn, value, path = "") {
|
|
|
578
635
|
for (const sub of schema.allOf)
|
|
579
636
|
issues.push(...validateValue(doc, sub, value, path));
|
|
580
637
|
}
|
|
581
|
-
if (schema.anyOf
|
|
582
|
-
|
|
638
|
+
if (schema.anyOf) {
|
|
639
|
+
// `anyOf` needs only ONE match, so short-circuit on the first clean variant — `validateValue`
|
|
640
|
+
// runs on every request (params/query/body/response) and evaluating every variant for a large
|
|
641
|
+
// schema is needless work once we have a hit. Only when NO variant matches do we build the full
|
|
642
|
+
// per-variant issue list to name the allowed shapes and surface the closest variant's issues.
|
|
643
|
+
let anyMatched = false;
|
|
644
|
+
const perVariant = [];
|
|
645
|
+
for (const sub of schema.anyOf) {
|
|
646
|
+
const subIssues = validateValue(doc, sub, value, path);
|
|
647
|
+
if (subIssues.length === 0) {
|
|
648
|
+
anyMatched = true;
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
perVariant.push(subIssues);
|
|
652
|
+
}
|
|
653
|
+
if (!anyMatched) {
|
|
654
|
+
issues.push({
|
|
655
|
+
path: at,
|
|
656
|
+
message: `does not match any of the ${schema.anyOf.length} allowed shapes${variantSummary(doc, schema.anyOf)}`,
|
|
657
|
+
});
|
|
658
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
659
|
+
}
|
|
583
660
|
}
|
|
584
661
|
if (schema.oneOf) {
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
662
|
+
const perVariant = schema.oneOf.map((sub) => validateValue(doc, sub, value, path));
|
|
663
|
+
const matched = perVariant.filter((v) => v.length === 0).length;
|
|
664
|
+
if (matched === 0) {
|
|
665
|
+
// No variant matched: name the allowed shapes AND surface the closest variant's issues, so a
|
|
666
|
+
// discriminated body (e.g. exactly one of pr|url) fails with actionable detail, not "matched 0".
|
|
667
|
+
issues.push({
|
|
668
|
+
path: at,
|
|
669
|
+
message: `does not match any of the ${schema.oneOf.length} allowed shapes${variantSummary(doc, schema.oneOf)}`,
|
|
670
|
+
});
|
|
671
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
672
|
+
}
|
|
673
|
+
else if (matched > 1) {
|
|
674
|
+
// Matched more than one: the shapes are not mutually exclusive for this value (usually a body
|
|
675
|
+
// that supplies two discriminants, or under-constrained variants). Report the ambiguity.
|
|
676
|
+
issues.push({
|
|
677
|
+
path: at,
|
|
678
|
+
message: `must match exactly one of the ${schema.oneOf.length} allowed shapes, but matched ${matched}${variantSummary(doc, schema.oneOf)}`,
|
|
679
|
+
});
|
|
588
680
|
}
|
|
589
681
|
}
|
|
590
682
|
// `value` is non-null here (the null branch returned above), so match against the declared value
|
|
@@ -13,6 +13,33 @@ export interface ApiBinding {
|
|
|
13
13
|
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
14
14
|
docs?: boolean | string;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
|
|
18
|
+
* page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
|
|
19
|
+
* `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
|
|
20
|
+
*
|
|
21
|
+
* Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
|
|
22
|
+
* trailing slash — reverse proxies and some webviews append one), and behind the Nano console
|
|
23
|
+
* proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
|
|
24
|
+
* relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
|
|
25
|
+
* "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
|
|
26
|
+
* app never sees the proxy prefix, so `window.location` in the browser is the only place with the
|
|
27
|
+
* full mount path.
|
|
28
|
+
*
|
|
29
|
+
* We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
|
|
30
|
+
* the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
|
|
31
|
+
* Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
|
|
32
|
+
* page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
|
|
33
|
+
* document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
|
|
34
|
+
*
|
|
35
|
+
* This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
|
|
36
|
+
* shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
|
|
37
|
+
* and free of template literals / `${}` (it is interpolated into a template literal).
|
|
38
|
+
*/
|
|
39
|
+
export declare function deriveSwaggerUrls(pageHref: string, docsBase: string, apiBase: string, relSpec: string): {
|
|
40
|
+
specUrl: string;
|
|
41
|
+
server: string | null;
|
|
42
|
+
};
|
|
16
43
|
/** The `{ params, query, body }` contract a typed operation is generic over. `body` is optional so
|
|
17
44
|
* a generated request type for an operation without a required requestBody (its `body?` is
|
|
18
45
|
* optional/`undefined`) still satisfies the constraint — see the generated `operations.ts` /
|
|
@@ -28,10 +28,49 @@ const SWAGGER_UI_VERSION = "5.17.14";
|
|
|
28
28
|
function escapeHtml(s) {
|
|
29
29
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
|
|
33
|
+
* page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
|
|
34
|
+
* `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
|
|
35
|
+
*
|
|
36
|
+
* Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
|
|
37
|
+
* trailing slash — reverse proxies and some webviews append one), and behind the Nano console
|
|
38
|
+
* proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
|
|
39
|
+
* relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
|
|
40
|
+
* "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
|
|
41
|
+
* app never sees the proxy prefix, so `window.location` in the browser is the only place with the
|
|
42
|
+
* full mount path.
|
|
43
|
+
*
|
|
44
|
+
* We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
|
|
45
|
+
* the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
|
|
46
|
+
* Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
|
|
47
|
+
* page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
|
|
48
|
+
* document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
|
|
49
|
+
*
|
|
50
|
+
* This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
|
|
51
|
+
* shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
|
|
52
|
+
* and free of template literals / `${}` (it is interpolated into a template literal).
|
|
53
|
+
*/
|
|
54
|
+
export function deriveSwaggerUrls(pageHref, docsBase, apiBase, relSpec) {
|
|
55
|
+
try {
|
|
56
|
+
const loc = new URL(pageHref);
|
|
57
|
+
const path = loc.pathname.replace(/\/+$/, "");
|
|
58
|
+
if (path.length >= docsBase.length && path.slice(path.length - docsBase.length) === docsBase) {
|
|
59
|
+
const mount = loc.origin + path.slice(0, path.length - docsBase.length);
|
|
60
|
+
return { specUrl: mount + docsBase + "/openapi.json", server: mount + apiBase };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Malformed href — fall through to the document-relative fallback below.
|
|
65
|
+
}
|
|
66
|
+
return { specUrl: relSpec, server: null };
|
|
67
|
+
}
|
|
31
68
|
/** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
|
|
32
|
-
* unreadable (
|
|
33
|
-
* which keeps the docs route decoupled from spec-load failures.
|
|
34
|
-
|
|
69
|
+
* unreadable (on a fetch/parse failure it hands `specUrl` to Swagger UI, which surfaces the error
|
|
70
|
+
* in-page), which keeps the docs route decoupled from spec-load failures. The client derives
|
|
71
|
+
* absolute spec + server URLs from `window.location` via `deriveSwaggerUrls` so "Try it out"
|
|
72
|
+
* targets the app regardless of a trailing slash on the docs URL or a reverse-proxy prefix. */
|
|
73
|
+
function swaggerUiPage(title, specUrl, docsBase, apiBase) {
|
|
35
74
|
const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
|
|
36
75
|
const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
|
|
37
76
|
return `<!doctype html>
|
|
@@ -47,7 +86,22 @@ function swaggerUiPage(title, specUrl) {
|
|
|
47
86
|
<div id="swagger-ui"></div>
|
|
48
87
|
<script src="${bundle}" crossorigin="anonymous"></script>
|
|
49
88
|
<script>
|
|
50
|
-
|
|
89
|
+
(function () {
|
|
90
|
+
var derive = ${deriveSwaggerUrls.toString()};
|
|
91
|
+
var urls = derive(window.location.href, ${JSON.stringify(docsBase)}, ${JSON.stringify(apiBase)}, ${JSON.stringify(specUrl)});
|
|
92
|
+
function boot(opts) { window.ui = SwaggerUIBundle(opts); }
|
|
93
|
+
fetch(urls.specUrl)
|
|
94
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
95
|
+
.then(function (spec) {
|
|
96
|
+
if (spec && urls.server) { spec.servers = [{ url: urls.server }]; }
|
|
97
|
+
boot(spec
|
|
98
|
+
? { spec: spec, dom_id: '#swagger-ui', deepLinking: true }
|
|
99
|
+
: { url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
100
|
+
})
|
|
101
|
+
.catch(function () {
|
|
102
|
+
boot({ url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
103
|
+
});
|
|
104
|
+
})();
|
|
51
105
|
</script>
|
|
52
106
|
</body>
|
|
53
107
|
</html>`;
|
|
@@ -201,12 +255,13 @@ export function mountApi(ctx, app) {
|
|
|
201
255
|
const dir = binding.dir ?? "operations";
|
|
202
256
|
const surfaceEject = binding.eject === true;
|
|
203
257
|
const specRoutePath = `${docsBase}/openapi.json`;
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
258
|
+
// Fallback values for the RAW served spec + docs page. The interactive Swagger UI computes
|
|
259
|
+
// ABSOLUTE spec + server URLs client-side from `window.location` (see `deriveSwaggerUrls`), which
|
|
260
|
+
// is immune to a trailing slash on the docs URL and to the Nano console reverse-proxy prefix
|
|
261
|
+
// (/console/app-view/<name>/). These document-relative values remain (a) the client's fallback
|
|
262
|
+
// when the page path unexpectedly doesn't end in `docsBase`, and (b) what a non-interactive
|
|
263
|
+
// consumer reading `openapi.json` directly sees. They still rebase onto the app's mount root
|
|
264
|
+
// rather than using a root-absolute "/app/…" that would escape the proxy prefix (issue #151).
|
|
210
265
|
const lastSegment = (p) => p.split("/").filter(Boolean).pop() ?? "";
|
|
211
266
|
const parentPath = (p) => {
|
|
212
267
|
const parts = p.split("/").filter(Boolean);
|
|
@@ -217,8 +272,7 @@ export function mountApi(ctx, app) {
|
|
|
217
272
|
const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
|
|
218
273
|
// `servers` can only be relativized when the operations `base` shares the docs page's parent
|
|
219
274
|
// directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
|
|
220
|
-
// different parent, keep the root-absolute base
|
|
221
|
-
// the documented limitation in #151).
|
|
275
|
+
// different parent, keep the root-absolute base for the raw spec.
|
|
222
276
|
const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
|
|
223
277
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
224
278
|
// pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
|
|
@@ -605,7 +659,7 @@ export function mountApi(ctx, app) {
|
|
|
605
659
|
return json({ ...d, servers: [{ url: serversUrl }] });
|
|
606
660
|
};
|
|
607
661
|
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
608
|
-
const serveDocs = () => html(swaggerUiPage(docsTitle, specDocUrl));
|
|
662
|
+
const serveDocs = () => html(swaggerUiPage(docsTitle, specDocUrl, docsBase, base));
|
|
609
663
|
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
610
664
|
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
611
665
|
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
@@ -26,9 +26,14 @@ import { html, json } from "../router.js";
|
|
|
26
26
|
import { cancelInstanceReconciling } from "./cancel.js";
|
|
27
27
|
import { apiDocsPath } from "./api.js";
|
|
28
28
|
import { quoteIdent } from "./gateway.js";
|
|
29
|
-
/** A JavaScript-body response (the renderer module served at
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
30
|
+
* `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
|
|
31
|
+
function javascript(body, status = 200, extraHeaders) {
|
|
32
|
+
return {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "content-type": "text/javascript; charset=utf-8", ...extraHeaders },
|
|
35
|
+
body,
|
|
36
|
+
};
|
|
32
37
|
}
|
|
33
38
|
/** A SQL identifier guard — a table name must be a bare identifier *and* a known table
|
|
34
39
|
* (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
|
|
@@ -75,14 +80,29 @@ export function createPagesRoutes(opts, deps) {
|
|
|
75
80
|
};
|
|
76
81
|
const routes = [];
|
|
77
82
|
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
83
|
+
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
84
|
+
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
85
|
+
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
86
|
+
// is what gets the long-lived immutable caching (via its content-hashed URL).
|
|
87
|
+
shell.headers = { ...shell.headers, "cache-control": "no-cache" };
|
|
78
88
|
// ── the renderer shell + module ─────────────────────────────────────────
|
|
79
89
|
routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
|
|
80
90
|
routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
|
|
91
|
+
// The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
|
|
92
|
+
routes.push({
|
|
93
|
+
method: "GET",
|
|
94
|
+
path: RUNTIME_JS_PATH,
|
|
95
|
+
source: "surface:pages",
|
|
96
|
+
handler: () => javascript(RENDERER_JS, 200, { "cache-control": IMMUTABLE_CACHE }),
|
|
97
|
+
});
|
|
98
|
+
// Back-compat / defence in depth: the legacy unhashed URL keeps working, but is served
|
|
99
|
+
// `no-cache` so any client (or cached shell) that still requests it always revalidates and
|
|
100
|
+
// gets the current bytes instead of replaying a heuristically-cached older copy.
|
|
81
101
|
routes.push({
|
|
82
102
|
method: "GET",
|
|
83
103
|
path: "/app/runtime.js",
|
|
84
104
|
source: "surface:pages",
|
|
85
|
-
handler: () => javascript(RENDERER_JS),
|
|
105
|
+
handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
|
|
86
106
|
});
|
|
87
107
|
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
88
108
|
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
@@ -377,7 +397,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
377
397
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
378
398
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
379
399
|
//
|
|
380
|
-
// The href must be DOCUMENT-relative, exactly like the `./app/runtime
|
|
400
|
+
// The href must be DOCUMENT-relative, exactly like the `./app/runtime.<hash>.js` script tag below:
|
|
381
401
|
// the shell is served at the app's mount root (always a trailing "/"), which is the origin
|
|
382
402
|
// root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
|
|
383
403
|
// proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
|
|
@@ -400,7 +420,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
400
420
|
</head>
|
|
401
421
|
<body>${apiDocsBadge}
|
|
402
422
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
403
|
-
<script type="module" src="
|
|
423
|
+
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
404
424
|
</body>
|
|
405
425
|
</html>`;
|
|
406
426
|
}
|
|
@@ -454,7 +474,9 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
454
474
|
.pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
|
|
455
475
|
.pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
|
|
456
476
|
.pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
457
|
-
.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); }
|
|
477
|
+
.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); }
|
|
478
|
+
.pc-field-check label { display:flex; flex-direction:row; align-items:center; gap:.5rem; font-size:.9rem; color:var(--nano-text); cursor:pointer; }
|
|
479
|
+
.pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
|
|
458
480
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
459
481
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
460
482
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
@@ -761,8 +783,24 @@ function renderActionForm(node) {
|
|
|
761
783
|
const inputs = new Map();
|
|
762
784
|
const fieldTypes = new Map();
|
|
763
785
|
for (const f of p.fields || []) {
|
|
764
|
-
const kind = f.type === "number" ? "number" : "text";
|
|
786
|
+
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
765
787
|
fieldTypes.set(f.key, kind);
|
|
788
|
+
if (kind === "checkbox") {
|
|
789
|
+
// A boolean field renders a real checkbox; its default checked state comes
|
|
790
|
+
// from default/checked on the field (unset -> unchecked). We record that on
|
|
791
|
+
// the input's defaultChecked so a post-submit reset can restore it without a
|
|
792
|
+
// side map that could drift from the actual input.
|
|
793
|
+
const checked = f.default === true || f.checked === true;
|
|
794
|
+
const input = el("input", { type: "checkbox" });
|
|
795
|
+
input.defaultChecked = checked;
|
|
796
|
+
input.checked = checked;
|
|
797
|
+
inputs.set(f.key, input);
|
|
798
|
+
// Input-first, wrapped in the label so the whole row is a click target.
|
|
799
|
+
card.append(
|
|
800
|
+
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
|
|
801
|
+
);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
766
804
|
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
767
805
|
if (kind === "number") {
|
|
768
806
|
attrs.inputmode = "numeric";
|
|
@@ -779,7 +817,11 @@ function renderActionForm(node) {
|
|
|
779
817
|
btn.addEventListener("click", async () => {
|
|
780
818
|
const variables = Object.create(null);
|
|
781
819
|
for (const [k, input] of inputs) {
|
|
782
|
-
if (fieldTypes.get(k) === "
|
|
820
|
+
if (fieldTypes.get(k) === "checkbox") {
|
|
821
|
+
// Always emit a real boolean (checked -> true / false), never a string,
|
|
822
|
+
// so a strict equality check on the action side sees the intended value.
|
|
823
|
+
variables[k] = input.checked === true;
|
|
824
|
+
} else if (fieldTypes.get(k) === "number") {
|
|
783
825
|
const t = String(input.value).trim();
|
|
784
826
|
// Blank → omit so the action-side default applies; a non-finite parse
|
|
785
827
|
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
@@ -802,7 +844,12 @@ function renderActionForm(node) {
|
|
|
802
844
|
msg.className = "pc-msg ok";
|
|
803
845
|
msg.textContent = (p.action && p.action.successLabel) ||
|
|
804
846
|
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
805
|
-
for (const input of inputs
|
|
847
|
+
for (const [k, input] of inputs) {
|
|
848
|
+
// Text/number inputs clear; a checkbox resets to its declared default,
|
|
849
|
+
// read straight off the input's defaultChecked (no side map to drift).
|
|
850
|
+
if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
|
|
851
|
+
else input.value = "";
|
|
852
|
+
}
|
|
806
853
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
807
854
|
} catch (e) {
|
|
808
855
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -1280,3 +1327,23 @@ async function renderPage() {
|
|
|
1280
1327
|
window.addEventListener("hashchange", renderPage);
|
|
1281
1328
|
renderPage();
|
|
1282
1329
|
`;
|
|
1330
|
+
/** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
|
|
1331
|
+
* module's URL so a content change busts the browser cache. Environment-agnostic (no
|
|
1332
|
+
* `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
|
|
1333
|
+
function fnv1aHex(s) {
|
|
1334
|
+
let h = 0x811c9dc5;
|
|
1335
|
+
for (let i = 0; i < s.length; i++) {
|
|
1336
|
+
h ^= s.charCodeAt(i);
|
|
1337
|
+
h = Math.imul(h, 0x01000193);
|
|
1338
|
+
}
|
|
1339
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1340
|
+
}
|
|
1341
|
+
// The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
|
|
1342
|
+
// path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
|
|
1343
|
+
// the URL changes with it and the browser fetches the new module instead of silently replaying a
|
|
1344
|
+
// heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
|
|
1345
|
+
// other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
|
|
1346
|
+
// per content, the response itself is served `immutable` with a one-year max-age.
|
|
1347
|
+
const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
|
|
1348
|
+
const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
|
|
1349
|
+
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
|
|
|
6
6
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
7
7
|
export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
8
8
|
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
9
|
-
export
|
|
9
|
+
export { createLogger } from "./core/logger.ts";
|
|
10
|
+
export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
|
|
10
11
|
export { BpmnError, isBpmnError } from "./core/host.ts";
|
|
11
12
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
|
|
12
13
|
export type { ConnectorsHandle } from "./core/modules/connectors.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -5,6 +5,9 @@ export { createUrbanApp } from "./core/runtime.js";
|
|
|
5
5
|
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
|
|
6
6
|
// Validation
|
|
7
7
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
|
|
8
|
+
// Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
|
|
9
|
+
// so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
|
|
10
|
+
export { createLogger } from "./core/logger.js";
|
|
8
11
|
// BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
|
|
9
12
|
// error routed to an error boundary (ADR 0050), instead of a retryable failure.
|
|
10
13
|
export { BpmnError, isBpmnError } from "./core/host.js";
|
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface GenResult {
|
|
|
42
42
|
incomplete: boolean;
|
|
43
43
|
/** Per-file model-derivation errors (empty on success). */
|
|
44
44
|
modelErrors: ModelError[];
|
|
45
|
+
/** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
|
|
46
|
+
* a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
|
|
47
|
+
warnings: string[];
|
|
45
48
|
}
|
|
46
49
|
declare function join(root: string, rel: string): string;
|
|
47
50
|
declare function dirOf(p: string): string;
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -9,7 +9,7 @@ import { deriveWorkerBindings } from "./derivers/worker-io.js";
|
|
|
9
9
|
import { deriveMeta } from "./derivers/meta.js";
|
|
10
10
|
import { deriveMessageBindings } from "./derivers/messages.js";
|
|
11
11
|
import { deriveApi } from "./derivers/api.js";
|
|
12
|
-
import { parseSpec } from "../openapi/spec.js";
|
|
12
|
+
import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.js";
|
|
13
13
|
import { deriveModels, MODEL_PROVENANCE } from "./models.js";
|
|
14
14
|
function join(root, rel) {
|
|
15
15
|
// Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
|
|
@@ -68,6 +68,7 @@ async function collectAll(opts) {
|
|
|
68
68
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
69
69
|
const manifest = JSON.parse(await io.readText(manifestPath));
|
|
70
70
|
const artifacts = [];
|
|
71
|
+
const warnings = [];
|
|
71
72
|
// 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
|
|
72
73
|
if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
|
|
73
74
|
artifacts.push(...deriveMigrations(manifest));
|
|
@@ -101,13 +102,21 @@ async function collectAll(opts) {
|
|
|
101
102
|
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
102
103
|
? manifest.api.dir.trim()
|
|
103
104
|
: undefined;
|
|
104
|
-
|
|
105
|
+
const doc = parseSpec(specText);
|
|
106
|
+
artifacts.push(...deriveApi(doc, opsDir, manifest.api?.eject === true));
|
|
107
|
+
// Non-fatal spec-hygiene: a requestBody schema reused by >1 operation can't be tightened into
|
|
108
|
+
// per-operation discriminated variants — flag it so the author can split it (never fail gen).
|
|
109
|
+
for (const { ref, operationIds } of sharedRequestBodySchemas(doc)) {
|
|
110
|
+
warnings.push(`requestBody schema ${ref} is shared by ${operationIds.length} operations ` +
|
|
111
|
+
`(${operationIds.join(", ")}). A single schema can't model per-operation discriminants — ` +
|
|
112
|
+
"consider splitting it into named variants + oneOf (additionalProperties:false) per operation.");
|
|
113
|
+
}
|
|
105
114
|
}
|
|
106
|
-
return { artifacts: sortArtifacts(artifacts), derived };
|
|
115
|
+
return { artifacts: sortArtifacts(artifacts), derived, warnings };
|
|
107
116
|
}
|
|
108
117
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
|
109
118
|
export async function runGen(opts) {
|
|
110
|
-
const { artifacts, derived } = await collectAll(opts);
|
|
119
|
+
const { artifacts, derived, warnings } = await collectAll(opts);
|
|
111
120
|
const { root, io } = opts;
|
|
112
121
|
const emitModels = opts.emitModels ?? true;
|
|
113
122
|
const drift = [];
|
|
@@ -160,7 +169,7 @@ export async function runGen(opts) {
|
|
|
160
169
|
swept.push(rel);
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
|
-
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
|
|
172
|
+
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
|
|
164
173
|
}
|
|
165
174
|
export { dirOf, join as joinPath };
|
|
166
175
|
/** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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",
|
package/src/cli.ts
CHANGED
|
@@ -153,6 +153,12 @@ async function cmdGen(f: Flags): Promise<number> {
|
|
|
153
153
|
for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
|
|
154
154
|
return 1;
|
|
155
155
|
}
|
|
156
|
+
// Non-fatal spec-hygiene advisories (e.g. a requestBody schema shared by >1 operation). Printed
|
|
157
|
+
// in both gen and check; never change the exit code.
|
|
158
|
+
if (res.warnings.length > 0) {
|
|
159
|
+
console.warn(`⚠ ${res.warnings.length} spec warning(s):`);
|
|
160
|
+
for (const w of res.warnings) console.warn(` • ${w}`);
|
|
161
|
+
}
|
|
156
162
|
if (f.check) {
|
|
157
163
|
if (res.drift.length === 0 && res.missingStubs.length === 0) {
|
|
158
164
|
console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
|