@nanobpm/urban 0.41.0 → 0.43.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/cancel.d.ts +8 -4
- package/dist/runtime/core/modules/cancel.js +23 -11
- package/dist/runtime/core/modules/pages.js +229 -9
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +3 -0
- package/package.json +2 -2
- 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/cancel.test.ts +28 -0
- package/src/runtime/core/modules/cancel.ts +23 -11
- package/src/runtime/core/modules/pages.test.ts +120 -0
- package/src/runtime/core/modules/pages.ts +229 -9
- 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
|
|
@@ -19,10 +19,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
|
|
|
19
19
|
* Outcome rules:
|
|
20
20
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
21
21
|
* 204); trust it even if the read model still lags at ACTIVE.
|
|
22
|
-
* - `cancelInstance` throws but the instance reads back
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
23
|
+
* → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
|
|
24
|
+
* row here (see below); a COMPLETED read intentionally reconciles nothing.
|
|
25
|
+
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
26
|
+
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
27
|
+
* instance) → an honest failure: report it and do NOT touch any row (the instance may still be
|
|
28
|
+
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
29
|
+
* termination, so success is never inferred from it.
|
|
26
30
|
*
|
|
27
31
|
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
28
32
|
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// This primitive keys success on the engine's response, then reconciles off the read model: a
|
|
15
15
|
// non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
|
|
16
|
-
// lags at ACTIVE), while a throw
|
|
17
|
-
//
|
|
18
|
-
// never drift from the poll reconciler's.
|
|
16
|
+
// lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
|
|
17
|
+
// or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
|
|
18
|
+
// `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
|
|
19
19
|
import { errorMessage } from "../guards.js";
|
|
20
20
|
import { reconcileTerminatedKey } from "./instance-tracking.js";
|
|
21
21
|
/**
|
|
@@ -25,10 +25,14 @@ import { reconcileTerminatedKey } from "./instance-tracking.js";
|
|
|
25
25
|
* Outcome rules:
|
|
26
26
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
27
27
|
* 204); trust it even if the read model still lags at ACTIVE.
|
|
28
|
-
* - `cancelInstance` throws but the instance reads back
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
29
|
+
* → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
|
|
30
|
+
* row here (see below); a COMPLETED read intentionally reconciles nothing.
|
|
31
|
+
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
32
|
+
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
33
|
+
* instance) → an honest failure: report it and do NOT touch any row (the instance may still be
|
|
34
|
+
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
35
|
+
* termination, so success is never inferred from it.
|
|
32
36
|
*
|
|
33
37
|
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
34
38
|
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
@@ -43,11 +47,19 @@ export async function cancelInstanceReconciling(api, bindings, processInstanceKe
|
|
|
43
47
|
threw = error;
|
|
44
48
|
}
|
|
45
49
|
const state = await readState(api, key, threw);
|
|
46
|
-
if (threw && state
|
|
47
|
-
// The cancel
|
|
48
|
-
// terminal
|
|
49
|
-
|
|
50
|
+
if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
|
|
51
|
+
// The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
|
|
52
|
+
// confirm a terminal state. Two shapes land here, both honest failures:
|
|
53
|
+
// • state === "ACTIVE": the instance is demonstrably still running.
|
|
54
|
+
// • state === "gone": the read model has no record of the key. A throw makes this
|
|
55
|
+
// ambiguous — after an engine restart the query store can transiently lack a live instance
|
|
56
|
+
// before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
|
|
57
|
+
// Trust neither as success: claiming a cancel that may not have happened (while the run keeps
|
|
58
|
+
// executing) is the worst possible lie for a cancel button. Report ok:false so the caller
|
|
59
|
+
// surfaces it and retries; never flip a tracked row to terminal while the instance may live.
|
|
60
|
+
api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
|
|
50
61
|
processInstanceKey: key,
|
|
62
|
+
state,
|
|
51
63
|
error: errorMessage(threw),
|
|
52
64
|
});
|
|
53
65
|
return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
|
|
@@ -474,14 +474,22 @@ 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; }
|
|
481
483
|
.pc-msg.err { color:var(--nano-danger); }
|
|
482
484
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
483
485
|
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
|
|
484
|
-
|
|
486
|
+
/* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
|
|
487
|
+
cell can't force its column wide and blow the whole table past the page.
|
|
488
|
+
overflow-wrap:anywhere reduces the cell's min-content width so the auto table
|
|
489
|
+
layout can shrink it to fit; max-width caps a single column when there is
|
|
490
|
+
ample room. vertical-align:top keeps a multi-line cell aligned with its
|
|
491
|
+
single-line neighbours. */
|
|
492
|
+
table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; max-width:32rem; vertical-align:top; }
|
|
485
493
|
table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
486
494
|
.pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
|
|
487
495
|
.pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
|
|
@@ -493,6 +501,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
493
501
|
.pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
|
|
494
502
|
.pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
|
|
495
503
|
.pc-link { color:var(--nano-accent); }
|
|
504
|
+
.pc-badge { display:inline-flex; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; box-sizing:border-box; padding:0 .4rem; border-radius:999px; font-size:.72rem; font-weight:700; line-height:1; color:#fff; background:var(--nano-danger); }
|
|
505
|
+
.pc-badge-danger { background:var(--nano-danger); }
|
|
506
|
+
.pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
|
|
507
|
+
.pc-badge-ok { background:var(--nano-ok); }
|
|
508
|
+
.pc-badge-info { background:var(--nano-info); }
|
|
509
|
+
:root[data-appearance="light"] .pc-badge-warn { color:#fff; }
|
|
510
|
+
@media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
|
|
496
511
|
.pc-child { margin:.6rem 0; }
|
|
497
512
|
.pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
|
|
498
513
|
.pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
|
|
@@ -503,6 +518,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
503
518
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
504
519
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
505
520
|
.pc-card-body[hidden] { display:none; }
|
|
521
|
+
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
522
|
+
.pc-buttonrow { margin:1rem 0; }
|
|
523
|
+
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|
|
524
|
+
.pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
|
|
525
|
+
.pc-modal-overlay { position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:rgba(0,0,0,.55); }
|
|
526
|
+
.pc-modal { background:var(--nano-panel); border:1px solid var(--nano-edge-strong); border-radius:.6rem; padding:1.25rem 1.35rem; max-width:44rem; width:100%; max-height:85vh; overflow:auto; box-shadow:0 12px 40px rgba(0,0,0,.45); }
|
|
527
|
+
.pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
|
|
528
|
+
.pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
|
|
529
|
+
.pc-modal-code { white-space:pre-wrap; word-break:break-word; background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.75rem; font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; max-height:45vh; overflow:auto; margin:0 0 .85rem; color:var(--nano-text); }
|
|
530
|
+
.pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
|
|
506
531
|
/* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
|
|
507
532
|
.pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
|
|
508
533
|
.pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
|
|
@@ -559,6 +584,15 @@ function teardown() {
|
|
|
559
584
|
}
|
|
560
585
|
}
|
|
561
586
|
|
|
587
|
+
// Monotonic counter for per-modal element ids. aria-labelledby needs a unique
|
|
588
|
+
// title id: a fixed id would clash if the page already contains that id, and
|
|
589
|
+
// mis-associate the label for assistive tech.
|
|
590
|
+
let modalSeq = 0;
|
|
591
|
+
// At most one modal is open at a time. A double-click (or two rapid button
|
|
592
|
+
// clicks) would otherwise stack multiple overlays + document-level keydown
|
|
593
|
+
// listeners; this guard keeps a single dialog + handler live.
|
|
594
|
+
let modalOpen = false;
|
|
595
|
+
|
|
562
596
|
// Theme bridge for the Nano console embed. The app runs in a sandboxed,
|
|
563
597
|
// same-origin iframe, so the console's --nano-* custom properties and its
|
|
564
598
|
// data-appearance never cascade in. Instead the console postMessages its
|
|
@@ -712,11 +746,151 @@ function renderText(node) {
|
|
|
712
746
|
return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
|
|
713
747
|
}
|
|
714
748
|
|
|
715
|
-
//
|
|
716
|
-
//
|
|
749
|
+
// Copy text to the clipboard, resiliently. The async Clipboard API is tried
|
|
750
|
+
// first, but it can reject inside a sandboxed console iframe (no clipboard-write
|
|
751
|
+
// permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
|
|
752
|
+
// works under a user gesture with only allow-scripts. Returns whether a copy
|
|
753
|
+
// succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
|
|
754
|
+
async function copyToClipboard(text) {
|
|
755
|
+
try {
|
|
756
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
757
|
+
await navigator.clipboard.writeText(text);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
} catch (_e) { /* fall through to the execCommand path */ }
|
|
761
|
+
const ta = document.createElement("textarea");
|
|
762
|
+
try {
|
|
763
|
+
ta.value = text;
|
|
764
|
+
ta.setAttribute("readonly", "");
|
|
765
|
+
ta.style.position = "fixed";
|
|
766
|
+
ta.style.top = "-1000px";
|
|
767
|
+
ta.style.opacity = "0";
|
|
768
|
+
document.body.append(ta);
|
|
769
|
+
ta.select();
|
|
770
|
+
return document.execCommand("copy");
|
|
771
|
+
} catch (_e) { return false; }
|
|
772
|
+
// Always remove the hidden textarea, even if select()/execCommand threw, so a
|
|
773
|
+
// failed copy never leaves a detached node accreting in <body> on retry.
|
|
774
|
+
finally { ta.remove(); }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
|
|
778
|
+
// absolute mount root (always ends in "/"), so a prompt like
|
|
779
|
+
// "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
|
|
780
|
+
// the console proxy path when embedded, the app origin when standalone. No other
|
|
781
|
+
// interpolation happens: copy text is otherwise emitted verbatim.
|
|
782
|
+
function resolveCopyText(text) {
|
|
783
|
+
return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
|
|
787
|
+
// backdrop click, Escape, or a page switch; the keydown listener is removed on
|
|
788
|
+
// close so it never leaks, and close() is registered with teardown() so
|
|
789
|
+
// navigating away (hashchange → renderPage → teardown) tears down any open modal
|
|
790
|
+
// instead of leaving a stale overlay + document-level listener alive. When it
|
|
791
|
+
// carries copyText it renders a scrollable code block plus a Copy button (see
|
|
792
|
+
// copyToClipboard for the sandbox-safe fallback).
|
|
793
|
+
function openModal(m) {
|
|
794
|
+
// Only one modal at a time — drop a second (e.g. double-click) open so we
|
|
795
|
+
// never stack overlays or register duplicate document-level keydown handlers.
|
|
796
|
+
if (modalOpen) return;
|
|
797
|
+
modalOpen = true;
|
|
798
|
+
const overlay = el("div", { class: "pc-modal-overlay" });
|
|
799
|
+
// role="dialog" + aria-modal need an accessible name: label by the visible
|
|
800
|
+
// title when present, else fall back to a generic aria-label so screen readers
|
|
801
|
+
// don't announce an unnamed dialog. The title id is unique per instance so it
|
|
802
|
+
// can't collide with a pre-existing page id.
|
|
803
|
+
const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
|
|
804
|
+
const dialog = el("div", titleId
|
|
805
|
+
? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
|
|
806
|
+
: { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
|
|
807
|
+
if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
|
|
808
|
+
if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
|
|
809
|
+
const copyText = resolveCopyText(m.copyText);
|
|
810
|
+
if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
|
|
811
|
+
const actions = el("div", { class: "pc-modal-actions" });
|
|
812
|
+
// Remember what had focus so we can restore it on close — a keyboard/screen
|
|
813
|
+
// reader user returns to where they were instead of being dumped at <body>.
|
|
814
|
+
const prevFocus = document.activeElement;
|
|
815
|
+
let closed = false;
|
|
816
|
+
function close() {
|
|
817
|
+
if (closed) return;
|
|
818
|
+
closed = true;
|
|
819
|
+
modalOpen = false;
|
|
820
|
+
document.removeEventListener("keydown", onKey);
|
|
821
|
+
overlay.remove();
|
|
822
|
+
// A manual close (button / Escape / backdrop) never pops the disposers
|
|
823
|
+
// stack, so drop our own entry here — otherwise repeated open/close cycles
|
|
824
|
+
// accrete dead close() closures until the next page navigation runs teardown.
|
|
825
|
+
const i = disposers.indexOf(close);
|
|
826
|
+
if (i !== -1) disposers.splice(i, 1);
|
|
827
|
+
if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
|
|
828
|
+
}
|
|
829
|
+
// Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
|
|
830
|
+
function focusables() {
|
|
831
|
+
return Array.prototype.slice.call(dialog.querySelectorAll(
|
|
832
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
833
|
+
)).filter((n) => !n.disabled);
|
|
834
|
+
}
|
|
835
|
+
function onKey(ev) {
|
|
836
|
+
if (ev.key === "Escape") { close(); return; }
|
|
837
|
+
if (ev.key !== "Tab") return;
|
|
838
|
+
// Trap Tab within the dialog so focus can't slip behind the overlay onto the
|
|
839
|
+
// inert page underneath — that would break aria-modal semantics.
|
|
840
|
+
const f = focusables();
|
|
841
|
+
if (!f.length) return;
|
|
842
|
+
const first = f[0];
|
|
843
|
+
const last = f[f.length - 1];
|
|
844
|
+
if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
|
|
845
|
+
else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
|
|
846
|
+
}
|
|
847
|
+
if (copyText) {
|
|
848
|
+
const label = m.copyLabel || "Copy";
|
|
849
|
+
const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
|
|
850
|
+
copyBtn.addEventListener("click", async () => {
|
|
851
|
+
const ok = await copyToClipboard(copyText);
|
|
852
|
+
copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
|
|
853
|
+
setTimeout(() => { copyBtn.textContent = label; }, 2200);
|
|
854
|
+
});
|
|
855
|
+
actions.append(copyBtn);
|
|
856
|
+
}
|
|
857
|
+
const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
|
|
858
|
+
closeBtn.addEventListener("click", close);
|
|
859
|
+
actions.append(closeBtn);
|
|
860
|
+
dialog.append(actions);
|
|
861
|
+
overlay.append(dialog);
|
|
862
|
+
// A click on the backdrop (never the dialog itself) dismisses.
|
|
863
|
+
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
|
|
864
|
+
document.addEventListener("keydown", onKey);
|
|
865
|
+
document.body.append(overlay);
|
|
866
|
+
// A page switch runs teardown() before the next render — dispose the modal
|
|
867
|
+
// there too so navigating away never leaves a stale overlay + keydown listener.
|
|
868
|
+
disposers.push(close);
|
|
869
|
+
closeBtn.focus();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// A standalone button node. Clicking it opens props.modal — used e.g. to surface
|
|
873
|
+
// a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
|
|
874
|
+
// renders the muted outline style. A button without a modal is inert.
|
|
875
|
+
function renderButton(node) {
|
|
876
|
+
const p = node.props || {};
|
|
877
|
+
const btn = el(
|
|
878
|
+
"button",
|
|
879
|
+
{ class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
|
|
880
|
+
p.label || "Open",
|
|
881
|
+
);
|
|
882
|
+
const m = p.modal && typeof p.modal === "object" ? p.modal : null;
|
|
883
|
+
if (m) btn.addEventListener("click", () => openModal(m));
|
|
884
|
+
return el("div", { class: "pc-buttonrow" }, btn);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// A grid td cell with three column-declared rendering modes, checked in order:
|
|
888
|
+
// 1. badge — a compact status indicator, gated on a non-empty field value
|
|
889
|
+
// (see the detailed note at the check below).
|
|
890
|
+
// 2. linkField — the cell text becomes a link to the URL held in that other
|
|
717
891
|
// field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
|
|
718
892
|
// smuggled through row data falls back to plain text).
|
|
719
|
-
//
|
|
893
|
+
// 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
|
|
720
894
|
// link: the cell text links to the Nano console's explorer view for the
|
|
721
895
|
// process instance whose key is held in the row's keyField. The console
|
|
722
896
|
// path is constructed HERE (never taken from row data) and the key is
|
|
@@ -732,6 +906,27 @@ function renderText(node) {
|
|
|
732
906
|
// grid and child grids.
|
|
733
907
|
function gridCell(col, row) {
|
|
734
908
|
const text = row[col.field] == null ? "" : String(row[col.field]);
|
|
909
|
+
// 1. badge — a compact status indicator. When the row's field value is
|
|
910
|
+
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
911
|
+
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
912
|
+
// when empty the cell stays blank so the column is unobtrusive until it
|
|
913
|
+
// matters. tone (danger|warn|ok|info, default danger) picks the color and
|
|
914
|
+
// label the glyph inside (default "1"). Label/tone come from the app
|
|
915
|
+
// schema (trusted); the tooltip is row data set via title/textContent
|
|
916
|
+
// (DOM-escaped by the platform, so no HTML/attribute injection). The full
|
|
917
|
+
// field text is also mirrored into aria-label so assistive tech announces
|
|
918
|
+
// the incident/status text (the visible "1" glyph alone is not meaningful).
|
|
919
|
+
if (col.badge) {
|
|
920
|
+
if (text.trim() === "") return el("td", {});
|
|
921
|
+
const t = col.badge.tone;
|
|
922
|
+
const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
|
|
923
|
+
const label = col.badge.label == null ? "1" : String(col.badge.label);
|
|
924
|
+
return el(
|
|
925
|
+
"td",
|
|
926
|
+
{},
|
|
927
|
+
el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
735
930
|
if (col.linkField) {
|
|
736
931
|
const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
|
|
737
932
|
if (text !== "" && /^https?:\/\//i.test(href)) {
|
|
@@ -781,8 +976,24 @@ function renderActionForm(node) {
|
|
|
781
976
|
const inputs = new Map();
|
|
782
977
|
const fieldTypes = new Map();
|
|
783
978
|
for (const f of p.fields || []) {
|
|
784
|
-
const kind = f.type === "number" ? "number" : "text";
|
|
979
|
+
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
785
980
|
fieldTypes.set(f.key, kind);
|
|
981
|
+
if (kind === "checkbox") {
|
|
982
|
+
// A boolean field renders a real checkbox; its default checked state comes
|
|
983
|
+
// from default/checked on the field (unset -> unchecked). We record that on
|
|
984
|
+
// the input's defaultChecked so a post-submit reset can restore it without a
|
|
985
|
+
// side map that could drift from the actual input.
|
|
986
|
+
const checked = f.default === true || f.checked === true;
|
|
987
|
+
const input = el("input", { type: "checkbox" });
|
|
988
|
+
input.defaultChecked = checked;
|
|
989
|
+
input.checked = checked;
|
|
990
|
+
inputs.set(f.key, input);
|
|
991
|
+
// Input-first, wrapped in the label so the whole row is a click target.
|
|
992
|
+
card.append(
|
|
993
|
+
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
|
|
994
|
+
);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
786
997
|
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
787
998
|
if (kind === "number") {
|
|
788
999
|
attrs.inputmode = "numeric";
|
|
@@ -799,7 +1010,11 @@ function renderActionForm(node) {
|
|
|
799
1010
|
btn.addEventListener("click", async () => {
|
|
800
1011
|
const variables = Object.create(null);
|
|
801
1012
|
for (const [k, input] of inputs) {
|
|
802
|
-
if (fieldTypes.get(k) === "
|
|
1013
|
+
if (fieldTypes.get(k) === "checkbox") {
|
|
1014
|
+
// Always emit a real boolean (checked -> true / false), never a string,
|
|
1015
|
+
// so a strict equality check on the action side sees the intended value.
|
|
1016
|
+
variables[k] = input.checked === true;
|
|
1017
|
+
} else if (fieldTypes.get(k) === "number") {
|
|
803
1018
|
const t = String(input.value).trim();
|
|
804
1019
|
// Blank → omit so the action-side default applies; a non-finite parse
|
|
805
1020
|
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
@@ -822,7 +1037,12 @@ function renderActionForm(node) {
|
|
|
822
1037
|
msg.className = "pc-msg ok";
|
|
823
1038
|
msg.textContent = (p.action && p.action.successLabel) ||
|
|
824
1039
|
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
825
|
-
for (const input of inputs
|
|
1040
|
+
for (const [k, input] of inputs) {
|
|
1041
|
+
// Text/number inputs clear; a checkbox resets to its declared default,
|
|
1042
|
+
// read straight off the input's defaultChecked (no side map to drift).
|
|
1043
|
+
if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
|
|
1044
|
+
else input.value = "";
|
|
1045
|
+
}
|
|
826
1046
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
827
1047
|
} catch (e) {
|
|
828
1048
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -1201,7 +1421,7 @@ function renderNav(node) {
|
|
|
1201
1421
|
return nav;
|
|
1202
1422
|
}
|
|
1203
1423
|
|
|
1204
|
-
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
|
|
1424
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
|
|
1205
1425
|
|
|
1206
1426
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
1207
1427
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
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.43.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
69
|
-
"create-urban-app": "^0.
|
|
69
|
+
"create-urban-app": "^0.14.0",
|
|
70
70
|
"yaml": "^2.9.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
@@ -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
|
+
});
|