@nanobpm/urban 0.31.0 → 0.32.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.
@@ -12,8 +12,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
12
12
  };
13
13
  import { isAbsolute, resolve } from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
+ import { existsSync } from "node:fs";
15
16
  import { DatabaseSync } from "node:sqlite";
16
17
  import { AsyncLocalStorage } from "node:async_hooks";
18
+ import { resolveModulePath } from "../core/module-path.js";
17
19
  function sqliteParams(params) {
18
20
  return params.map((param) => {
19
21
  if (param === null ||
@@ -67,7 +69,8 @@ export function createDenoHost(opts = {}) {
67
69
  return wrapSqlite(db);
68
70
  },
69
71
  importModule: (p) => {
70
- const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
72
+ const resolved = resolveModulePath(abs(p), existsSync);
73
+ const href = pathToFileURL(resolved).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
71
74
  const mod = import(__rewriteRelativeImportExtension(href));
72
75
  return mod;
73
76
  },
@@ -17,6 +17,7 @@ import { isAbsolute, resolve } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { DatabaseSync } from "node:sqlite";
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
+ import { resolveModulePath } from "../core/module-path.js";
20
21
  function sqliteParams(params) {
21
22
  return params.map((param) => {
22
23
  if (param === null ||
@@ -115,7 +116,8 @@ export function createNodeHost(opts = {}) {
115
116
  return wrapNodeSqlite(db);
116
117
  },
117
118
  importModule: (p) => {
118
- const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
119
+ const resolved = resolveModulePath(abs(p), existsSync);
120
+ const href = pathToFileURL(resolved).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
119
121
  const mod = import(__rewriteRelativeImportExtension(href));
120
122
  return mod;
121
123
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Candidate extensions tried, in order, for an extensionless module path. Compiled outputs come
3
+ * first (`.js`/`.mjs`/`.cjs`, matching the connector-SDK precedent) so a mixed source+build tree
4
+ * resolves to the importable JS rather than a TS file that fails on a runtime without
5
+ * type-stripping; a from-source tree falls through to the TS variants (`.ts`/`.mts`/`.cts`). The
6
+ * source group mirrors the extensions {@link hasModuleExtension} treats as already-extensioned.
7
+ */
8
+ export declare const MODULE_EXTENSION_CANDIDATES: readonly [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"];
9
+ /**
10
+ * If `path` already has a JS/TS extension, return it unchanged. Otherwise return the first
11
+ * `${path}${ext}` (ext ∈ {@link MODULE_EXTENSION_CANDIDATES}) that `exists`, so an extensionless
12
+ * delegate path resolves to its real source/compiled file. When none exists, return `path`
13
+ * unchanged so the underlying loader surfaces its own (accurate) "module not found" error rather
14
+ * than a guessed extension masking it.
15
+ */
16
+ export declare function resolveModulePath(path: string, exists: (candidate: string) => boolean): string;
@@ -0,0 +1,43 @@
1
+ // Resolve a module path that may have been authored WITHOUT a file extension.
2
+ //
3
+ // The OpenAPI delegate surface (ADR 0058) builds a delegate import path from `<dir>/<operationId>`
4
+ // — and an `operationId` cannot carry a file extension (it must be a single safe path segment, and
5
+ // it doubles as a TS type stem). Node's ESM resolver (including `--experimental-strip-types`) and
6
+ // Deno both require an explicit extension for a file: URL, so a bare `import(".../operations/getX")`
7
+ // fails with a "cannot find module" error even though `.../operations/getX.ts` exists. `actions[]`
8
+ // don't hit this because the manifest carries the extension (`"module": "actions/x.ts"`).
9
+ //
10
+ // This mirrors the connector-SDK shim resolution (`connectorShimUrl` in adapters/node.ts): probe
11
+ // the on-disk candidates, PREFERRING a compiled `.js` (the published / `urban run` path, importable
12
+ // on plain Node without TypeScript type-stripping) over the from-source `.ts` (node strip-types /
13
+ // Deno), so both resolve the same author-written `<dir>/<operationId>` path. Preferring compiled
14
+ // first matters in a mixed source+build tree: if both exist and the process has no type-stripping,
15
+ // selecting `.ts` would fail even though `.js` was importable.
16
+ /**
17
+ * Candidate extensions tried, in order, for an extensionless module path. Compiled outputs come
18
+ * first (`.js`/`.mjs`/`.cjs`, matching the connector-SDK precedent) so a mixed source+build tree
19
+ * resolves to the importable JS rather than a TS file that fails on a runtime without
20
+ * type-stripping; a from-source tree falls through to the TS variants (`.ts`/`.mts`/`.cts`). The
21
+ * source group mirrors the extensions {@link hasModuleExtension} treats as already-extensioned.
22
+ */
23
+ export const MODULE_EXTENSION_CANDIDATES = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"];
24
+ /** True when the path already ends in a JS/TS module extension (.ts/.js/.mts/.cts/.mjs/.cjs). */
25
+ function hasModuleExtension(path) {
26
+ return /\.[cm]?[jt]s$/i.test(path);
27
+ }
28
+ /**
29
+ * If `path` already has a JS/TS extension, return it unchanged. Otherwise return the first
30
+ * `${path}${ext}` (ext ∈ {@link MODULE_EXTENSION_CANDIDATES}) that `exists`, so an extensionless
31
+ * delegate path resolves to its real source/compiled file. When none exists, return `path`
32
+ * unchanged so the underlying loader surfaces its own (accurate) "module not found" error rather
33
+ * than a guessed extension masking it.
34
+ */
35
+ export function resolveModulePath(path, exists) {
36
+ if (hasModuleExtension(path))
37
+ return path;
38
+ for (const ext of MODULE_EXTENSION_CANDIDATES) {
39
+ if (exists(path + ext))
40
+ return path + ext;
41
+ }
42
+ return path;
43
+ }
@@ -9,6 +9,10 @@ export interface ApiBinding {
9
9
  base?: string;
10
10
  validateResponses?: "dev" | "always" | "never";
11
11
  eject?: boolean;
12
+ /** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
13
+ * off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
14
+ * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
15
+ docs?: boolean | string;
12
16
  }
13
17
  /** The `{ params, query, body }` contract a typed operation is generic over. */
14
18
  export interface OperationContract {
@@ -48,6 +52,10 @@ export type OperationHandler<Req extends OperationContract = DefaultContract, Re
48
52
  export declare function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(_id: string, handler: OperationHandler<Req, Res>): OperationHandler<Req, Res>;
49
53
  /** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
50
54
  export declare function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined;
55
+ /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
56
+ * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
57
+ * the docs without re-deriving the path. */
58
+ export declare function apiDocsPath(manifest: unknown): string | undefined;
51
59
  export interface ApiHandle {
52
60
  readonly name: string;
53
61
  routes: Route[];
@@ -12,9 +12,41 @@
12
12
  // `x-urban-eject: true` OpenAPI vendor extension. The delegate is always additively imperative: it
13
13
  // gets the raw `req` and full `AppApi` on top of the validated input.
14
14
  import { errorMessage } from "../guards.js";
15
- import { json, normalizeRoutePath } from "../router.js";
15
+ import { html, json, normalizeRoutePath } from "../router.js";
16
16
  import { resolveAppPath } from "./datasource.js";
17
17
  import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
18
+ /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
19
+ * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
20
+ * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
21
+ const SWAGGER_UI_VERSION = "5.17.14";
22
+ /** Escape HTML-significant characters before embedding a value (the app name) in the docs markup. */
23
+ function escapeHtml(s) {
24
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
25
+ }
26
+ /** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
27
+ * unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
28
+ * which keeps the docs route decoupled from spec-load failures. */
29
+ function swaggerUiPage(title, specUrl) {
30
+ const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
31
+ const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
32
+ return `<!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1">
37
+ <title>${escapeHtml(title)}</title>
38
+ <link rel="stylesheet" href="${css}">
39
+ <style>body{margin:0}</style>
40
+ </head>
41
+ <body>
42
+ <div id="swagger-ui"></div>
43
+ <script src="${bundle}" crossorigin="anonymous"></script>
44
+ <script>
45
+ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
46
+ </script>
47
+ </body>
48
+ </html>`;
49
+ }
18
50
  /**
19
51
  * Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
20
52
  * `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
@@ -47,6 +79,7 @@ function readApiBinding(manifest) {
47
79
  const base = Reflect.get(raw, "base");
48
80
  const validateResponses = Reflect.get(raw, "validateResponses");
49
81
  const eject = Reflect.get(raw, "eject");
82
+ const docs = Reflect.get(raw, "docs");
50
83
  // Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
51
84
  return {
52
85
  spec: spec.trim(),
@@ -56,8 +89,32 @@ function readApiBinding(manifest) {
56
89
  ? validateResponses
57
90
  : undefined,
58
91
  eject: eject === true,
92
+ // Docs default ON (spec-first apps get Swagger for free); `false` disables; a non-empty
93
+ // string overrides the route. Anything else (including whitespace) falls back to the default.
94
+ docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
59
95
  };
60
96
  }
97
+ /** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
98
+ * serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
99
+ function resolveApiRoutes(binding) {
100
+ const base = normalizeRoutePath(binding.base, "/app/api");
101
+ const docsEnabled = binding.docs !== false;
102
+ // Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
103
+ // of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
104
+ // that owns the `${base}/` prefix (a string `docs` overrides the whole route).
105
+ const docsBase = normalizeRoutePath(typeof binding.docs === "string" ? binding.docs : `${base}-docs`, `${base}-docs`);
106
+ return { base, docsEnabled, docsBase };
107
+ }
108
+ /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
109
+ * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
110
+ * the docs without re-deriving the path. */
111
+ export function apiDocsPath(manifest) {
112
+ const binding = readApiBinding(manifest);
113
+ if (!binding)
114
+ return undefined;
115
+ const { docsEnabled, docsBase } = resolveApiRoutes(binding);
116
+ return docsEnabled ? docsBase : undefined;
117
+ }
61
118
  /** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
62
119
  * validate. A value that will not coerce is left as-is and fails the type check with a clear
63
120
  * message. The delegate still receives the original string(s). */
@@ -89,9 +146,16 @@ export function mountApi(ctx, app) {
89
146
  // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
90
147
  // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
91
148
  // like "app/api" would never match request paths, which always start with "/".
92
- const base = normalizeRoutePath(binding.base, "/app/api");
149
+ //
150
+ // The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
151
+ // this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
152
+ // shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
153
+ // copy of that logic here. The spec JSON is served under the docs path so the operations
154
+ // namespace stays purely operations.
155
+ const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
93
156
  const dir = binding.dir ?? "operations";
94
157
  const surfaceEject = binding.eject === true;
158
+ const specRoutePath = `${docsBase}/openapi.json`;
95
159
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
96
160
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
97
161
  // than failing app boot.
@@ -351,9 +415,57 @@ export function mountApi(ctx, app) {
351
415
  source: `api:${binding.spec}`,
352
416
  handler: dispatch,
353
417
  };
418
+ // Docs routes (ADR 0058): the Swagger UI page + the OpenAPI JSON it reads. Both are exact GET
419
+ // routes on the `${base}-docs` sibling namespace, so they are wholly independent of the
420
+ // operation dispatcher above.
421
+ const serveSpec = async () => {
422
+ let d;
423
+ try {
424
+ d = await loadDoc();
425
+ }
426
+ catch (e) {
427
+ return json({ error: errorMessage(e) }, 500);
428
+ }
429
+ // Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
430
+ // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
431
+ // makes the interactive console call the real Urban routes rather than the origin root.
432
+ return json({ ...d, servers: [{ url: base }] });
433
+ };
434
+ const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
435
+ const serveDocs = () => html(swaggerUiPage(docsTitle, specRoutePath));
436
+ // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
437
+ // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
438
+ // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
439
+ // (slash-less) `docsBase` that `apiDocsPath` and every internal link already use.
440
+ const redirectDocsSlash = () => ({ status: 308, headers: { location: docsBase } });
441
+ const docsRoutes = docsEnabled
442
+ ? [
443
+ { method: "GET", path: docsBase, source: `api-docs:${binding.spec}`, handler: serveDocs },
444
+ {
445
+ method: "GET",
446
+ path: `${docsBase}/`,
447
+ source: `api-docs:${binding.spec}`,
448
+ handler: redirectDocsSlash,
449
+ },
450
+ {
451
+ method: "GET",
452
+ path: specRoutePath,
453
+ source: `api-docs:${binding.spec}`,
454
+ handler: serveSpec,
455
+ },
456
+ ]
457
+ : [];
354
458
  return {
355
459
  name: "api",
356
- routes: [rootRoute, route],
357
- describe: () => ({ api: { spec: binding.spec, base, dir, eject: surfaceEject } }),
460
+ routes: [...docsRoutes, rootRoute, route],
461
+ describe: () => ({
462
+ api: {
463
+ spec: binding.spec,
464
+ base,
465
+ dir,
466
+ eject: surfaceEject,
467
+ docs: docsEnabled ? { ui: docsBase, spec: specRoutePath } : false,
468
+ },
469
+ }),
358
470
  };
359
471
  }
@@ -18,6 +18,12 @@ export interface PagesOptions {
18
18
  rowLimit?: number;
19
19
  /** The injected default datasource name (the alias apps bind to). Default `app`. */
20
20
  sourceName?: string;
21
+ /**
22
+ * The app's Swagger UI route (from the `api` binding, resolved by `apiDocsPath`). When set,
23
+ * the shell renders a persistent "API docs" badge linking to it — so a spec-first app surfaces
24
+ * its interactive docs from its own UI for free. Omitted → no badge (app declares no `api`).
25
+ */
26
+ apiDocsPath?: string;
21
27
  }
22
28
  export interface PagesDeps {
23
29
  db: PagesDataSource;
@@ -17,6 +17,7 @@
17
17
  import { errorMessage, isRecord } from "../guards.js";
18
18
  import { html, json } from "../router.js";
19
19
  import { cancelInstanceReconciling } from "./cancel.js";
20
+ import { apiDocsPath } from "./api.js";
20
21
  import { quoteIdent } from "./gateway.js";
21
22
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
22
23
  function javascript(body, status = 200) {
@@ -66,7 +67,7 @@ export function createPagesRoutes(opts, deps) {
66
67
  return p;
67
68
  };
68
69
  const routes = [];
69
- const shell = html(rendererShell(homePage));
70
+ const shell = html(rendererShell(homePage, opts.apiDocsPath));
70
71
  // ── the renderer shell + module ─────────────────────────────────────────
71
72
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
72
73
  routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
@@ -335,6 +336,9 @@ export function mountPages(ctx, app) {
335
336
  homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
336
337
  rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
337
338
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
339
+ // Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
340
+ // binding (resolved by the api module, so the path never drifts from where docs mount).
341
+ apiDocsPath: apiDocsPath(ctx.manifest),
338
342
  };
339
343
  const sourceName = opts.sourceName ?? "app";
340
344
  const bindings = ctx.manifest.instanceTracking ?? [];
@@ -361,7 +365,13 @@ export function mountPages(ctx, app) {
361
365
  function escapeAttr(s) {
362
366
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
363
367
  }
364
- function rendererShell(homePage) {
368
+ function rendererShell(homePage, apiDocsPath) {
369
+ // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
370
+ // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
371
+ // tab a handle back to this window (reverse-tabnabbing).
372
+ const apiDocsBadge = apiDocsPath
373
+ ? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
374
+ : "";
365
375
  return `<!doctype html>
366
376
  <html lang="en">
367
377
  <head>
@@ -370,7 +380,7 @@ function rendererShell(homePage) {
370
380
  <title>Urban App</title>
371
381
  <style>${RENDERER_CSS}</style>
372
382
  </head>
373
- <body>
383
+ <body>${apiDocsBadge}
374
384
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
375
385
  <script type="module" src="./app/runtime.js"></script>
376
386
  </body>
@@ -417,6 +427,8 @@ const RENDERER_CSS = `
417
427
  * { box-sizing: border-box; }
418
428
  body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
419
429
  .pc-empty { color:var(--nano-text-faint); }
430
+ .pc-apidocs { position:fixed; top:.75rem; right:.75rem; z-index:10; font-size:.8rem; text-decoration:none; padding:.3rem .6rem; border:1px solid var(--nano-edge); border-radius:999px; background:var(--nano-panel); color:var(--nano-text-muted); }
431
+ .pc-apidocs:hover { color:var(--nano-text); border-color:var(--nano-accent); }
420
432
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
421
433
  .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
422
434
  .pc-body { margin:.5rem 0; }
@@ -663,10 +675,28 @@ function renderActionForm(node) {
663
675
  }
664
676
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
665
677
  try {
666
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
667
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
668
- msg.className = "pc-msg ok";
669
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
678
+ // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
679
+ // publishMessage -> engine.publishMessage, which correlates a message to a message start
680
+ // (or intermediate catch) event. correlationKeyField names the form field that carries
681
+ // the correlation key; the whole form becomes the message variables.
682
+ if (p.action && p.action.kind === "publishMessage") {
683
+ const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
684
+ if (!correlationKey) {
685
+ msg.className = "pc-msg err";
686
+ msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
687
+ return;
688
+ }
689
+ await getJSON("/app/actions/message", { method: "POST",
690
+ headers: { "content-type": "application/json" },
691
+ body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
692
+ msg.className = "pc-msg ok";
693
+ msg.textContent = "Message published";
694
+ } else {
695
+ const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
696
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
697
+ msg.className = "pc-msg ok";
698
+ msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
699
+ }
670
700
  for (const input of inputs.values()) input.value = "";
671
701
  document.dispatchEvent(new CustomEvent("pc:refresh"));
672
702
  } catch (e) {
@@ -12,10 +12,13 @@ export declare function json(body: unknown, status?: number): HttpResponse;
12
12
  export declare function html(body: string, status?: number): HttpResponse;
13
13
  export declare function text(body: string, status?: number): HttpResponse;
14
14
  /**
15
- * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
16
- * slashes. Incoming request paths always start with "/", so a manifest path
17
- * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
18
- * `fallback` when the value is missing or collapses to empty ("/").
15
+ * Normalize a manifest-supplied route path: collapse leading slashes to exactly
16
+ * one, drop trailing slashes. Incoming request paths always start with "/", so a
17
+ * manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
18
+ * multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
19
+ * entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
20
+ * path instead of becoming a protocol-relative URL that navigates off-origin. Falls
21
+ * back to `fallback` when the value is missing or collapses to empty ("/").
19
22
  */
20
23
  export declare function normalizeRoutePath(value: string | undefined, fallback: string): string;
21
24
  /** Build a single HttpHandler that dispatches across the given routes. */
@@ -11,14 +11,17 @@ export function text(body, status = 200) {
11
11
  return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
12
12
  }
13
13
  /**
14
- * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
15
- * slashes. Incoming request paths always start with "/", so a manifest path
16
- * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
17
- * `fallback` when the value is missing or collapses to empty ("/").
14
+ * Normalize a manifest-supplied route path: collapse leading slashes to exactly
15
+ * one, drop trailing slashes. Incoming request paths always start with "/", so a
16
+ * manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
17
+ * multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
18
+ * entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
19
+ * path instead of becoming a protocol-relative URL that navigates off-origin. Falls
20
+ * back to `fallback` when the value is missing or collapses to empty ("/").
18
21
  */
19
22
  export function normalizeRoutePath(value, fallback) {
20
23
  const raw = (value ?? fallback).trim();
21
- const withLead = raw.startsWith("/") ? raw : `/${raw}`;
24
+ const withLead = `/${raw.replace(/^\/+/, "")}`;
22
25
  const noTrail = withLead.replace(/\/+$/, "");
23
26
  return noTrail || fallback;
24
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.31.0",
3
+ "version": "0.32.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.7.0"
69
+ "create-urban-app": "^0.8.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^22.20.1",
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { isAbsolute, resolve } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
+ import { existsSync } from "node:fs";
8
9
  import { DatabaseSync } from "node:sqlite";
9
10
  import { AsyncLocalStorage } from "node:async_hooks";
10
11
  import type {
@@ -15,6 +16,7 @@ import type {
15
16
  HttpServer,
16
17
  SqliteDb,
17
18
  } from "../core/host.ts";
19
+ import { resolveModulePath } from "../core/module-path.ts";
18
20
 
19
21
  type SqliteParam = string | number | bigint | Uint8Array | null;
20
22
 
@@ -102,8 +104,9 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
102
104
  return wrapSqlite(db);
103
105
  },
104
106
  importModule: (p) => {
107
+ const resolved = resolveModulePath(abs(p), existsSync);
105
108
  const href =
106
- pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
109
+ pathToFileURL(resolved).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
107
110
  const mod: Promise<Record<string, unknown>> = import(href);
108
111
  return mod;
109
112
  },
@@ -19,6 +19,7 @@ import type {
19
19
  SqliteDb,
20
20
  WatchHandle,
21
21
  } from "../core/host.ts";
22
+ import { resolveModulePath } from "../core/module-path.ts";
22
23
 
23
24
  type SqliteParam = string | number | bigint | Uint8Array | null;
24
25
 
@@ -138,8 +139,9 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
138
139
  return wrapNodeSqlite(db);
139
140
  },
140
141
  importModule: (p) => {
142
+ const resolved = resolveModulePath(abs(p), existsSync);
141
143
  const href =
142
- pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
144
+ pathToFileURL(resolved).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
143
145
  const mod: Promise<Record<string, unknown>> = import(href);
144
146
  return mod;
145
147
  },
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { MODULE_EXTENSION_CANDIDATES, resolveModulePath } from "./module-path.ts";
8
+ import { createNodeHost } from "../adapters/node.ts";
9
+
10
+ test("resolveModulePath leaves an already-extensioned path untouched", () => {
11
+ const never = () => {
12
+ throw new Error("exists() must not be consulted for an extensioned path");
13
+ };
14
+ assert.equal(resolveModulePath("/a/b/x.ts", never), "/a/b/x.ts");
15
+ assert.equal(resolveModulePath("/a/b/x.js", never), "/a/b/x.js");
16
+ assert.equal(resolveModulePath("/a/b/x.mjs", never), "/a/b/x.mjs");
17
+ assert.equal(resolveModulePath("/a/b/x.cjs", never), "/a/b/x.cjs");
18
+ assert.equal(resolveModulePath("/a/b/x.mts", never), "/a/b/x.mts");
19
+ assert.equal(resolveModulePath("/a/b/x.cts", never), "/a/b/x.cts");
20
+ });
21
+
22
+ test("resolveModulePath appends the first candidate extension that exists", () => {
23
+ const present = new Set(["/ops/getX.js"]);
24
+ const resolved = resolveModulePath("/ops/getX", (c) => present.has(c));
25
+ assert.equal(resolved, "/ops/getX.js");
26
+ });
27
+
28
+ test("resolveModulePath prefers a compiled .js over .ts when both exist", () => {
29
+ // Matches the connector-SDK precedent (connectorShimUrl): a mixed source+build tree must
30
+ // resolve to the importable .js so it works on plain Node without TypeScript type-stripping.
31
+ const present = new Set(["/ops/getX.ts", "/ops/getX.js"]);
32
+ const resolved = resolveModulePath("/ops/getX", (c) => present.has(c));
33
+ assert.equal(resolved, "/ops/getX.js");
34
+ assert.equal(MODULE_EXTENSION_CANDIDATES[0], ".js");
35
+ });
36
+
37
+ test("resolveModulePath returns the path unchanged when nothing exists", () => {
38
+ const resolved = resolveModulePath("/ops/missing", () => false);
39
+ assert.equal(resolved, "/ops/missing");
40
+ });
41
+
42
+ test("createNodeHost.importModule loads an extensionless .ts delegate from disk", async () => {
43
+ const dir = mkdtempSync(join(tmpdir(), "urban-import-"));
44
+ try {
45
+ writeFileSync(
46
+ join(dir, "getVersion.ts"),
47
+ "export const marker = 'from-ts';\nexport default { marker };\n",
48
+ );
49
+ const host = createNodeHost({ cwd: dir });
50
+ // Path carries NO extension — mirrors how the OpenAPI delegate surface builds
51
+ // `<dir>/<operationId>`. Without extension probing this throws ERR_MODULE_NOT_FOUND.
52
+ const mod = await host.importModule(join(dir, "getVersion"));
53
+ assert.equal(mod.marker, "from-ts");
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ });
@@ -0,0 +1,44 @@
1
+ // Resolve a module path that may have been authored WITHOUT a file extension.
2
+ //
3
+ // The OpenAPI delegate surface (ADR 0058) builds a delegate import path from `<dir>/<operationId>`
4
+ // — and an `operationId` cannot carry a file extension (it must be a single safe path segment, and
5
+ // it doubles as a TS type stem). Node's ESM resolver (including `--experimental-strip-types`) and
6
+ // Deno both require an explicit extension for a file: URL, so a bare `import(".../operations/getX")`
7
+ // fails with a "cannot find module" error even though `.../operations/getX.ts` exists. `actions[]`
8
+ // don't hit this because the manifest carries the extension (`"module": "actions/x.ts"`).
9
+ //
10
+ // This mirrors the connector-SDK shim resolution (`connectorShimUrl` in adapters/node.ts): probe
11
+ // the on-disk candidates, PREFERRING a compiled `.js` (the published / `urban run` path, importable
12
+ // on plain Node without TypeScript type-stripping) over the from-source `.ts` (node strip-types /
13
+ // Deno), so both resolve the same author-written `<dir>/<operationId>` path. Preferring compiled
14
+ // first matters in a mixed source+build tree: if both exist and the process has no type-stripping,
15
+ // selecting `.ts` would fail even though `.js` was importable.
16
+
17
+ /**
18
+ * Candidate extensions tried, in order, for an extensionless module path. Compiled outputs come
19
+ * first (`.js`/`.mjs`/`.cjs`, matching the connector-SDK precedent) so a mixed source+build tree
20
+ * resolves to the importable JS rather than a TS file that fails on a runtime without
21
+ * type-stripping; a from-source tree falls through to the TS variants (`.ts`/`.mts`/`.cts`). The
22
+ * source group mirrors the extensions {@link hasModuleExtension} treats as already-extensioned.
23
+ */
24
+ export const MODULE_EXTENSION_CANDIDATES = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
25
+
26
+ /** True when the path already ends in a JS/TS module extension (.ts/.js/.mts/.cts/.mjs/.cjs). */
27
+ function hasModuleExtension(path: string): boolean {
28
+ return /\.[cm]?[jt]s$/i.test(path);
29
+ }
30
+
31
+ /**
32
+ * If `path` already has a JS/TS extension, return it unchanged. Otherwise return the first
33
+ * `${path}${ext}` (ext ∈ {@link MODULE_EXTENSION_CANDIDATES}) that `exists`, so an extensionless
34
+ * delegate path resolves to its real source/compiled file. When none exists, return `path`
35
+ * unchanged so the underlying loader surfaces its own (accurate) "module not found" error rather
36
+ * than a guessed extension masking it.
37
+ */
38
+ export function resolveModulePath(path: string, exists: (candidate: string) => boolean): string {
39
+ if (hasModuleExtension(path)) return path;
40
+ for (const ext of MODULE_EXTENSION_CANDIDATES) {
41
+ if (exists(path + ext)) return path + ext;
42
+ }
43
+ return path;
44
+ }
@@ -5,7 +5,7 @@ import type { AppManifest } from "../manifest.ts";
5
5
  import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
6
6
  import { makeRouter } from "../router.ts";
7
7
  import { DataLayer } from "./datasource.ts";
8
- import { mountApi, resolveOperationHandler, type OperationHandler } from "./api.ts";
8
+ import { mountApi, resolveOperationHandler, apiDocsPath, type ApiHandle, type OperationHandler } from "./api.ts";
9
9
 
10
10
  function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
11
11
  return {
@@ -72,9 +72,10 @@ const spec = JSON.stringify({
72
72
  });
73
73
 
74
74
  interface Built {
75
- router: (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
75
+ router: (r: HttpRequest) => Promise<{ status?: number; body?: string; headers?: Record<string, string> }>;
76
76
  imported: string[];
77
77
  logs: Array<{ level: string; msg: string }>;
78
+ handle: ApiHandle;
78
79
  }
79
80
 
80
81
  function build(
@@ -108,7 +109,12 @@ function build(
108
109
  const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
109
110
  const handle = mountApi(ctx, appFixture());
110
111
  const router = makeRouter(handle.routes);
111
- return { router: (r) => Promise.resolve(router(r)), imported, logs };
112
+ return { router: (r) => Promise.resolve(router(r)), imported, logs, handle };
113
+ }
114
+
115
+ /** Mount `mountApi` and return the handle directly (for asserting `describe()`). */
116
+ function buildHandle(api: Record<string, unknown>): { handle: ApiHandle } {
117
+ return { handle: build(api, {}).handle };
112
118
  }
113
119
 
114
120
  test("resolveOperationHandler prefers default (function) then handler", () => {
@@ -443,3 +449,71 @@ test("an unsafe operationId never mounts (skipped) — a crafted spec can't impo
443
449
  assert.equal(res.status, 404); // no route mounted for the skipped op
444
450
  assert.equal(imported.some((p) => p.includes("evil")), false);
445
451
  });
452
+
453
+ test("serves the OpenAPI spec at {base}-docs/openapi.json, rebased to the mounted namespace", async () => {
454
+ const { router } = build({ spec: "openapi.json" }, {});
455
+ const res = await router(req("GET", "/app/api-docs/openapi.json"));
456
+ assert.equal(res.status, 200);
457
+ assert.match(res.headers?.["content-type"] ?? "", /application\/json/);
458
+ const doc = JSON.parse(res.body ?? "{}");
459
+ // The doc is served intact...
460
+ assert.ok(doc.paths["/invoices"], "keeps the operation paths");
461
+ // ...but `servers` is rebased to `base` so Swagger UI "Try it out" hits the real routes
462
+ // (operations mount under /app/api, not the spec's bare /invoices).
463
+ assert.deepEqual(doc.servers, [{ url: "/app/api" }]);
464
+ });
465
+
466
+ test("serves the Swagger UI page at {base}-docs by default", async () => {
467
+ const { router } = build({ spec: "openapi.json" }, {});
468
+ const res = await router(req("GET", "/app/api-docs"));
469
+ assert.equal(res.status, 200);
470
+ assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
471
+ const body = res.body ?? "";
472
+ assert.match(body, /swagger-ui/);
473
+ // The UI reads the spec route we serve.
474
+ assert.match(body, /\/app\/api-docs\/openapi\.json/);
475
+ });
476
+
477
+ test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
478
+ const { router } = build({ spec: "openapi.json" }, {});
479
+ // The router exact-matches non-prefix routes, so `${base}-docs/` (common from proxies/browsers)
480
+ // would 404 without an explicit alias. It must permanent-redirect to the slash-less canonical.
481
+ const res = await router(req("GET", "/app/api-docs/"));
482
+ assert.equal(res.status, 308);
483
+ assert.equal(res.headers?.["location"], "/app/api-docs");
484
+ });
485
+
486
+ test("docs default on is reflected in describe()", () => {
487
+ const { handle } = buildHandle({ spec: "openapi.json" });
488
+ const d = handle.describe();
489
+ assert.deepEqual(d.api, {
490
+ spec: "openapi.json",
491
+ base: "/app/api",
492
+ dir: "operations",
493
+ eject: false,
494
+ docs: { ui: "/app/api-docs", spec: "/app/api-docs/openapi.json" },
495
+ });
496
+ });
497
+
498
+ test("docs:false disables both the UI and the spec route (no reserved paths)", async () => {
499
+ const { router } = build({ spec: "openapi.json", docs: false }, {});
500
+ // With docs off, the docs paths fall through to the operation dispatcher → no such operation.
501
+ assert.equal((await router(req("GET", "/app/api-docs"))).status, 404);
502
+ assert.equal((await router(req("GET", "/app/api-docs/openapi.json"))).status, 404);
503
+ });
504
+
505
+ test("a string docs overrides the UI route (and the spec route beneath it)", async () => {
506
+ const { router } = build({ spec: "openapi.json", docs: "/help" }, {});
507
+ assert.equal((await router(req("GET", "/help"))).status, 200);
508
+ assert.equal((await router(req("GET", "/help/openapi.json"))).status, 200);
509
+ // The default docs path is no longer served.
510
+ assert.equal((await router(req("GET", "/app/api-docs"))).status, 404);
511
+ });
512
+
513
+ test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
514
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
515
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/x-docs");
516
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
517
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
518
+ assert.equal(apiDocsPath({}), undefined);
519
+ });
@@ -15,7 +15,7 @@
15
15
  import type { AppApi, RuntimeContext } from "../context.ts";
16
16
  import { errorMessage } from "../guards.ts";
17
17
  import type { HttpRequest, HttpResponse } from "../host.ts";
18
- import { json, normalizeRoutePath, type Route } from "../router.ts";
18
+ import { html, json, normalizeRoutePath, type Route } from "../router.ts";
19
19
  import { resolveAppPath } from "./datasource.ts";
20
20
  import {
21
21
  collectOperations,
@@ -41,6 +41,48 @@ export interface ApiBinding {
41
41
  base?: string;
42
42
  validateResponses?: "dev" | "always" | "never";
43
43
  eject?: boolean;
44
+ /** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
45
+ * off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
46
+ * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
47
+ docs?: boolean | string;
48
+ }
49
+
50
+ /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
51
+ * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
52
+ * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
53
+ const SWAGGER_UI_VERSION = "5.17.14";
54
+
55
+ /** Escape HTML-significant characters before embedding a value (the app name) in the docs markup. */
56
+ function escapeHtml(s: string): string {
57
+ return s.replace(
58
+ /[&<>"']/g,
59
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c,
60
+ );
61
+ }
62
+
63
+ /** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
64
+ * unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
65
+ * which keeps the docs route decoupled from spec-load failures. */
66
+ function swaggerUiPage(title: string, specUrl: string): string {
67
+ const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
68
+ const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
69
+ return `<!doctype html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="utf-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1">
74
+ <title>${escapeHtml(title)}</title>
75
+ <link rel="stylesheet" href="${css}">
76
+ <style>body{margin:0}</style>
77
+ </head>
78
+ <body>
79
+ <div id="swagger-ui"></div>
80
+ <script src="${bundle}" crossorigin="anonymous"></script>
81
+ <script>
82
+ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
83
+ </script>
84
+ </body>
85
+ </html>`;
44
86
  }
45
87
 
46
88
  /** The `{ params, query, body }` contract a typed operation is generic over. */
@@ -113,6 +155,7 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
113
155
  const base = Reflect.get(raw, "base");
114
156
  const validateResponses = Reflect.get(raw, "validateResponses");
115
157
  const eject = Reflect.get(raw, "eject");
158
+ const docs = Reflect.get(raw, "docs");
116
159
  // Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
117
160
  return {
118
161
  spec: spec.trim(),
@@ -123,9 +166,41 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
123
166
  ? validateResponses
124
167
  : undefined,
125
168
  eject: eject === true,
169
+ // Docs default ON (spec-first apps get Swagger for free); `false` disables; a non-empty
170
+ // string overrides the route. Anything else (including whitespace) falls back to the default.
171
+ docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
126
172
  };
127
173
  }
128
174
 
175
+ /** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
176
+ * serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
177
+ function resolveApiRoutes(binding: ApiBinding): {
178
+ base: string;
179
+ docsEnabled: boolean;
180
+ docsBase: string;
181
+ } {
182
+ const base = normalizeRoutePath(binding.base, "/app/api");
183
+ const docsEnabled = binding.docs !== false;
184
+ // Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
185
+ // of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
186
+ // that owns the `${base}/` prefix (a string `docs` overrides the whole route).
187
+ const docsBase = normalizeRoutePath(
188
+ typeof binding.docs === "string" ? binding.docs : `${base}-docs`,
189
+ `${base}-docs`,
190
+ );
191
+ return { base, docsEnabled, docsBase };
192
+ }
193
+
194
+ /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
195
+ * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
196
+ * the docs without re-deriving the path. */
197
+ export function apiDocsPath(manifest: unknown): string | undefined {
198
+ const binding = readApiBinding(manifest);
199
+ if (!binding) return undefined;
200
+ const { docsEnabled, docsBase } = resolveApiRoutes(binding);
201
+ return docsEnabled ? docsBase : undefined;
202
+ }
203
+
129
204
  /** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
130
205
  * validate. A value that will not coerce is left as-is and fails the type check with a clear
131
206
  * message. The delegate still receives the original string(s). */
@@ -167,9 +242,16 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
167
242
  // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
168
243
  // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
169
244
  // like "app/api" would never match request paths, which always start with "/".
170
- const base = normalizeRoutePath(binding.base, "/app/api");
245
+ //
246
+ // The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
247
+ // this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
248
+ // shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
249
+ // copy of that logic here. The spec JSON is served under the docs path so the operations
250
+ // namespace stays purely operations.
251
+ const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
171
252
  const dir = binding.dir ?? "operations";
172
253
  const surfaceEject = binding.eject === true;
254
+ const specRoutePath = `${docsBase}/openapi.json`;
173
255
 
174
256
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
175
257
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
@@ -445,9 +527,57 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
445
527
  handler: dispatch,
446
528
  };
447
529
 
530
+ // Docs routes (ADR 0058): the Swagger UI page + the OpenAPI JSON it reads. Both are exact GET
531
+ // routes on the `${base}-docs` sibling namespace, so they are wholly independent of the
532
+ // operation dispatcher above.
533
+ const serveSpec = async (): Promise<HttpResponse> => {
534
+ let d: OpenApiDoc;
535
+ try {
536
+ d = await loadDoc();
537
+ } catch (e) {
538
+ return json({ error: errorMessage(e) }, 500);
539
+ }
540
+ // Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
541
+ // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
542
+ // makes the interactive console call the real Urban routes rather than the origin root.
543
+ return json({ ...d, servers: [{ url: base }] });
544
+ };
545
+ const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
546
+ const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specRoutePath));
547
+ // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
548
+ // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
549
+ // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
550
+ // (slash-less) `docsBase` that `apiDocsPath` and every internal link already use.
551
+ const redirectDocsSlash = (): HttpResponse => ({ status: 308, headers: { location: docsBase } });
552
+ const docsRoutes: Route[] = docsEnabled
553
+ ? [
554
+ { method: "GET", path: docsBase, source: `api-docs:${binding.spec}`, handler: serveDocs },
555
+ {
556
+ method: "GET",
557
+ path: `${docsBase}/`,
558
+ source: `api-docs:${binding.spec}`,
559
+ handler: redirectDocsSlash,
560
+ },
561
+ {
562
+ method: "GET",
563
+ path: specRoutePath,
564
+ source: `api-docs:${binding.spec}`,
565
+ handler: serveSpec,
566
+ },
567
+ ]
568
+ : [];
569
+
448
570
  return {
449
571
  name: "api",
450
- routes: [rootRoute, route],
451
- describe: () => ({ api: { spec: binding.spec, base, dir, eject: surfaceEject } }),
572
+ routes: [...docsRoutes, rootRoute, route],
573
+ describe: () => ({
574
+ api: {
575
+ spec: binding.spec,
576
+ base,
577
+ dir,
578
+ eject: surfaceEject,
579
+ docs: docsEnabled ? { ui: docsBase, spec: specRoutePath } : false,
580
+ },
581
+ }),
452
582
  };
453
583
  }
@@ -122,6 +122,34 @@ test("GET /app/runtime.js serves the renderer module", async () => {
122
122
  assert.match(res.body ?? "", /function apiUrl\(u\)/);
123
123
  });
124
124
 
125
+ test("the shell renders an API-docs badge only when an apiDocsPath is provided", async () => {
126
+ const routes = createPagesRoutes(
127
+ { pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
128
+ { db: fakeDb(), engine: fakeEngine().engine, readPage: async () => "{}" },
129
+ );
130
+ const withBadge = (await makeRouter(routes)(req("GET", "/"))).body ?? "";
131
+ assert.match(withBadge, /class="pc-apidocs"/);
132
+ assert.match(withBadge, /href="\/app\/api-docs"/);
133
+ // Opens in a new tab with a hardened rel (no reverse-tabnabbing handle).
134
+ assert.match(withBadge, /rel="noopener noreferrer"/);
135
+
136
+ // No `api` binding → no badge element (the CSS rule is always present in the shell styles).
137
+ const noBadge = (await dispatch("GET", "/")).body ?? "";
138
+ assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
139
+ });
140
+
141
+ test("renderActionForm supports a publishMessage action (message-started processes)", async () => {
142
+ const res = await dispatch("GET", "/app/runtime.js");
143
+ const js = res.body ?? "";
144
+ // The form submit branches on the action kind: publishMessage posts to the message endpoint,
145
+ // deriving the correlation key from the named form field; startProcess keeps its old path.
146
+ assert.match(js, /p\.action\.kind === "publishMessage"/);
147
+ assert.match(js, /variables\[p\.action\.correlationKeyField\]/);
148
+ assert.match(js, /"\/app\/actions\/message"/);
149
+ // startProcess remains the default/back-compat branch.
150
+ assert.match(js, /"\/app\/actions\/start\/" \+ encodeURIComponent\(p\.action\.process\)/);
151
+ });
152
+
125
153
  test("the renderer bridges the console theme over postMessage", async () => {
126
154
  const res = await dispatch("GET", "/app/runtime.js");
127
155
  const js = res.body ?? "";
@@ -20,6 +20,7 @@ import { errorMessage, isRecord } from "../guards.ts";
20
20
  import type { EngineClient } from "../host.ts";
21
21
  import { html, json, type Route } from "../router.ts";
22
22
  import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
23
+ import { apiDocsPath } from "./api.ts";
23
24
  import { quoteIdent } from "./gateway.ts";
24
25
 
25
26
  /** The subset of the datasource gateway the page runtime needs. */
@@ -37,6 +38,12 @@ export interface PagesOptions {
37
38
  rowLimit?: number;
38
39
  /** The injected default datasource name (the alias apps bind to). Default `app`. */
39
40
  sourceName?: string;
41
+ /**
42
+ * The app's Swagger UI route (from the `api` binding, resolved by `apiDocsPath`). When set,
43
+ * the shell renders a persistent "API docs" badge linking to it — so a spec-first app surfaces
44
+ * its interactive docs from its own UI for free. Omitted → no badge (app declares no `api`).
45
+ */
46
+ apiDocsPath?: string;
40
47
  }
41
48
 
42
49
  export interface PagesDeps {
@@ -115,7 +122,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
115
122
  };
116
123
 
117
124
  const routes: Route[] = [];
118
- const shell = html(rendererShell(homePage));
125
+ const shell = html(rendererShell(homePage, opts.apiDocsPath));
119
126
 
120
127
  // ── the renderer shell + module ─────────────────────────────────────────
121
128
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
@@ -372,6 +379,9 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
372
379
  homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
373
380
  rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
374
381
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
382
+ // Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
383
+ // binding (resolved by the api module, so the path never drifts from where docs mount).
384
+ apiDocsPath: apiDocsPath(ctx.manifest),
375
385
  };
376
386
  const sourceName = opts.sourceName ?? "app";
377
387
  const bindings = ctx.manifest.instanceTracking ?? [];
@@ -400,7 +410,13 @@ function escapeAttr(s: string): string {
400
410
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
401
411
  }
402
412
 
403
- function rendererShell(homePage: string): string {
413
+ function rendererShell(homePage: string, apiDocsPath?: string): string {
414
+ // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
415
+ // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
416
+ // tab a handle back to this window (reverse-tabnabbing).
417
+ const apiDocsBadge = apiDocsPath
418
+ ? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
419
+ : "";
404
420
  return `<!doctype html>
405
421
  <html lang="en">
406
422
  <head>
@@ -409,7 +425,7 @@ function rendererShell(homePage: string): string {
409
425
  <title>Urban App</title>
410
426
  <style>${RENDERER_CSS}</style>
411
427
  </head>
412
- <body>
428
+ <body>${apiDocsBadge}
413
429
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
414
430
  <script type="module" src="./app/runtime.js"></script>
415
431
  </body>
@@ -457,6 +473,8 @@ const RENDERER_CSS = `
457
473
  * { box-sizing: border-box; }
458
474
  body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
459
475
  .pc-empty { color:var(--nano-text-faint); }
476
+ .pc-apidocs { position:fixed; top:.75rem; right:.75rem; z-index:10; font-size:.8rem; text-decoration:none; padding:.3rem .6rem; border:1px solid var(--nano-edge); border-radius:999px; background:var(--nano-panel); color:var(--nano-text-muted); }
477
+ .pc-apidocs:hover { color:var(--nano-text); border-color:var(--nano-accent); }
460
478
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
461
479
  .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
462
480
  .pc-body { margin:.5rem 0; }
@@ -704,10 +722,28 @@ function renderActionForm(node) {
704
722
  }
705
723
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
706
724
  try {
707
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
708
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
709
- msg.className = "pc-msg ok";
710
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
725
+ // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
726
+ // publishMessage -> engine.publishMessage, which correlates a message to a message start
727
+ // (or intermediate catch) event. correlationKeyField names the form field that carries
728
+ // the correlation key; the whole form becomes the message variables.
729
+ if (p.action && p.action.kind === "publishMessage") {
730
+ const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
731
+ if (!correlationKey) {
732
+ msg.className = "pc-msg err";
733
+ msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
734
+ return;
735
+ }
736
+ await getJSON("/app/actions/message", { method: "POST",
737
+ headers: { "content-type": "application/json" },
738
+ body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
739
+ msg.className = "pc-msg ok";
740
+ msg.textContent = "Message published";
741
+ } else {
742
+ const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
743
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
744
+ msg.className = "pc-msg ok";
745
+ msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
746
+ }
711
747
  for (const input of inputs.values()) input.value = "";
712
748
  document.dispatchEvent(new CustomEvent("pc:refresh"));
713
749
  } catch (e) {
@@ -9,6 +9,12 @@ test("normalizeRoutePath ensures a leading slash and trims trailing slashes", ()
9
9
  assert.equal(normalizeRoutePath(" /chat ", "/chat"), "/chat");
10
10
  });
11
11
 
12
+ test("normalizeRoutePath collapses leading slashes so a //host value stays in-app", () => {
13
+ assert.equal(normalizeRoutePath("//evil.com", "/app/api"), "/evil.com");
14
+ assert.equal(normalizeRoutePath("///app/api", "/app/api"), "/app/api");
15
+ assert.equal(normalizeRoutePath(" //app/api-docs ", "/app/api"), "/app/api-docs");
16
+ });
17
+
12
18
  test("normalizeRoutePath falls back when value is missing or collapses to root", () => {
13
19
  assert.equal(normalizeRoutePath(undefined, "/tasks"), "/tasks");
14
20
  assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
@@ -27,14 +27,17 @@ export function text(body: string, status = 200): HttpResponse {
27
27
  }
28
28
 
29
29
  /**
30
- * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
31
- * slashes. Incoming request paths always start with "/", so a manifest path
32
- * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
33
- * `fallback` when the value is missing or collapses to empty ("/").
30
+ * Normalize a manifest-supplied route path: collapse leading slashes to exactly
31
+ * one, drop trailing slashes. Incoming request paths always start with "/", so a
32
+ * manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
33
+ * multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
34
+ * entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
35
+ * path instead of becoming a protocol-relative URL that navigates off-origin. Falls
36
+ * back to `fallback` when the value is missing or collapses to empty ("/").
34
37
  */
35
38
  export function normalizeRoutePath(value: string | undefined, fallback: string): string {
36
39
  const raw = (value ?? fallback).trim();
37
- const withLead = raw.startsWith("/") ? raw : `/${raw}`;
40
+ const withLead = `/${raw.replace(/^\/+/, "")}`;
38
41
  const noTrail = withLead.replace(/\/+$/, "");
39
42
  return noTrail || fallback;
40
43
  }