@nanobpm/urban 0.41.0 → 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/runtime/core/modules/api.d.ts +27 -0
- package/dist/runtime/core/modules/api.js +67 -13
- package/dist/runtime/core/modules/pages.js +31 -4
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +3 -0
- package/package.json +1 -1
- 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 +16 -0
- package/src/runtime/core/modules/pages.ts +31 -4
- package/src/runtime/index.ts +4 -2
|
@@ -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
|
|
@@ -474,7 +474,9 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
474
474
|
.pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
|
|
475
475
|
.pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
|
|
476
476
|
.pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
477
|
-
.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; }
|
|
478
480
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
479
481
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
480
482
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
@@ -781,8 +783,24 @@ function renderActionForm(node) {
|
|
|
781
783
|
const inputs = new Map();
|
|
782
784
|
const fieldTypes = new Map();
|
|
783
785
|
for (const f of p.fields || []) {
|
|
784
|
-
const kind = f.type === "number" ? "number" : "text";
|
|
786
|
+
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
785
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
|
+
}
|
|
786
804
|
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
787
805
|
if (kind === "number") {
|
|
788
806
|
attrs.inputmode = "numeric";
|
|
@@ -799,7 +817,11 @@ function renderActionForm(node) {
|
|
|
799
817
|
btn.addEventListener("click", async () => {
|
|
800
818
|
const variables = Object.create(null);
|
|
801
819
|
for (const [k, input] of inputs) {
|
|
802
|
-
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") {
|
|
803
825
|
const t = String(input.value).trim();
|
|
804
826
|
// Blank → omit so the action-side default applies; a non-finite parse
|
|
805
827
|
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
@@ -822,7 +844,12 @@ function renderActionForm(node) {
|
|
|
822
844
|
msg.className = "pc-msg ok";
|
|
823
845
|
msg.textContent = (p.action && p.action.successLabel) ||
|
|
824
846
|
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
825
|
-
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
|
+
}
|
|
826
853
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
827
854
|
} catch (e) {
|
|
828
855
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
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/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",
|
|
@@ -219,3 +219,14 @@ test("child() never throws when the added bindings are hostile — degrades to t
|
|
|
219
219
|
assert.equal(records[0].msg, "shipped");
|
|
220
220
|
assert.deepEqual({ ...records[0].fields }, { orderId: "o1" });
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
test("createLogger is re-exported from the runtime public barrel", async () => {
|
|
224
|
+
// Consumers build a Logger via the package entry (`@nanobpm/urban/runtime`) — e.g. a no-op sink
|
|
225
|
+
// for test doubles of `app.log`, or a custom sink in app code — so the barrel must expose it.
|
|
226
|
+
const barrel = await import("../index.ts");
|
|
227
|
+
assert.equal(typeof barrel.createLogger, "function");
|
|
228
|
+
const { records, sink } = recorder();
|
|
229
|
+
barrel.createLogger(sink).info("via-barrel");
|
|
230
|
+
assert.equal(records.length, 1);
|
|
231
|
+
assert.equal(records[0].msg, "via-barrel");
|
|
232
|
+
});
|
|
@@ -6,7 +6,7 @@ import type { AppManifest } from "../manifest.ts";
|
|
|
6
6
|
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
7
7
|
import { makeRouter } from "../router.ts";
|
|
8
8
|
import { DataLayer } from "./datasource.ts";
|
|
9
|
-
import { mountApi, resolveOperationHandler, apiDocsPath, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
9
|
+
import { mountApi, resolveOperationHandler, apiDocsPath, deriveSwaggerUrls, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
10
10
|
|
|
11
11
|
function req(
|
|
12
12
|
method: string,
|
|
@@ -746,12 +746,137 @@ test("serves the Swagger UI page at {base}-docs by default", async () => {
|
|
|
746
746
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
747
747
|
const body = res.body ?? "";
|
|
748
748
|
assert.match(body, /swagger-ui/);
|
|
749
|
-
// The
|
|
750
|
-
//
|
|
751
|
-
|
|
749
|
+
// The page derives ABSOLUTE spec + server URLs from window.location (issue #151 + the trailing-
|
|
750
|
+
// slash 404): the embedded derivation is passed the mount-root docs base, operations base, and a
|
|
751
|
+
// document-relative fallback. It must never hard-code a root-absolute "/app/api-docs/openapi.json"
|
|
752
|
+
// that would escape the Nano console reverse-proxy prefix.
|
|
753
|
+
assert.match(body, /window\.location\.href/);
|
|
754
|
+
assert.match(body, /"\/app\/api-docs"/); // docsBase handed to the client derivation
|
|
755
|
+
assert.match(body, /"\/app\/api"/); // operations base handed to the client derivation
|
|
756
|
+
assert.match(body, /"api-docs\/openapi\.json"/); // document-relative fallback
|
|
752
757
|
assert.doesNotMatch(body, /url: "\/app\/api-docs\/openapi\.json"/);
|
|
753
758
|
});
|
|
754
759
|
|
|
760
|
+
test("the rendered docs page runs its derivation end-to-end and targets the app through the proxy", async () => {
|
|
761
|
+
const { router } = build({ spec: "openapi.json" }, {});
|
|
762
|
+
const res = await router(req("GET", "/app/api-docs"));
|
|
763
|
+
const body = res.body ?? "";
|
|
764
|
+
// Extract the inline bootstrap <script> (the one that is NOT the swagger-ui-bundle src include).
|
|
765
|
+
const scripts = [...body.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
|
766
|
+
assert.ok(scripts.length >= 1, "expected an inline bootstrap script");
|
|
767
|
+
const boot = scripts[scripts.length - 1];
|
|
768
|
+
// 1) It must be syntactically valid JS (guards the `${derive.toString()}` template interpolation).
|
|
769
|
+
const runBoot = new Function("window", "SwaggerUIBundle", "fetch", boot);
|
|
770
|
+
// 2) Execute it in a mocked browser at the EXACT reported failing URL (proxy prefix + trailing
|
|
771
|
+
// slash) and assert the servers override Swagger UI receives is the app's /app/api under the
|
|
772
|
+
// proxy — never the /app/api-docs/api that produced the 404.
|
|
773
|
+
const specDoc = { openapi: "3.0.3", paths: {}, servers: [{ url: "api" }] };
|
|
774
|
+
let received: unknown;
|
|
775
|
+
const swaggerStub = (opts: { spec?: { servers?: unknown } }): unknown => {
|
|
776
|
+
received = opts.spec?.servers;
|
|
777
|
+
return {};
|
|
778
|
+
};
|
|
779
|
+
const proxySlashUrl = "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/";
|
|
780
|
+
const win: { location: { href: string }; ui?: unknown } = { location: { href: proxySlashUrl } };
|
|
781
|
+
let fetched = "";
|
|
782
|
+
const fetchStub = (url: string): Promise<{ ok: boolean; json: () => Promise<unknown> }> => {
|
|
783
|
+
fetched = url;
|
|
784
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve(specDoc) });
|
|
785
|
+
};
|
|
786
|
+
runBoot(win, swaggerStub, fetchStub);
|
|
787
|
+
await new Promise((r) => setTimeout(r, 0)); // let the fetch().then microtasks settle
|
|
788
|
+
assert.equal(
|
|
789
|
+
fetched,
|
|
790
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
791
|
+
"spec is fetched at an absolute, proxy-prefixed URL (immune to the trailing slash)",
|
|
792
|
+
);
|
|
793
|
+
assert.deepEqual(received, [
|
|
794
|
+
{ url: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api" },
|
|
795
|
+
// ^ Try it out now targets the app's operations base under the proxy, not /app/api-docs/api.
|
|
796
|
+
]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("deriveSwaggerUrls: absolute spec+server from the page URL, immune to trailing slash + proxy prefix", () => {
|
|
800
|
+
const docsBase = "/app/api-docs";
|
|
801
|
+
const apiBase = "/app/api";
|
|
802
|
+
const rel = "api-docs/openapi.json";
|
|
803
|
+
// Direct CLI (:3000), no trailing slash — the canonical case.
|
|
804
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs", docsBase, apiBase, rel), {
|
|
805
|
+
specUrl: "http://127.0.0.1:3000/app/api-docs/openapi.json",
|
|
806
|
+
server: "http://127.0.0.1:3000/app/api",
|
|
807
|
+
});
|
|
808
|
+
// Direct CLI WITH a trailing slash — a relative "servers: api" used to resolve to
|
|
809
|
+
// /app/api-docs/api here (the reported 404). The absolute derivation is unaffected.
|
|
810
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs/", docsBase, apiBase, rel), {
|
|
811
|
+
specUrl: "http://127.0.0.1:3000/app/api-docs/openapi.json",
|
|
812
|
+
server: "http://127.0.0.1:3000/app/api",
|
|
813
|
+
});
|
|
814
|
+
// Under the Nano console reverse proxy — the mount prefix is preserved in both URLs.
|
|
815
|
+
assert.deepEqual(
|
|
816
|
+
deriveSwaggerUrls(
|
|
817
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs",
|
|
818
|
+
docsBase,
|
|
819
|
+
apiBase,
|
|
820
|
+
rel,
|
|
821
|
+
),
|
|
822
|
+
{
|
|
823
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
824
|
+
server: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api",
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
// Proxy WITH a trailing slash — the exact failure the user reported. Now correct.
|
|
828
|
+
assert.deepEqual(
|
|
829
|
+
deriveSwaggerUrls(
|
|
830
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/",
|
|
831
|
+
docsBase,
|
|
832
|
+
apiBase,
|
|
833
|
+
rel,
|
|
834
|
+
),
|
|
835
|
+
{
|
|
836
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
837
|
+
server: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api",
|
|
838
|
+
},
|
|
839
|
+
);
|
|
840
|
+
// A query string / fragment on the docs URL is ignored (pathname only).
|
|
841
|
+
assert.equal(
|
|
842
|
+
deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs?x=1#y", docsBase, apiBase, rel).server,
|
|
843
|
+
"http://127.0.0.1:3000/app/api",
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("deriveSwaggerUrls: a custom docs path keeps the proxy prefix too", () => {
|
|
848
|
+
// A string `docs` override lands the page at a root-level `/help`; the derivation still recovers
|
|
849
|
+
// the (proxy-prefixed) mount root by stripping that suffix and re-appending the operations base.
|
|
850
|
+
assert.deepEqual(
|
|
851
|
+
deriveSwaggerUrls(
|
|
852
|
+
"http://127.0.0.1:8080/console/app-view/App/help/",
|
|
853
|
+
"/help",
|
|
854
|
+
"/app/api",
|
|
855
|
+
"help/openapi.json",
|
|
856
|
+
),
|
|
857
|
+
{
|
|
858
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/App/help/openapi.json",
|
|
859
|
+
server: "http://127.0.0.1:8080/console/app-view/App/app/api",
|
|
860
|
+
},
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("deriveSwaggerUrls: falls back to the document-relative spec when the path doesn't match or href is bad", () => {
|
|
865
|
+
const docsBase = "/app/api-docs";
|
|
866
|
+
const apiBase = "/app/api";
|
|
867
|
+
const rel = "api-docs/openapi.json";
|
|
868
|
+
// Page path doesn't end in docsBase (unexpected) → keep the relative fallback, no server override.
|
|
869
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/elsewhere", docsBase, apiBase, rel), {
|
|
870
|
+
specUrl: rel,
|
|
871
|
+
server: null,
|
|
872
|
+
});
|
|
873
|
+
// Malformed href → same safe fallback (no throw).
|
|
874
|
+
assert.deepEqual(deriveSwaggerUrls("not a url", docsBase, apiBase, rel), {
|
|
875
|
+
specUrl: rel,
|
|
876
|
+
server: null,
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
755
880
|
test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
|
|
756
881
|
const { router } = build({ spec: "openapi.json" }, {});
|
|
757
882
|
// The router exact-matches non-prefix routes, so `${base}-docs/` (common from proxies/browsers)
|
|
@@ -70,10 +70,54 @@ function escapeHtml(s: string): string {
|
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
|
|
75
|
+
* page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
|
|
76
|
+
* `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
|
|
77
|
+
*
|
|
78
|
+
* Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
|
|
79
|
+
* trailing slash — reverse proxies and some webviews append one), and behind the Nano console
|
|
80
|
+
* proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
|
|
81
|
+
* relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
|
|
82
|
+
* "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
|
|
83
|
+
* app never sees the proxy prefix, so `window.location` in the browser is the only place with the
|
|
84
|
+
* full mount path.
|
|
85
|
+
*
|
|
86
|
+
* We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
|
|
87
|
+
* the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
|
|
88
|
+
* Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
|
|
89
|
+
* page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
|
|
90
|
+
* document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
|
|
91
|
+
*
|
|
92
|
+
* This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
|
|
93
|
+
* shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
|
|
94
|
+
* and free of template literals / `${}` (it is interpolated into a template literal).
|
|
95
|
+
*/
|
|
96
|
+
export function deriveSwaggerUrls(
|
|
97
|
+
pageHref: string,
|
|
98
|
+
docsBase: string,
|
|
99
|
+
apiBase: string,
|
|
100
|
+
relSpec: string,
|
|
101
|
+
): { specUrl: string; server: string | null } {
|
|
102
|
+
try {
|
|
103
|
+
const loc = new URL(pageHref);
|
|
104
|
+
const path = loc.pathname.replace(/\/+$/, "");
|
|
105
|
+
if (path.length >= docsBase.length && path.slice(path.length - docsBase.length) === docsBase) {
|
|
106
|
+
const mount = loc.origin + path.slice(0, path.length - docsBase.length);
|
|
107
|
+
return { specUrl: mount + docsBase + "/openapi.json", server: mount + apiBase };
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed href — fall through to the document-relative fallback below.
|
|
111
|
+
}
|
|
112
|
+
return { specUrl: relSpec, server: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
/** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
|
|
74
|
-
* unreadable (
|
|
75
|
-
* which keeps the docs route decoupled from spec-load failures.
|
|
76
|
-
|
|
116
|
+
* unreadable (on a fetch/parse failure it hands `specUrl` to Swagger UI, which surfaces the error
|
|
117
|
+
* in-page), which keeps the docs route decoupled from spec-load failures. The client derives
|
|
118
|
+
* absolute spec + server URLs from `window.location` via `deriveSwaggerUrls` so "Try it out"
|
|
119
|
+
* targets the app regardless of a trailing slash on the docs URL or a reverse-proxy prefix. */
|
|
120
|
+
function swaggerUiPage(title: string, specUrl: string, docsBase: string, apiBase: string): string {
|
|
77
121
|
const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
|
|
78
122
|
const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
|
|
79
123
|
return `<!doctype html>
|
|
@@ -89,7 +133,22 @@ function swaggerUiPage(title: string, specUrl: string): string {
|
|
|
89
133
|
<div id="swagger-ui"></div>
|
|
90
134
|
<script src="${bundle}" crossorigin="anonymous"></script>
|
|
91
135
|
<script>
|
|
92
|
-
|
|
136
|
+
(function () {
|
|
137
|
+
var derive = ${deriveSwaggerUrls.toString()};
|
|
138
|
+
var urls = derive(window.location.href, ${JSON.stringify(docsBase)}, ${JSON.stringify(apiBase)}, ${JSON.stringify(specUrl)});
|
|
139
|
+
function boot(opts) { window.ui = SwaggerUIBundle(opts); }
|
|
140
|
+
fetch(urls.specUrl)
|
|
141
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
142
|
+
.then(function (spec) {
|
|
143
|
+
if (spec && urls.server) { spec.servers = [{ url: urls.server }]; }
|
|
144
|
+
boot(spec
|
|
145
|
+
? { spec: spec, dom_id: '#swagger-ui', deepLinking: true }
|
|
146
|
+
: { url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
147
|
+
})
|
|
148
|
+
.catch(function () {
|
|
149
|
+
boot({ url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
150
|
+
});
|
|
151
|
+
})();
|
|
93
152
|
</script>
|
|
94
153
|
</body>
|
|
95
154
|
</html>`;
|
|
@@ -312,12 +371,13 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
312
371
|
const dir = binding.dir ?? "operations";
|
|
313
372
|
const surfaceEject = binding.eject === true;
|
|
314
373
|
const specRoutePath = `${docsBase}/openapi.json`;
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
374
|
+
// Fallback values for the RAW served spec + docs page. The interactive Swagger UI computes
|
|
375
|
+
// ABSOLUTE spec + server URLs client-side from `window.location` (see `deriveSwaggerUrls`), which
|
|
376
|
+
// is immune to a trailing slash on the docs URL and to the Nano console reverse-proxy prefix
|
|
377
|
+
// (/console/app-view/<name>/). These document-relative values remain (a) the client's fallback
|
|
378
|
+
// when the page path unexpectedly doesn't end in `docsBase`, and (b) what a non-interactive
|
|
379
|
+
// consumer reading `openapi.json` directly sees. They still rebase onto the app's mount root
|
|
380
|
+
// rather than using a root-absolute "/app/…" that would escape the proxy prefix (issue #151).
|
|
321
381
|
const lastSegment = (p: string): string => p.split("/").filter(Boolean).pop() ?? "";
|
|
322
382
|
const parentPath = (p: string): string => {
|
|
323
383
|
const parts = p.split("/").filter(Boolean);
|
|
@@ -328,8 +388,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
328
388
|
const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
|
|
329
389
|
// `servers` can only be relativized when the operations `base` shares the docs page's parent
|
|
330
390
|
// directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
|
|
331
|
-
// different parent, keep the root-absolute base
|
|
332
|
-
// the documented limitation in #151).
|
|
391
|
+
// different parent, keep the root-absolute base for the raw spec.
|
|
333
392
|
const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
|
|
334
393
|
|
|
335
394
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
@@ -741,7 +800,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
741
800
|
return json({ ...d, servers: [{ url: serversUrl }] });
|
|
742
801
|
};
|
|
743
802
|
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
744
|
-
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl));
|
|
803
|
+
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl, docsBase, base));
|
|
745
804
|
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
746
805
|
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
747
806
|
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
@@ -353,6 +353,22 @@ test("the renderer honours numeric actionForm fields", async () => {
|
|
|
353
353
|
assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
+
test("the renderer honours boolean (checkbox) actionForm fields", async () => {
|
|
357
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
358
|
+
const js = res.body ?? "";
|
|
359
|
+
// Fields declared `type: "checkbox"` render a real <input type="checkbox">…
|
|
360
|
+
assert.match(js, /f\.type === "checkbox"/);
|
|
361
|
+
assert.match(js, /el\("input", \{ type: "checkbox" \}\)/);
|
|
362
|
+
// …whose default checked state comes from `default`/`checked` on the field…
|
|
363
|
+
assert.match(js, /f\.default === true \|\| f\.checked === true/);
|
|
364
|
+
// …and whose submitted value is always a real boolean (never a string), so a
|
|
365
|
+
// strict `=== true` check on the action side sees the intended value.
|
|
366
|
+
assert.match(js, /variables\[k\] = input\.checked === true;/);
|
|
367
|
+
// A post-submit reset restores a checkbox to its declared default (read off the
|
|
368
|
+
// input's defaultChecked) rather than clearing `.value` (a no-op for a checkbox).
|
|
369
|
+
assert.match(js, /input\.checked = input\.defaultChecked;/);
|
|
370
|
+
});
|
|
371
|
+
|
|
356
372
|
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
357
373
|
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
358
374
|
// body, and the collapsed state is written to localStorage keyed by the home
|
|
@@ -525,7 +525,9 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
525
525
|
.pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
|
|
526
526
|
.pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
|
|
527
527
|
.pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
528
|
-
.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; }
|
|
529
531
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
530
532
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
531
533
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
@@ -833,8 +835,24 @@ function renderActionForm(node) {
|
|
|
833
835
|
const inputs = new Map();
|
|
834
836
|
const fieldTypes = new Map();
|
|
835
837
|
for (const f of p.fields || []) {
|
|
836
|
-
const kind = f.type === "number" ? "number" : "text";
|
|
838
|
+
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
837
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
|
+
}
|
|
838
856
|
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
839
857
|
if (kind === "number") {
|
|
840
858
|
attrs.inputmode = "numeric";
|
|
@@ -851,7 +869,11 @@ function renderActionForm(node) {
|
|
|
851
869
|
btn.addEventListener("click", async () => {
|
|
852
870
|
const variables = Object.create(null);
|
|
853
871
|
for (const [k, input] of inputs) {
|
|
854
|
-
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") {
|
|
855
877
|
const t = String(input.value).trim();
|
|
856
878
|
// Blank → omit so the action-side default applies; a non-finite parse
|
|
857
879
|
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
@@ -874,7 +896,12 @@ function renderActionForm(node) {
|
|
|
874
896
|
msg.className = "pc-msg ok";
|
|
875
897
|
msg.textContent = (p.action && p.action.successLabel) ||
|
|
876
898
|
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
877
|
-
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
|
+
}
|
|
878
905
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
879
906
|
} catch (e) {
|
|
880
907
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
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.
|