@nanobpm/urban 0.30.0 → 0.31.1

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
+ }
@@ -580,12 +580,20 @@ function renderText(node) {
580
580
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
581
581
  }
582
582
 
583
- // A grid td cell. When the column declares linkField, the cell text (the
584
- // column's own value) becomes a link to the URL held in that other field,
585
- // opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
586
- // javascript: URL smuggled through row data) falls back to plain text — and
587
- // external links get rel=noopener noreferrer so the opened page can't reach
588
- // window.opener. Shared by the top-level grid and child grids.
583
+ // A grid td cell. Two column-declared linking modes, checked in order:
584
+ // 1. linkField — the cell text becomes a link to the URL held in that other
585
+ // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
586
+ // smuggled through row data falls back to plain text).
587
+ // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
588
+ // link: the cell text links to the Nano console's explorer view for the
589
+ // process instance whose key is held in the row's keyField. The console
590
+ // path is constructed HERE (never taken from row data) and the key is
591
+ // URL-encoded, so row data can't smuggle a path or scheme; only a
592
+ // non-empty key (after trimming surrounding whitespace) produces a link.
593
+ // Unknown link kinds fall back to plain text so an unrecognised schema
594
+ // can't render a broken anchor.
595
+ // Both open in a new tab with rel=noopener noreferrer so the opened page can't
596
+ // reach window.opener. Shared by the top-level grid and child grids.
589
597
  function gridCell(col, row) {
590
598
  const text = row[col.field] == null ? "" : String(row[col.field]);
591
599
  if (col.linkField) {
@@ -598,6 +606,18 @@ function gridCell(col, row) {
598
606
  );
599
607
  }
600
608
  }
609
+ if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
610
+ const key = row[col.link.keyField];
611
+ const keyStr = key == null ? "" : String(key).trim();
612
+ if (text !== "" && keyStr !== "") {
613
+ const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
614
+ return el(
615
+ "td",
616
+ {},
617
+ el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
618
+ );
619
+ }
620
+ }
601
621
  return el("td", {}, text);
602
622
  }
603
623
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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",
@@ -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
+ }
@@ -189,6 +189,23 @@ test("renderer wires a column's linkField to a new-tab anchor", async () => {
189
189
  assert.match(js, /rel: "noopener noreferrer"/);
190
190
  });
191
191
 
192
+ test("renderer wires a column's processExplorer link to the console explorer", async () => {
193
+ const res = await dispatch("GET", "/app/runtime.js");
194
+ const js = res.body ?? "";
195
+ // A column declaring `link: { kind: "processExplorer", keyField }` renders its
196
+ // text as a link to the Nano console's explorer for the process instance whose
197
+ // key is held in that field. Guard the whole shape so the primitive can't
198
+ // silently regress: the discriminant + keyField are read, the key is trimmed
199
+ // (whitespace-only keys don't produce a link), the console path is constructed
200
+ // here (not from row data), the trimmed key is URL-encoded, and the anchor
201
+ // opens in a new tab with a hardened rel.
202
+ assert.match(js, /col\.link && col\.link\.kind === "processExplorer" && col\.link\.keyField/);
203
+ assert.match(js, /String\(key\)\.trim\(\)/);
204
+ assert.match(js, /"\/console\/explorer\?instance="\s*\+\s*encodeURIComponent\(keyStr\)/);
205
+ assert.match(js, /target:\s*"_blank"/);
206
+ assert.match(js, /rel:\s*"noopener noreferrer"/);
207
+ });
208
+
192
209
  test("the renderer honours numeric actionForm fields", async () => {
193
210
  const res = await dispatch("GET", "/app/runtime.js");
194
211
  const js = res.body ?? "";
@@ -621,12 +621,20 @@ function renderText(node) {
621
621
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
622
622
  }
623
623
 
624
- // A grid td cell. When the column declares linkField, the cell text (the
625
- // column's own value) becomes a link to the URL held in that other field,
626
- // opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
627
- // javascript: URL smuggled through row data) falls back to plain text — and
628
- // external links get rel=noopener noreferrer so the opened page can't reach
629
- // window.opener. Shared by the top-level grid and child grids.
624
+ // A grid td cell. Two column-declared linking modes, checked in order:
625
+ // 1. linkField — the cell text becomes a link to the URL held in that other
626
+ // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
627
+ // smuggled through row data falls back to plain text).
628
+ // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
629
+ // link: the cell text links to the Nano console's explorer view for the
630
+ // process instance whose key is held in the row's keyField. The console
631
+ // path is constructed HERE (never taken from row data) and the key is
632
+ // URL-encoded, so row data can't smuggle a path or scheme; only a
633
+ // non-empty key (after trimming surrounding whitespace) produces a link.
634
+ // Unknown link kinds fall back to plain text so an unrecognised schema
635
+ // can't render a broken anchor.
636
+ // Both open in a new tab with rel=noopener noreferrer so the opened page can't
637
+ // reach window.opener. Shared by the top-level grid and child grids.
630
638
  function gridCell(col, row) {
631
639
  const text = row[col.field] == null ? "" : String(row[col.field]);
632
640
  if (col.linkField) {
@@ -639,6 +647,18 @@ function gridCell(col, row) {
639
647
  );
640
648
  }
641
649
  }
650
+ if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
651
+ const key = row[col.link.keyField];
652
+ const keyStr = key == null ? "" : String(key).trim();
653
+ if (text !== "" && keyStr !== "") {
654
+ const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
655
+ return el(
656
+ "td",
657
+ {},
658
+ el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
659
+ );
660
+ }
661
+ }
642
662
  return el("td", {}, text);
643
663
  }
644
664