@nanobpm/urban 0.77.0 → 0.77.2
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/pages.js +45 -2
- package/dist/runtime/core/router.d.ts +14 -2
- package/dist/runtime/core/router.js +8 -2
- package/dist/toolkit/derivers/api.js +64 -13
- package/dist/toolkit/derivers/domain.js +10 -12
- package/dist/toolkit/derivers/messages.js +6 -7
- package/dist/toolkit/derivers/meta.js +10 -8
- package/dist/toolkit/derivers/worker-io.js +8 -9
- package/package.json +2 -2
- package/src/runtime/core/modules/agent.test.ts +6 -3
- package/src/runtime/core/modules/pages.test.ts +127 -0
- package/src/runtime/core/modules/pages.ts +44 -2
- package/src/runtime/core/modules/surfaces.test.ts +8 -4
- package/src/runtime/core/router.ts +21 -2
- package/src/toolkit/derivers/api.test.ts +28 -0
- package/src/toolkit/derivers/api.ts +61 -15
- package/src/toolkit/derivers/bindings-parity.test.ts +1 -1
- package/src/toolkit/derivers/domain.test.ts +1 -1
- package/src/toolkit/derivers/domain.ts +10 -12
- package/src/toolkit/derivers/generated-lint.test.ts +369 -0
- package/src/toolkit/derivers/messages.test.ts +2 -6
- package/src/toolkit/derivers/messages.ts +6 -7
- package/src/toolkit/derivers/meta.test.ts +13 -13
- package/src/toolkit/derivers/meta.ts +11 -8
- package/src/toolkit/derivers/worker-io.test.ts +1 -1
- package/src/toolkit/derivers/worker-io.ts +8 -9
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// GET / (+ /app/runtime.js) → the schema-driven browser renderer
|
|
12
12
|
// GET /app/pages/<id> → the page's page.json
|
|
13
13
|
// GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
|
|
14
|
-
// GET /{embed,standalone}.html /mount.js /cockpit.css → appView sidecars (from pagesDir, #420)
|
|
14
|
+
// GET /{embed,standalone}.html /mount.js /cockpit.css → flat appView sidecars (from pagesDir, #420)
|
|
15
|
+
// GET /<subdir>/<asset> → nested appView sidecars (pages/<subdir>/…, #442)
|
|
15
16
|
// GET /dist/<path> → the app dist/ bundle the sidecars reference
|
|
16
17
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
17
18
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
@@ -45,7 +46,10 @@ function javascript(body, status = 200, extraHeaders) {
|
|
|
45
46
|
// document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
|
|
46
47
|
// `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
|
|
47
48
|
// iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
|
|
48
|
-
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`.
|
|
49
|
+
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. An app that ships
|
|
50
|
+
// MORE than one `appView` gives each its own SUBDIR sidecar (`./cockpit/embed.html`,
|
|
51
|
+
// `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
|
|
52
|
+
// this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
|
|
49
53
|
// import-map references is served separately under the `/dist/` prefix below.
|
|
50
54
|
const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"];
|
|
51
55
|
// The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
|
|
@@ -485,6 +489,45 @@ export function createPagesRoutes(opts, deps) {
|
|
|
485
489
|
}
|
|
486
490
|
},
|
|
487
491
|
});
|
|
492
|
+
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
493
|
+
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
494
|
+
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
495
|
+
// `./cockpit/embed.html`, `pages/board.page.json` → `./board/embed.html`. The renderer's
|
|
496
|
+
// `baseRelativeUrl(p.embed)` rebases those onto the mount root, so the browser fetches
|
|
497
|
+
// `/cockpit/embed.html`, `/board/mount.js`, … — every one of which #420's flat 4-name
|
|
498
|
+
// serve 404'd. Serve the `pagesDir` as a TREE, mapping `/<subpath>` onto
|
|
499
|
+
// `pagesDir/<subpath>` with the SAME traversal guard as the `/dist/` route (mirroring it).
|
|
500
|
+
// It is a `/`-prefix route, so to avoid a root catch-all silently swallowing `/healthz`,
|
|
501
|
+
// the app's own trigger routes, or any other route, the handler DECLINES (returns
|
|
502
|
+
// undefined → the router falls through to the next route, see `RouteHandler`) whenever it
|
|
503
|
+
// cannot serve a real nested asset:
|
|
504
|
+
// • a bare single-segment path (`/healthz`, the flat sidecars) — owned by its own exact
|
|
505
|
+
// route / the runtime's liveness route, never this tree;
|
|
506
|
+
// • an unsafe subpath (`..`/absolute/percent-escaped/backslash) — traversal-rejected
|
|
507
|
+
// before it reaches the filesystem;
|
|
508
|
+
// • a miss (no such file) — so a same-path route registered AFTER the pages surface (a
|
|
509
|
+
// trigger, the appended `/healthz`) still resolves instead of being masked by a 404.
|
|
510
|
+
// Registered LAST among the pages routes so every exact/prefix pages route above wins first.
|
|
511
|
+
routes.push({
|
|
512
|
+
method: "GET",
|
|
513
|
+
path: "/",
|
|
514
|
+
prefix: true,
|
|
515
|
+
source: "surface:pages",
|
|
516
|
+
handler: async (req) => {
|
|
517
|
+
const rest = req.path.slice(1); // drop the leading "/"
|
|
518
|
+
// Only a SUBDIR asset (has a `/`) belongs to this tree; a single-segment path is left
|
|
519
|
+
// to its own exact route / the appended `/healthz` liveness route.
|
|
520
|
+
if (!rest.includes("/"))
|
|
521
|
+
return undefined;
|
|
522
|
+
const safe = safeAssetSubpath(rest);
|
|
523
|
+
if (safe === null)
|
|
524
|
+
return undefined; // traversal/escape → decline (never touch the FS)
|
|
525
|
+
const res = await serveAsset(readAsset, `${pagesDir}/${safe}`);
|
|
526
|
+
// A miss declines rather than 404s, so a later same-path route (e.g. a trigger) can
|
|
527
|
+
// still claim it; the router's own default 404 covers a genuinely absent asset.
|
|
528
|
+
return res.status === 404 ? undefined : res;
|
|
529
|
+
},
|
|
530
|
+
});
|
|
488
531
|
return routes;
|
|
489
532
|
}
|
|
490
533
|
/**
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import type { HttpHandler, HttpResponse } from "./host.ts";
|
|
1
|
+
import type { HttpHandler, HttpRequest, HttpResponse } from "./host.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A route handler that may DECLINE a matched request by returning `undefined`
|
|
4
|
+
* (or a promise of it). The router then treats the route as if it had not matched
|
|
5
|
+
* and continues to the next route (fall-through). This lets a broad prefix route —
|
|
6
|
+
* e.g. the pages surface's nested static serve at `/` — claim only the requests it
|
|
7
|
+
* can actually satisfy (a real, safe asset) and let everything else (`/healthz`, an
|
|
8
|
+
* app's own trigger routes registered *after* it) resolve to their own handlers,
|
|
9
|
+
* instead of a root catch-all silently swallowing them. A handler that returns a
|
|
10
|
+
* concrete `HttpResponse` (including a 404) still short-circuits as before, so this
|
|
11
|
+
* is backwards-compatible with every existing handler (none return `undefined`).
|
|
12
|
+
*/
|
|
13
|
+
export type RouteHandler = (req: HttpRequest) => Promise<HttpResponse | undefined> | HttpResponse | undefined;
|
|
2
14
|
export interface Route {
|
|
3
15
|
method: string;
|
|
4
16
|
path: string;
|
|
5
17
|
/** When true, match any path that starts with `path`. */
|
|
6
18
|
prefix?: boolean;
|
|
7
|
-
handler:
|
|
19
|
+
handler: RouteHandler;
|
|
8
20
|
/** For diagnostics / inspect(). */
|
|
9
21
|
source?: string;
|
|
10
22
|
}
|
|
@@ -43,8 +43,14 @@ function matches(route, req) {
|
|
|
43
43
|
export function makeRouter(routes) {
|
|
44
44
|
return async (req) => {
|
|
45
45
|
for (const route of routes) {
|
|
46
|
-
if (matches(route, req))
|
|
47
|
-
|
|
46
|
+
if (matches(route, req)) {
|
|
47
|
+
// A handler may DECLINE by returning `undefined` (see RouteHandler): treat the
|
|
48
|
+
// route as unmatched and keep looking, so a broad prefix serve can't shadow a
|
|
49
|
+
// later exact route (`/healthz`, a trigger) it didn't actually satisfy.
|
|
50
|
+
const res = await route.handler(req);
|
|
51
|
+
if (res !== undefined)
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
48
54
|
}
|
|
49
55
|
return text("not found", 404);
|
|
50
56
|
};
|
|
@@ -17,6 +17,48 @@ export const API_BINDINGS_TS = "operations.ts";
|
|
|
17
17
|
* `tsc` (ADR 0059). The runtime dispatches through this map instead of per-op path imports. */
|
|
18
18
|
export const API_CONTROLLER_TS = "controller.ts";
|
|
19
19
|
export { DEFAULT_OPERATIONS_DIR };
|
|
20
|
+
/** Order two import source specifiers the way Biome's `organizeImports` assist does, so the
|
|
21
|
+
* controller emits its delegate imports in Biome's canonical order and the generated file passes
|
|
22
|
+
* `biome check` without a post-generation import sort. Biome's collation is, per character:
|
|
23
|
+
* digit-runs compared numerically (`item2` before `item10`), letters compared case-insensitively
|
|
24
|
+
* as the primary key, and — when two characters are the same letter differing only in case — the
|
|
25
|
+
* uppercase one sorts first as an immediate tiebreak (`getAgentInstructions` before
|
|
26
|
+
* `getAgenticRegistry` before `getAgentSkill`). */
|
|
27
|
+
function naturalCompare(a, b) {
|
|
28
|
+
const tokens = (s) => s.match(/\d+|\D/g) ?? [];
|
|
29
|
+
const isDigitRun = (t) => t.charCodeAt(0) >= 48 && t.charCodeAt(0) <= 57;
|
|
30
|
+
const ax = tokens(a);
|
|
31
|
+
const bx = tokens(b);
|
|
32
|
+
const n = Math.min(ax.length, bx.length);
|
|
33
|
+
for (let i = 0; i < n; i++) {
|
|
34
|
+
const ca = ax[i];
|
|
35
|
+
const cb = bx[i];
|
|
36
|
+
if (ca === undefined || cb === undefined)
|
|
37
|
+
break;
|
|
38
|
+
if (isDigitRun(ca) && isDigitRun(cb)) {
|
|
39
|
+
// Compare digit runs by numeric value *without* floating-point loss: strip leading zeros,
|
|
40
|
+
// then a longer run is the larger number, else compare the equal-length digits lexically.
|
|
41
|
+
// (`Number(ca) - Number(cb)` collapses 17+-digit runs to the same IEEE-754 value and yields
|
|
42
|
+
// `NaN` beyond `Number.MAX_SAFE_INTEGER`, both of which break the total order `sort` needs.)
|
|
43
|
+
const na = ca.replace(/^0+/, "");
|
|
44
|
+
const nb = cb.replace(/^0+/, "");
|
|
45
|
+
if (na.length !== nb.length)
|
|
46
|
+
return na.length < nb.length ? -1 : 1;
|
|
47
|
+
if (na !== nb)
|
|
48
|
+
return na < nb ? -1 : 1;
|
|
49
|
+
if (ca !== cb)
|
|
50
|
+
return ca < cb ? -1 : 1; // equal value, differing leading zeros
|
|
51
|
+
}
|
|
52
|
+
else if (ca !== cb) {
|
|
53
|
+
const la = ca.toLowerCase();
|
|
54
|
+
const lb = cb.toLowerCase();
|
|
55
|
+
if (la !== lb)
|
|
56
|
+
return la < lb ? -1 : 1; // case-insensitive primary key
|
|
57
|
+
return ca < cb ? -1 : 1; // same letter, different case → uppercase (lower code point) first
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ax.length - bx.length;
|
|
61
|
+
}
|
|
20
62
|
/** A TS identifier form of an operationId (used for the per-op type names). */
|
|
21
63
|
function typeStem(operationId) {
|
|
22
64
|
const cleaned = operationId.replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
@@ -139,7 +181,7 @@ function objectToTs(schema, depth) {
|
|
|
139
181
|
function paramsTs(op, where, ejected) {
|
|
140
182
|
const ps = op.parameters.filter((p) => p.in === where);
|
|
141
183
|
if (ps.length === 0)
|
|
142
|
-
return "
|
|
184
|
+
return "Record<string, never>";
|
|
143
185
|
const rawWire = where === "query" ? "string | string[]" : "string";
|
|
144
186
|
const fields = ps.map((p) => {
|
|
145
187
|
// Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
|
|
@@ -188,8 +230,7 @@ export function emitApiBindings(doc, surfaceEject = false) {
|
|
|
188
230
|
"// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
|
|
189
231
|
"// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
|
|
190
232
|
"// spec. Do not edit — regenerated from\n" +
|
|
191
|
-
"// the spec; erased to plain JS at compile.\n"
|
|
192
|
-
"// eslint-disable\n\n";
|
|
233
|
+
"// the spec; erased to plain JS at compile.\n\n";
|
|
193
234
|
const componentDecls = Object.entries(components)
|
|
194
235
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
195
236
|
.map(([name, schema]) => `export type ${typeStem(name)} = ${schemaToTs(schema)};`)
|
|
@@ -242,13 +283,19 @@ export function emitApiBindings(doc, surfaceEject = false) {
|
|
|
242
283
|
return ` ${JSON.stringify(op.operationId)}: { request: ${stem}Request; response: ${stem}Response };`;
|
|
243
284
|
})
|
|
244
285
|
.join("\n");
|
|
286
|
+
// With generated `.ts` now linted in the scaffold, an empty operation set must NOT emit an empty
|
|
287
|
+
// `interface ApiOperations {}` (`suspicious/noEmptyInterface`); fall back to the same non-empty-safe
|
|
288
|
+
// `Record<never, never>` used elsewhere (its `keyof` is `never`, matching `ApiOperationId = never`).
|
|
289
|
+
const apiOperations = ops.length > 0
|
|
290
|
+
? `export interface ApiOperations {\n${mapEntries}\n}\n`
|
|
291
|
+
: `export type ApiOperations = Record<never, never>;\n`;
|
|
245
292
|
return (header +
|
|
246
293
|
(componentDecls ? `${componentDecls}\n\n` : "") +
|
|
247
294
|
(opDecls ? `${opDecls}\n\n` : "") +
|
|
248
295
|
`/** Every declared operationId (ADR 0058): the delegate key set. */\n` +
|
|
249
296
|
`export type ApiOperationId = ${idUnion};\n\n` +
|
|
250
297
|
`/** Request/response contract per operationId. */\n` +
|
|
251
|
-
|
|
298
|
+
apiOperations);
|
|
252
299
|
}
|
|
253
300
|
/** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
|
|
254
301
|
* generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
|
|
@@ -257,10 +304,9 @@ export function emitApiBindingsRuntime() {
|
|
|
257
304
|
return ("// AUTO-GENERATED by @nanobpm/urban (ADR 0058): the typed `defineOperation`.\n" +
|
|
258
305
|
"// Re-exports the operation SDK and overrides `defineOperation` with an operationId-keyed\n" +
|
|
259
306
|
"// signature (the delegate's validated request + result typed from the spec). Erased to a\n" +
|
|
260
|
-
"// pass-through at runtime. Do not edit.\n" +
|
|
261
|
-
"// eslint-disable\n\n" +
|
|
262
|
-
`import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
|
|
307
|
+
"// pass-through at runtime. Do not edit.\n\n" +
|
|
263
308
|
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
309
|
+
`import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
|
|
264
310
|
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n\n` +
|
|
265
311
|
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
266
312
|
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
@@ -303,17 +349,22 @@ export function emitApiController(doc, operationsDir = DEFAULT_OPERATIONS_DIR) {
|
|
|
303
349
|
"// registry. Statically imports every operation delegate and asserts the complete, correctly-\n" +
|
|
304
350
|
"// typed set via `satisfies` — a missing, mismatched, or orphan delegate fails `tsc`. The\n" +
|
|
305
351
|
"// runtime dispatches through this map, so endpoints cannot drift from the spec. Do not edit —\n" +
|
|
306
|
-
"// regenerated from the spec.\n"
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
352
|
+
"// regenerated from the spec.\n\n";
|
|
353
|
+
// Emit imports in Biome's canonical `organizeImports` order so the generated file is lint-clean
|
|
354
|
+
// without a post-gen fixup: the bare package import first, then the delegate imports sorted by
|
|
355
|
+
// source specifier (Biome's natural, numeric-aware collation), then the relative `./api-io.d.ts`
|
|
356
|
+
// type import last. Alias bindings (`op<i>`) stay tied to the original index so the `operations`
|
|
357
|
+
// map below still resolves each operationId to its delegate regardless of import line order.
|
|
310
358
|
const delegateImports = ops
|
|
311
|
-
.map((op, i) =>
|
|
359
|
+
.map((op, i) => ({ i, spec: `../${dir}/${op.operationId}.ts` }))
|
|
360
|
+
.sort((a, b) => naturalCompare(a.spec, b.spec))
|
|
361
|
+
.map(({ i, spec }) => `import op${i} from ${JSON.stringify(spec)};`)
|
|
312
362
|
.join("\n");
|
|
313
363
|
const mapEntries = ops.map((op, i) => ` ${JSON.stringify(op.operationId)}: op${i},`).join("\n");
|
|
314
364
|
return (header +
|
|
315
|
-
|
|
365
|
+
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
316
366
|
(delegateImports ? `${delegateImports}\n` : "") +
|
|
367
|
+
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n` +
|
|
317
368
|
`\n` +
|
|
318
369
|
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
319
370
|
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
@@ -83,10 +83,9 @@ function tableInterface(t) {
|
|
|
83
83
|
*/
|
|
84
84
|
export function emitDomainDts(tables) {
|
|
85
85
|
const header = "// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\n" +
|
|
86
|
-
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n"
|
|
87
|
-
"// eslint-disable\n";
|
|
86
|
+
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n";
|
|
88
87
|
if (tables.length === 0) {
|
|
89
|
-
return `${header}\nexport
|
|
88
|
+
return `${header}\nexport type DomainTables = Record<never, never>;\n`;
|
|
90
89
|
}
|
|
91
90
|
const interfaces = tables.map(tableInterface).join("\n\n");
|
|
92
91
|
const map = tables
|
|
@@ -113,15 +112,14 @@ export function emitDomainDtsForSources(sources, defaultSource) {
|
|
|
113
112
|
return emitDomainDts(sources[0]?.tables ?? []);
|
|
114
113
|
}
|
|
115
114
|
const header = "// AUTO-GENERATED by nanobpmn from the datasource schemas (ADR 0029 §4.1/§6).\n" +
|
|
116
|
-
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n"
|
|
117
|
-
"// eslint-disable\n";
|
|
115
|
+
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n";
|
|
118
116
|
const interfaces = sources
|
|
119
117
|
.flatMap((s) => s.tables.map((t) => tableInterfaceNamed(prefixedInterfaceName(s.source, t.name), t)))
|
|
120
118
|
.join("\n\n");
|
|
121
119
|
const sourceMap = sources
|
|
122
120
|
.map((s) => {
|
|
123
121
|
if (s.tables.length === 0)
|
|
124
|
-
return ` ${JSON.stringify(s.source)}:
|
|
122
|
+
return ` ${JSON.stringify(s.source)}: Record<never, never>;`;
|
|
125
123
|
const tableMap = s.tables
|
|
126
124
|
.map((t) => ` ${JSON.stringify(t.name)}: ${prefixedInterfaceName(s.source, t.name)};`)
|
|
127
125
|
.join("\n");
|
|
@@ -181,7 +179,7 @@ export function emitDomainTypeRegistry(types) {
|
|
|
181
179
|
return ` ${key}${opt}: ${fieldTsType(field, ids)};`;
|
|
182
180
|
})
|
|
183
181
|
.join("\n");
|
|
184
|
-
const body = fields.length > 0 ? `{\n${fields}\n }` : "
|
|
182
|
+
const body = fields.length > 0 ? `{\n${fields}\n }` : "Record<never, never>";
|
|
185
183
|
return ` ${JSON.stringify(id)}: ${body};`;
|
|
186
184
|
})
|
|
187
185
|
.join("\n");
|
|
@@ -233,8 +231,7 @@ export function emitDomainBindings(sources, defaultSource) {
|
|
|
233
231
|
const header = "// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\n" +
|
|
234
232
|
"// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\n" +
|
|
235
233
|
"// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\n" +
|
|
236
|
-
"// regenerated from the live tables. Erased to plain JS at `deno compile`.\n"
|
|
237
|
-
"// eslint-disable\n";
|
|
234
|
+
"// regenerated from the live tables. Erased to plain JS at `deno compile`.\n";
|
|
238
235
|
const multi = sources.length > 1;
|
|
239
236
|
const def = defaultSource && sources.some((s) => s.source === defaultSource)
|
|
240
237
|
? defaultSource
|
|
@@ -279,7 +276,7 @@ export function emitDomainBindings(sources, defaultSource) {
|
|
|
279
276
|
(binds ? `${binds}\n` : "") +
|
|
280
277
|
` close: () => raw.close(),\n };\n}`;
|
|
281
278
|
return `${header}\n` +
|
|
282
|
-
`import {
|
|
279
|
+
`import { type DataSource, openDataSource, type Table } from "./data-sdk.ts";\n` +
|
|
283
280
|
importTypes +
|
|
284
281
|
`\n${domainIface}\n\n${openFn}\n\nexport type { Table };\n`;
|
|
285
282
|
}
|
|
@@ -323,13 +320,14 @@ function emitKeyedDomainBindings(header, sources, def) {
|
|
|
323
320
|
`export async function openDomain<K extends DomainSource = ${JSON.stringify(def)}>(\n` +
|
|
324
321
|
` source?: K,\n` +
|
|
325
322
|
`): Promise<Domain<DomainSources[K]>> {\n` +
|
|
326
|
-
` const key =
|
|
323
|
+
` const key = source ?? DEFAULT_SOURCE;\n` +
|
|
327
324
|
` const raw = await openDataSource(key);\n` +
|
|
328
325
|
` const db: Record<string, unknown> = { raw, close: () => raw.close() };\n` +
|
|
329
326
|
` for (const t of DOMAIN_TABLES[key] ?? []) db[t.name] = raw.table(t.name, t.pk);\n` +
|
|
327
|
+
` // biome-ignore lint/plugin: the per-source table set is bound dynamically from DOMAIN_TABLES; the Domain<M> shape can't be expressed statically over the runtime loop.\n` +
|
|
330
328
|
` return db as Domain<DomainSources[K]>;\n}`;
|
|
331
329
|
return `${header}\n` +
|
|
332
|
-
`import {
|
|
330
|
+
`import { type DataSource, openDataSource, type Table } from "./data-sdk.ts";\n` +
|
|
333
331
|
`import type { DomainSources } from "./${DOMAIN_DTS}";\n\n` +
|
|
334
332
|
`${runtimeMap}\n\n${types}\n\n${openFn}\n\nexport type { Table };\n`;
|
|
335
333
|
}
|
|
@@ -79,15 +79,14 @@ export function emitMessageBindings(messages, declaredTypeIds) {
|
|
|
79
79
|
"// The bridge from a `bpmn:message`'s data envelope to the message type system:\n" +
|
|
80
80
|
"// each named message maps to the TS type of its payload, so the typed\n" +
|
|
81
81
|
"// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
|
|
82
|
-
"// from the model. Erased to plain JS at compile.\n"
|
|
83
|
-
"// eslint-disable\n";
|
|
82
|
+
"// from the model. Erased to plain JS at compile.\n";
|
|
84
83
|
const needsRegistry = payloads.length > 0;
|
|
85
84
|
const importTypes = needsRegistry
|
|
86
85
|
? `import type { DomainTypes } from "./${DOMAIN_DTS}";\n`
|
|
87
86
|
: "";
|
|
88
87
|
const payloadsIface = payloads.length > 0
|
|
89
88
|
? `export interface MessagePayloads {\n${payloads.join("\n")}\n}\n`
|
|
90
|
-
: `export
|
|
89
|
+
: `export type MessagePayloads = Record<never, never>;\n`;
|
|
91
90
|
return `${header}\n` +
|
|
92
91
|
importTypes +
|
|
93
92
|
`\n/** Untyped fallback payload for a message that declares no envelope type. */\n` +
|
|
@@ -149,11 +148,10 @@ export function emitMessageBindingsRuntime() {
|
|
|
149
148
|
return "// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\n" +
|
|
150
149
|
"// Re-exports the worker SDK's `publishMessage` and overrides it with a\n" +
|
|
151
150
|
"// messageName-keyed typed signature (variables typed from the message's declared\n" +
|
|
152
|
-
"// envelope). Erased to a pass-through at runtime. Do not edit.\n" +
|
|
153
|
-
"
|
|
154
|
-
`import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n` +
|
|
151
|
+
"// envelope). Erased to a pass-through at runtime. Do not edit.\n\n" +
|
|
152
|
+
`import type { MessageName, MessagePayloads, MessageVars } from "./${MESSAGE_BINDINGS_DTS}";\n` +
|
|
155
153
|
`import type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n` +
|
|
156
|
-
`import
|
|
154
|
+
`import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n\n` +
|
|
157
155
|
`export type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n\n` +
|
|
158
156
|
`type PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\n\n` +
|
|
159
157
|
`/**\n` +
|
|
@@ -166,6 +164,7 @@ export function emitMessageBindingsRuntime() {
|
|
|
166
164
|
` name: K,\n` +
|
|
167
165
|
` opts?: PublishMessageOptions<PayloadFor<K> & object>,\n` +
|
|
168
166
|
`): Promise<PublishMessageResult> {\n` +
|
|
167
|
+
` // biome-ignore lint/plugin: generic-to-raw erasure at the message-SDK boundary; the wire stays untyped JSON (ADR 0040 slice 2).\n` +
|
|
169
168
|
` return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\n` +
|
|
170
169
|
`}\n`;
|
|
171
170
|
}
|
|
@@ -67,23 +67,25 @@ export function emitMeta(metas) {
|
|
|
67
67
|
const header = "// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 §5): the typed\n" +
|
|
68
68
|
"// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
|
|
69
69
|
"// a checked `AppMeta` property, so `meta(key)` is typed and app-wide queryable\n" +
|
|
70
|
-
"// without a second store. Do not edit — regenerated from the model.\n"
|
|
71
|
-
"// eslint-disable\n\n";
|
|
70
|
+
"// without a second store. Do not edit — regenerated from the model.\n\n";
|
|
72
71
|
const iface = keys.length > 0
|
|
73
72
|
? `export interface AppMeta {\n${keys.map((k) => ` ${propKey(k)}: string;`).join("\n")}\n}\n`
|
|
74
|
-
: `export
|
|
73
|
+
: `export type AppMeta = Record<never, never>;\n`;
|
|
74
|
+
// Dot-access satisfies Biome's `useLiteralKeys`, but `__proto__` must stay a computed key: dot
|
|
75
|
+
// access would trip `noProto` and (off a null-prototype dict) change semantics.
|
|
76
|
+
const assignTarget = (k) => isIdent(k) && k !== "__proto__" ? `m.${k}` : `m[${JSON.stringify(k)}]`;
|
|
75
77
|
const constDecl = keys.length > 0
|
|
76
78
|
? `export const appMeta: AppMeta = (() => {\n` +
|
|
77
|
-
` const m:
|
|
78
|
-
keys.map((k) => `
|
|
79
|
-
`\n return m
|
|
80
|
-
: `export const appMeta: AppMeta = Object.create(null)
|
|
79
|
+
` const m: AppMeta = Object.create(null);\n` +
|
|
80
|
+
keys.map((k) => ` ${assignTarget(k)} = ${JSON.stringify(folded[k])};`).join("\n") +
|
|
81
|
+
`\n return m;\n})();\n`
|
|
82
|
+
: `export const appMeta: AppMeta = Object.create(null);\n`;
|
|
81
83
|
const accessor = `\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n` +
|
|
82
84
|
` * returns \`string | undefined\`. */\n` +
|
|
83
85
|
`export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n` +
|
|
84
86
|
`export function meta(key: string): string | undefined;\n` +
|
|
85
87
|
`export function meta(key: string): string | undefined {\n` +
|
|
86
|
-
` return (appMeta
|
|
88
|
+
` return Reflect.get(appMeta, key);\n` +
|
|
87
89
|
`}\n`;
|
|
88
90
|
return `${header}/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n` +
|
|
89
91
|
iface + "\n" + constDecl + accessor;
|
|
@@ -143,21 +143,20 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
143
143
|
"// The bridge from the process model to the worker type system: each declared\n" +
|
|
144
144
|
"// worker's `taskType` maps to the TS type of its input payload (`job.variables`)\n" +
|
|
145
145
|
"// and result, so the typed `defineWorker` types a handler by its job type. Do\n" +
|
|
146
|
-
"// not edit — regenerated from the manifest. Erased to plain JS at compile.\n"
|
|
147
|
-
"// eslint-disable\n";
|
|
146
|
+
"// not edit — regenerated from the manifest. Erased to plain JS at compile.\n";
|
|
148
147
|
const needsRegistry = inputs.length > 0 || outputs.length > 0;
|
|
149
148
|
const importTypes = needsRegistry
|
|
150
149
|
? `import type { DomainTypes } from "./${DOMAIN_DTS}";\n`
|
|
151
150
|
: "";
|
|
152
151
|
const inputsIface = inputs.length > 0
|
|
153
152
|
? `export interface WorkerInputs {\n${inputs.join("\n")}\n}\n`
|
|
154
|
-
: `export
|
|
153
|
+
: `export type WorkerInputs = Record<never, never>;\n`;
|
|
155
154
|
const outputsIface = outputs.length > 0
|
|
156
155
|
? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
|
|
157
|
-
: `export
|
|
156
|
+
: `export type WorkerOutputs = Record<never, never>;\n`;
|
|
158
157
|
const headersIface = headers.length > 0
|
|
159
158
|
? `export interface WorkerHeaders {\n${headers.join("\n")}\n}\n`
|
|
160
|
-
: `export
|
|
159
|
+
: `export type WorkerHeaders = Record<never, never>;\n`;
|
|
161
160
|
return `${header}\n` +
|
|
162
161
|
importTypes +
|
|
163
162
|
`\n/** Untyped fallback for a job whose worker declares no input/output type. */\n` +
|
|
@@ -315,11 +314,10 @@ export function emitWorkerBindingsRuntime() {
|
|
|
315
314
|
"// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\n" +
|
|
316
315
|
"// typed signature (job.variables, job.customHeaders + result typed from the\n" +
|
|
317
316
|
"// worker's declared input/output/header contract). Erased to a pass-through at\n" +
|
|
318
|
-
"// runtime. Do not edit.\n" +
|
|
319
|
-
"
|
|
320
|
-
`import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n` +
|
|
317
|
+
"// runtime. Do not edit.\n\n" +
|
|
318
|
+
`import type { WorkerHdrs, WorkerHeaders, WorkerInputs, WorkerOutputs, WorkerTaskType, WorkerVars } from "./${WORKER_BINDINGS_DTS}";\n` +
|
|
321
319
|
`import type { WorkerOptions } from "./worker-sdk.ts";\n` +
|
|
322
|
-
`import
|
|
320
|
+
`import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n\n` +
|
|
323
321
|
`export * from "./worker-sdk.ts";\n\n` +
|
|
324
322
|
`type InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\n` +
|
|
325
323
|
`type OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\n` +
|
|
@@ -334,6 +332,7 @@ export function emitWorkerBindingsRuntime() {
|
|
|
334
332
|
`export function defineWorker<K extends WorkerTaskType>(\n` +
|
|
335
333
|
` opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\n` +
|
|
336
334
|
`): void {\n` +
|
|
335
|
+
` // biome-ignore lint/plugin: generic-to-raw erasure at the worker-SDK boundary; the wire stays untyped JSON (ADR 0033 §3).\n` +
|
|
337
336
|
` defineWorkerRaw(opts as unknown as WorkerOptions);\n` +
|
|
338
337
|
`}\n`;
|
|
339
338
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.2",
|
|
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",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@nanobpm/nano-sdk": "^1.2.5",
|
|
104
104
|
"@nanobpm/workflow": "^0.14.0",
|
|
105
105
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
106
|
-
"create-urban-app": "^0.15.
|
|
106
|
+
"create-urban-app": "^0.15.1",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
@@ -5,7 +5,8 @@ import { test } from "node:test";
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { mountAgent } from "./agent.ts";
|
|
7
7
|
import type { RuntimeContext } from "../context.ts";
|
|
8
|
-
import type { EngineClient, HostContext,
|
|
8
|
+
import type { EngineClient, HostContext, HttpResponse } from "../host.ts";
|
|
9
|
+
import type { Route } from "../router.ts";
|
|
9
10
|
|
|
10
11
|
const fakeEngine: EngineClient = {
|
|
11
12
|
deployResources: async () => ({ deployed: 0 }),
|
|
@@ -44,14 +45,16 @@ function ctxWithFiles(files: Record<string, string>): RuntimeContext {
|
|
|
44
45
|
return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App" }, host, engine: fakeEngine };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
async function call(route:
|
|
48
|
-
|
|
48
|
+
async function call(route: Route): Promise<HttpResponse> {
|
|
49
|
+
const res = await route.handler({
|
|
49
50
|
method: "GET",
|
|
50
51
|
path: "/",
|
|
51
52
|
query: new URLSearchParams(),
|
|
52
53
|
headers: new Headers(),
|
|
53
54
|
text: async () => "",
|
|
54
55
|
});
|
|
56
|
+
assert.ok(res, "handler should not decline");
|
|
57
|
+
return res;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
const routeAt = (ctx: RuntimeContext, path: string) => {
|
|
@@ -1823,3 +1823,130 @@ test("the static asset routes do not shadow the app's own routes or /healthz", a
|
|
|
1823
1823
|
const home = await router(req("GET", "/"));
|
|
1824
1824
|
assert.match(home.body ?? "", /data-home="home"/);
|
|
1825
1825
|
});
|
|
1826
|
+
|
|
1827
|
+
// ── nested (multi-appView) sidecar serving (#442) ──────────────────────────
|
|
1828
|
+
// An app with two-or-more appViews ships each view's sidecar under its own subdir
|
|
1829
|
+
// (`./cockpit/embed.html`, `./board/embed.html`); #420's flat 4-name serve 404'd every
|
|
1830
|
+
// one. These pin the generalized nested prefix serve of `pages/<subpath>` (and that it
|
|
1831
|
+
// still declines — never a root catch-all — so `/healthz` / triggers keep resolving).
|
|
1832
|
+
|
|
1833
|
+
test("GET /<subdir>/embed.html serves the nested appView sidecar", async () => {
|
|
1834
|
+
const router = buildWithAssets({
|
|
1835
|
+
"pages/cockpit/embed.html": "<!doctype html><title>cockpit</title>",
|
|
1836
|
+
"pages/board/embed.html": "<!doctype html><title>board</title>",
|
|
1837
|
+
});
|
|
1838
|
+
const cockpit = await router(req("GET", "/cockpit/embed.html"));
|
|
1839
|
+
assert.equal(cockpit.status, 200);
|
|
1840
|
+
assert.match(cockpit.headers?.["content-type"] ?? "", /text\/html/);
|
|
1841
|
+
assert.match(cockpit.body ?? "", /<title>cockpit<\/title>/);
|
|
1842
|
+
const board = await router(req("GET", "/board/embed.html"));
|
|
1843
|
+
assert.equal(board.status, 200);
|
|
1844
|
+
assert.match(board.body ?? "", /<title>board<\/title>/);
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
test("all nested subdir sidecar assets are served with correct content types", async () => {
|
|
1848
|
+
const router = buildWithAssets({
|
|
1849
|
+
"pages/cockpit/embed.html": "<html>embed</html>",
|
|
1850
|
+
"pages/cockpit/standalone.html": "<html>standalone</html>",
|
|
1851
|
+
"pages/cockpit/mount.js": "export const mountCockpit = () => {};",
|
|
1852
|
+
"pages/cockpit/cockpit.css": "body{margin:0}",
|
|
1853
|
+
"pages/delivery-graphs/embed.html": "<html>dg</html>",
|
|
1854
|
+
});
|
|
1855
|
+
const cases: [string, RegExp][] = [
|
|
1856
|
+
["/cockpit/embed.html", /text\/html/],
|
|
1857
|
+
["/cockpit/standalone.html", /text\/html/],
|
|
1858
|
+
["/cockpit/mount.js", /text\/javascript/],
|
|
1859
|
+
["/cockpit/cockpit.css", /text\/css/],
|
|
1860
|
+
// A hyphenated multi-appView page id (the merlin repro) resolves too.
|
|
1861
|
+
["/delivery-graphs/embed.html", /text\/html/],
|
|
1862
|
+
];
|
|
1863
|
+
for (const [path, ct] of cases) {
|
|
1864
|
+
const res = await router(req("GET", path));
|
|
1865
|
+
assert.equal(res.status, 200, `${path} should 200`);
|
|
1866
|
+
assert.match(res.headers?.["content-type"] ?? "", ct, `${path} content-type`);
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
test("the flat single-appView sidecar back-compat still serves (#420) alongside nested", async () => {
|
|
1871
|
+
// The root `pages/embed.html` case #420 shipped must keep working: an app with exactly
|
|
1872
|
+
// one appView (no subdir) is unaffected by the nested generalization.
|
|
1873
|
+
const router = buildWithAssets({
|
|
1874
|
+
"pages/embed.html": "<html>flat</html>",
|
|
1875
|
+
"pages/cockpit/embed.html": "<html>nested</html>",
|
|
1876
|
+
});
|
|
1877
|
+
const flat = await router(req("GET", "/embed.html"));
|
|
1878
|
+
assert.equal(flat.status, 200);
|
|
1879
|
+
assert.match(flat.body ?? "", /flat/);
|
|
1880
|
+
const nested = await router(req("GET", "/cockpit/embed.html"));
|
|
1881
|
+
assert.equal(nested.status, 200);
|
|
1882
|
+
assert.match(nested.body ?? "", /nested/);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
test("a missing nested sidecar 404s instead of throwing", async () => {
|
|
1886
|
+
const router = buildWithAssets({ "pages/cockpit/embed.html": "<html>ok</html>" });
|
|
1887
|
+
const res = await router(req("GET", "/board/embed.html"));
|
|
1888
|
+
assert.equal(res.status, 404);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test("a nested-serve path traversal attempt is rejected (never reaches the filesystem)", async () => {
|
|
1892
|
+
// The `pages/` tree reuses the SAME sub-path guard as `/dist/`: any `..` / absolute /
|
|
1893
|
+
// percent-escaped / backslash / doubled-slash segment must be rejected before a read.
|
|
1894
|
+
let readAttempts = 0;
|
|
1895
|
+
const { engine } = fakeEngine();
|
|
1896
|
+
const router = makeRouter(
|
|
1897
|
+
createPagesRoutes(
|
|
1898
|
+
{ pagesDir: "pages", homePage: "home", sourceName: "app" },
|
|
1899
|
+
{
|
|
1900
|
+
db: fakeDb(),
|
|
1901
|
+
engine,
|
|
1902
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
1903
|
+
readAsset: async (path: string) => {
|
|
1904
|
+
readAttempts++;
|
|
1905
|
+
return `bytes of ${path}`;
|
|
1906
|
+
},
|
|
1907
|
+
listPages: async () => ["home"],
|
|
1908
|
+
},
|
|
1909
|
+
),
|
|
1910
|
+
);
|
|
1911
|
+
for (const evil of ["/cockpit/../secret.json", "/a/../../secret", "/cockpit/%2e%2e/secret", "/cockpit//etc"]) {
|
|
1912
|
+
const res = await router(req("GET", evil));
|
|
1913
|
+
assert.equal(res.status, 404, `${evil} must 404`);
|
|
1914
|
+
}
|
|
1915
|
+
assert.equal(readAttempts, 0, "no traversal path may reach the asset reader");
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
test("the nested serve declines (does not shadow) a same-path trigger route registered after it", async () => {
|
|
1919
|
+
// The nested serve is a `/`-prefix route registered before the triggers surface. A miss
|
|
1920
|
+
// must DECLINE (fall through) so a trigger at `/webhooks/deploy` still resolves, rather
|
|
1921
|
+
// than being masked by the pages tree's 404. A root catch-all would silently swallow it.
|
|
1922
|
+
const routes = createPagesRoutes(
|
|
1923
|
+
{ pagesDir: "pages", homePage: "home", sourceName: "app" },
|
|
1924
|
+
{
|
|
1925
|
+
db: fakeDb(),
|
|
1926
|
+
engine: fakeEngine().engine,
|
|
1927
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
1928
|
+
readAsset: async (path: string) => {
|
|
1929
|
+
// Only the real nested sidecar exists; every other path is a miss.
|
|
1930
|
+
if (path === "pages/cockpit/embed.html") return "<html>cockpit</html>";
|
|
1931
|
+
throw new Error(`no such file: ${path}`);
|
|
1932
|
+
},
|
|
1933
|
+
listPages: async () => ["home"],
|
|
1934
|
+
},
|
|
1935
|
+
);
|
|
1936
|
+
const router = makeRouter([
|
|
1937
|
+
...routes,
|
|
1938
|
+
{
|
|
1939
|
+
method: "GET",
|
|
1940
|
+
path: "/webhooks/deploy",
|
|
1941
|
+
handler: () => ({ status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify({ queued: true }) }),
|
|
1942
|
+
},
|
|
1943
|
+
]);
|
|
1944
|
+
// The real nested sidecar is served by the pages tree.
|
|
1945
|
+
const sidecar = await router(req("GET", "/cockpit/embed.html"));
|
|
1946
|
+
assert.equal(sidecar.status, 200);
|
|
1947
|
+
assert.match(sidecar.body ?? "", /cockpit/);
|
|
1948
|
+
// The multi-segment trigger path is NOT shadowed — the nested serve declines the miss.
|
|
1949
|
+
const hook = await router(req("GET", "/webhooks/deploy"));
|
|
1950
|
+
assert.equal(hook.status, 202);
|
|
1951
|
+
assert.deepEqual(JSON.parse(hook.body ?? "{}"), { queued: true });
|
|
1952
|
+
});
|