@nanobpm/urban 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface GenIO {
|
|
|
7
7
|
writeText(path: string, content: string): Promise<void>;
|
|
8
8
|
/** File names (not paths) in a directory; empty if it does not exist. */
|
|
9
9
|
listDir(path: string): Promise<string[]>;
|
|
10
|
+
/**
|
|
11
|
+
* Sub-directory names (not files) in a directory; empty if it does not exist. Optional: the
|
|
12
|
+
* deploy-by-convention model scan uses it to descend one level into `resources/<subdir>/`. When
|
|
13
|
+
* absent, only files directly under `resources/` are scanned.
|
|
14
|
+
*/
|
|
15
|
+
listSubdirs?(path: string): Promise<string[]>;
|
|
10
16
|
exists(path: string): Promise<boolean>;
|
|
11
17
|
/**
|
|
12
18
|
* Import an app module and return its exports. Optional: only present when the caller runs on
|
|
@@ -50,7 +56,26 @@ declare function join(root: string, rel: string): string;
|
|
|
50
56
|
declare function dirOf(p: string): string;
|
|
51
57
|
/** Resolve a `dir/*.ext` (or literal) manifest pattern to file paths relative to root. */
|
|
52
58
|
export declare function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]>;
|
|
53
|
-
/**
|
|
59
|
+
/** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
|
|
60
|
+
* runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
|
|
61
|
+
* deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
|
|
62
|
+
export declare const RESOURCES_DIR = "resources";
|
|
63
|
+
/**
|
|
64
|
+
* Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
|
|
65
|
+
* and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
|
|
66
|
+
* and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
|
|
67
|
+
* `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
|
|
68
|
+
*/
|
|
69
|
+
export declare function discoverResourceModels(root: string, io: GenIO): Promise<string[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
|
|
72
|
+
* `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
|
|
73
|
+
* discovered by convention under `resources/`; with a `models` block declared, `models.processes`
|
|
74
|
+
* is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
|
|
75
|
+
* alone would fall back to convention even when a `models` block is present for other overrides
|
|
76
|
+
* (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
|
|
77
|
+
* gen/runtime drift.
|
|
78
|
+
*/
|
|
54
79
|
export declare function readModels(root: string, io: GenIO, manifest: {
|
|
55
80
|
models?: {
|
|
56
81
|
processes?: string[];
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
|
|
5
5
|
import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.js";
|
|
6
6
|
import { deriveMigrations } from "./derivers/migrations.js";
|
|
7
|
-
import {
|
|
8
|
-
import { deriveWorkerBindings } from "./derivers/worker-io.js";
|
|
7
|
+
import { emitDomainModel, registryFromManifest, sourcesFromManifest } from "./derivers/domain.js";
|
|
8
|
+
import { byModelPath, deriveWorkerBindings, detectEnvelopeConflicts, DOMAIN_DTS, formatEnvelopeConflict, scanModelWorkers } from "./derivers/worker-io.js";
|
|
9
9
|
import { deriveMeta } from "./derivers/meta.js";
|
|
10
10
|
import { deriveMessageBindings } from "./derivers/messages.js";
|
|
11
|
+
import { resolveShapes, scanModelShapes } from "./derivers/shapes.js";
|
|
11
12
|
import { deriveApi } from "./derivers/api.js";
|
|
12
13
|
import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.js";
|
|
13
14
|
import { deriveModels, MODEL_PROVENANCE } from "./models.js";
|
|
@@ -45,15 +46,63 @@ export async function expandPattern(root, io, pattern) {
|
|
|
45
46
|
.map((n) => (dir === "." ? n : `${dir}/${n}`))
|
|
46
47
|
.sort();
|
|
47
48
|
}
|
|
48
|
-
/**
|
|
49
|
+
/** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
|
|
50
|
+
* runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
|
|
51
|
+
* deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
|
|
52
|
+
export const RESOURCES_DIR = "resources";
|
|
53
|
+
/** Model file extensions the `nano:shape`/code-first scan reads by convention (BPMN + DMN). */
|
|
54
|
+
const MODEL_EXTS = [".bpmn", ".dmn"];
|
|
55
|
+
/**
|
|
56
|
+
* Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
|
|
57
|
+
* and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
|
|
58
|
+
* and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
|
|
59
|
+
* `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
|
|
60
|
+
*/
|
|
61
|
+
export async function discoverResourceModels(root, io) {
|
|
62
|
+
const isModel = (n) => MODEL_EXTS.some((e) => n.endsWith(e));
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const n of await io.listDir(join(root, RESOURCES_DIR))) {
|
|
65
|
+
if (isModel(n))
|
|
66
|
+
out.push(`${RESOURCES_DIR}/${n}`);
|
|
67
|
+
}
|
|
68
|
+
const subdirs = io.listSubdirs ? await io.listSubdirs(join(root, RESOURCES_DIR)) : [];
|
|
69
|
+
for (const sub of subdirs.slice().sort()) {
|
|
70
|
+
for (const n of await io.listDir(join(root, `${RESOURCES_DIR}/${sub}`))) {
|
|
71
|
+
if (isModel(n))
|
|
72
|
+
out.push(`${RESOURCES_DIR}/${sub}/${n}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out.sort();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
|
|
79
|
+
* `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
|
|
80
|
+
* discovered by convention under `resources/`; with a `models` block declared, `models.processes`
|
|
81
|
+
* is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
|
|
82
|
+
* alone would fall back to convention even when a `models` block is present for other overrides
|
|
83
|
+
* (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
|
|
84
|
+
* gen/runtime drift.
|
|
85
|
+
*/
|
|
49
86
|
export async function readModels(root, io, manifest) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
const byConvention = manifest.models === undefined;
|
|
88
|
+
let rels;
|
|
89
|
+
if (byConvention) {
|
|
90
|
+
rels = await discoverResourceModels(root, io);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
rels = [];
|
|
94
|
+
for (const pat of manifest.models?.processes ?? []) {
|
|
95
|
+
rels.push(...(await expandPattern(root, io, pat)));
|
|
55
96
|
}
|
|
56
97
|
}
|
|
98
|
+
const models = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const rel of rels) {
|
|
101
|
+
if (seen.has(rel))
|
|
102
|
+
continue;
|
|
103
|
+
seen.add(rel);
|
|
104
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
105
|
+
}
|
|
57
106
|
return models;
|
|
58
107
|
}
|
|
59
108
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
@@ -69,28 +118,60 @@ async function collectAll(opts) {
|
|
|
69
118
|
const manifest = JSON.parse(await io.readText(manifestPath));
|
|
70
119
|
const artifacts = [];
|
|
71
120
|
const warnings = [];
|
|
72
|
-
// 1.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
artifacts.push(...deriveDomain(manifest));
|
|
76
|
-
}
|
|
77
|
-
// 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
|
|
78
|
-
// `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
|
|
79
|
-
// derivers below so worker I/O works for a code-first app even on a `--no-models` run.
|
|
121
|
+
// 1. Resolve the app's models first (code-first `workflows/*.ts` → BPMN, plus authored on-disk
|
|
122
|
+
// `.bpmn`): the fused domain model (below) lifts `nano:shape` envelopes *from* the models, so
|
|
123
|
+
// they must be in hand before the domain + worker/message type-contracts are derived.
|
|
80
124
|
const derived = await deriveModels(root, io, manifest);
|
|
125
|
+
// The derived `.bpmn` are emitted only when `emitModels` (default), but always fed to the
|
|
126
|
+
// type-contract derivers so worker I/O works for a code-first app even on a `--no-models` run.
|
|
81
127
|
if (emitModels)
|
|
82
128
|
artifacts.push(...derived.artifacts);
|
|
83
|
-
|
|
84
|
-
|
|
129
|
+
const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
|
|
130
|
+
const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
|
|
131
|
+
const models = [...diskModels, ...derived.models];
|
|
132
|
+
// 2. Fused domain model (ADR 0040): the `DomainTypes` registry is the union of the manifest
|
|
133
|
+
// `types` registry and the model-authored `nano:shape` envelopes, resolved through the same
|
|
134
|
+
// fuse the console reifier (`envelope_scan.rs`) uses. Without this the standalone `urban gen`
|
|
135
|
+
// would honour only `manifest.types` and silently ignore every model-authored data envelope,
|
|
136
|
+
// so worker/message I/O against those envelopes would fall back to the untyped `WorkerVars`.
|
|
137
|
+
const manifestRegistry = registryFromManifest(manifest);
|
|
138
|
+
const sources = sourcesFromManifest(manifest);
|
|
139
|
+
const shapeDecls = models.flatMap((m) => scanModelShapes(m.xml));
|
|
140
|
+
const shapeResolution = resolveShapes(shapeDecls, manifestRegistry, sources);
|
|
141
|
+
const fusedTypes = { ...manifestRegistry, ...shapeResolution.types };
|
|
142
|
+
for (const d of shapeResolution.diagnostics) {
|
|
143
|
+
warnings.push(`data-envelope shape "${d.shape}" (${d.kind}): ${d.message}`);
|
|
144
|
+
}
|
|
145
|
+
// 3. types → migrations (persisted `manifest.types` only — shapes are transient wire envelopes,
|
|
146
|
+
// never tables) + the fused domain row types (`DomainTables` spine + `DomainTypes` registry).
|
|
147
|
+
if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
|
|
148
|
+
artifacts.push(...deriveMigrations(manifest));
|
|
149
|
+
}
|
|
150
|
+
if (!opts.modelsOnly && Object.keys(fusedTypes).length > 0) {
|
|
151
|
+
artifacts.push({
|
|
152
|
+
path: `${GENERATED_DIR}/${DOMAIN_DTS}`,
|
|
153
|
+
content: emitDomainModel(sources, manifest.data?.default ?? "app", fusedTypes),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// 4. models (authored on-disk + derived) → worker I/O index + meta accessor + message map. The
|
|
157
|
+
// declared type ids include the fused shapes, so an envelope ref to a model-authored shape
|
|
158
|
+
// resolves to its `DomainTypes[...]` entry instead of degrading to the untyped fallback.
|
|
85
159
|
if (!opts.modelsOnly) {
|
|
86
|
-
const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
|
|
87
|
-
const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
|
|
88
|
-
const models = [...diskModels, ...derived.models];
|
|
89
160
|
if (models.length > 0) {
|
|
90
|
-
const declaredTypeIds = Object.keys(
|
|
161
|
+
const declaredTypeIds = Object.keys(fusedTypes);
|
|
91
162
|
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
|
92
163
|
artifacts.push(...deriveMeta(models));
|
|
93
164
|
artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
|
|
165
|
+
// Surface genuine data-envelope conflicts: a taskType whose variants disagree on a field's
|
|
166
|
+
// TYPE. The emitted contract is the union of the variants (sound for a shared handler), so a
|
|
167
|
+
// type clash would silently widen to `A | B` — warn instead so the author unifies the envelope.
|
|
168
|
+
const scannedWorkers = [...models]
|
|
169
|
+
.sort(byModelPath)
|
|
170
|
+
.flatMap((m) => scanModelWorkers(m.xml))
|
|
171
|
+
.map((w) => ({ taskType: w.taskType, inputType: w.in, outputType: w.out }));
|
|
172
|
+
for (const c of detectEnvelopeConflicts(scannedWorkers, fusedTypes)) {
|
|
173
|
+
warnings.push(formatEnvelopeConflict(c));
|
|
174
|
+
}
|
|
94
175
|
}
|
|
95
176
|
}
|
|
96
177
|
// 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
|
package/dist/toolkit/models.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ import type { GenIO } from "./gen.ts";
|
|
|
4
4
|
import type { ModelSource } from "./derivers/worker-io.ts";
|
|
5
5
|
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
6
6
|
export declare const DEFAULT_WORKFLOW_PATTERNS: string[];
|
|
7
|
-
/** Default output dir for derived models
|
|
8
|
-
*
|
|
9
|
-
|
|
7
|
+
/** Default output dir for derived models when the manifest declares no `models.processes`: under
|
|
8
|
+
* the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
|
|
9
|
+
* exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
|
|
10
|
+
* `processesOutDir` derives the dir from that glob instead (the model-first override). */
|
|
11
|
+
export declare const PROCESSES_DIR = "resources/processes";
|
|
10
12
|
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
11
13
|
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
12
14
|
export declare const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts \u2014 do not edit; edit the flow and re-run `urban gen`.";
|
|
@@ -29,7 +31,7 @@ export interface ModelError {
|
|
|
29
31
|
message: string;
|
|
30
32
|
}
|
|
31
33
|
export interface DerivedModels {
|
|
32
|
-
/** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
34
|
+
/** `resources/processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
33
35
|
artifacts: DerivedArtifact[];
|
|
34
36
|
/** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
|
|
35
37
|
models: ModelSource[];
|
package/dist/toolkit/models.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
|
|
4
4
|
// imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
|
|
5
|
-
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn
|
|
5
|
+
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `resources/processes/<id>.bpmn`
|
|
6
|
+
// (the deploy-by-convention `resources/` tree, ADR 0062 — see `PROCESSES_DIR`).
|
|
6
7
|
// Executing the app's TypeScript needs a runtime module loader, so this is gated on the
|
|
7
8
|
// optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
|
|
8
9
|
// loader is absent — or the app authors its models directly (model-first) — derivation is a
|
|
@@ -12,9 +13,11 @@ import { expandPattern, joinPath } from "./gen.js";
|
|
|
12
13
|
import { isRecord } from "../runtime/core/guards.js";
|
|
13
14
|
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
14
15
|
export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
|
|
15
|
-
/** Default output dir for derived models
|
|
16
|
-
*
|
|
17
|
-
|
|
16
|
+
/** Default output dir for derived models when the manifest declares no `models.processes`: under
|
|
17
|
+
* the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
|
|
18
|
+
* exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
|
|
19
|
+
* `processesOutDir` derives the dir from that glob instead (the model-first override). */
|
|
20
|
+
export const PROCESSES_DIR = "resources/processes";
|
|
18
21
|
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
19
22
|
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
20
23
|
export const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts — do not edit; edit the flow and re-run `urban gen`.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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",
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
SqliteDb,
|
|
18
18
|
} from "../core/host.ts";
|
|
19
19
|
import { resolveModulePath } from "../core/module-path.ts";
|
|
20
|
+
import { LOOPBACK_HOST } from "../core/manifest.ts";
|
|
20
21
|
import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.ts";
|
|
21
22
|
|
|
22
23
|
type SqliteParam = string | number | bigint | Uint8Array | null;
|
|
@@ -48,11 +49,11 @@ interface DenoGlobal {
|
|
|
48
49
|
env: { get(name: string): string | undefined };
|
|
49
50
|
cwd(): string;
|
|
50
51
|
readTextFile(path: string): Promise<string>;
|
|
51
|
-
readDir(path: string): AsyncIterable<{ name: string; isFile: boolean }>;
|
|
52
|
+
readDir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>;
|
|
52
53
|
stat(path: string): Promise<unknown>;
|
|
53
54
|
watchFs(paths: string | string[], options?: { recursive?: boolean }): DenoFsWatcher;
|
|
54
55
|
serve(
|
|
55
|
-
opts: { port: number; onListen?: (a: { port: number }) => void },
|
|
56
|
+
opts: { port: number; hostname?: string; onListen?: (a: { port: number }) => void },
|
|
56
57
|
handler: (req: Request) => Response | Promise<Response>,
|
|
57
58
|
): DenoHttpServer;
|
|
58
59
|
}
|
|
@@ -104,6 +105,17 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
104
105
|
return [];
|
|
105
106
|
}
|
|
106
107
|
},
|
|
108
|
+
async listSubdirs(dir) {
|
|
109
|
+
try {
|
|
110
|
+
const names: string[] = [];
|
|
111
|
+
for await (const e of Deno.readDir(abs(dir))) {
|
|
112
|
+
if (e.isDirectory) names.push(e.name);
|
|
113
|
+
}
|
|
114
|
+
return names;
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
},
|
|
107
119
|
async exists(p) {
|
|
108
120
|
try {
|
|
109
121
|
await Deno.stat(abs(p));
|
|
@@ -123,8 +135,8 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
123
135
|
const mod: Promise<Record<string, unknown>> = import(href);
|
|
124
136
|
return mod;
|
|
125
137
|
},
|
|
126
|
-
async serveHttp(port, handler) {
|
|
127
|
-
return startDenoServer(port, handler);
|
|
138
|
+
async serveHttp(port, handler, bindHost) {
|
|
139
|
+
return startDenoServer(port, handler, bindHost);
|
|
128
140
|
},
|
|
129
141
|
watch(onChange) {
|
|
130
142
|
const w = Deno.watchFs(cwd, { recursive: true });
|
|
@@ -177,9 +189,17 @@ function wrapSqlite(db: DatabaseSync): SqliteDb {
|
|
|
177
189
|
};
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
function startDenoServer(
|
|
192
|
+
function startDenoServer(
|
|
193
|
+
port: number,
|
|
194
|
+
handler: HttpHandler,
|
|
195
|
+
bindHost?: string,
|
|
196
|
+
): Promise<HttpServer> {
|
|
181
197
|
return new Promise<HttpServer>((resolveServer) => {
|
|
182
|
-
|
|
198
|
+
// `hostname` binds the interface (issue #235): `"127.0.0.1"` loopback-only, `"0.0.0.0"`
|
|
199
|
+
// all interfaces. When no bind host is resolved we fail *closed* to loopback rather than
|
|
200
|
+
// inheriting Deno's default, so a caller that omits it can never silently expose the server
|
|
201
|
+
// off-box.
|
|
202
|
+
const server = Deno.serve({ port, hostname: bindHost ?? LOOPBACK_HOST, onListen: ({ port: p }) => {
|
|
183
203
|
resolveServer({
|
|
184
204
|
port: p,
|
|
185
205
|
stop: () => server.shutdown(),
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as http from "node:http";
|
|
4
|
+
import { createNodeHost } from "./node.ts";
|
|
5
|
+
import type { HttpServer } from "../core/host.ts";
|
|
6
|
+
|
|
7
|
+
// Issue #235: the node adapter must bind the interface it is handed so the manifest's
|
|
8
|
+
// `network.bind` setting actually controls off-box reachability. We assert the *listen host*
|
|
9
|
+
// the socket bound to for each setting by reading the native server's address.
|
|
10
|
+
function boundAddress(server: HttpServer): string {
|
|
11
|
+
const native = server.native;
|
|
12
|
+
assert.ok(native instanceof http.Server, "adapter exposes a node http.Server as native");
|
|
13
|
+
const addr = native.address();
|
|
14
|
+
assert.ok(addr && typeof addr === "object", "server has a bound AddressInfo");
|
|
15
|
+
return addr.address;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("serveHttp binds loopback when handed 127.0.0.1 (refuses off-box)", async () => {
|
|
19
|
+
const host = createNodeHost({ log: () => {} });
|
|
20
|
+
const server = await host.serveHttp(0, () => ({ status: 200 }), "127.0.0.1");
|
|
21
|
+
try {
|
|
22
|
+
assert.equal(boundAddress(server), "127.0.0.1");
|
|
23
|
+
} finally {
|
|
24
|
+
await server.stop();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("serveHttp binds all interfaces when handed 0.0.0.0 (reachable on the LAN)", async () => {
|
|
29
|
+
const host = createNodeHost({ log: () => {} });
|
|
30
|
+
const server = await host.serveHttp(0, () => ({ status: 200 }), "0.0.0.0");
|
|
31
|
+
try {
|
|
32
|
+
assert.equal(boundAddress(server), "0.0.0.0");
|
|
33
|
+
} finally {
|
|
34
|
+
await server.stop();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Issue #235 (fail closed): omitting the bind host must NOT inherit Node's bind-all default —
|
|
39
|
+
// a caller that forgets to resolve one should still get loopback, never off-box exposure.
|
|
40
|
+
test("serveHttp fails closed to loopback when no bind host is given", async () => {
|
|
41
|
+
const host = createNodeHost({ log: () => {} });
|
|
42
|
+
const server = await host.serveHttp(0, () => ({ status: 200 }));
|
|
43
|
+
try {
|
|
44
|
+
assert.equal(boundAddress(server), "127.0.0.1");
|
|
45
|
+
} finally {
|
|
46
|
+
await server.stop();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A bind failure (EADDRINUSE) must reject the serveHttp promise instead of hanging forever
|
|
51
|
+
// waiting for a `listening` event that never fires.
|
|
52
|
+
test("serveHttp rejects when the port is already in use (does not hang)", async () => {
|
|
53
|
+
const host = createNodeHost({ log: () => {} });
|
|
54
|
+
const first = await host.serveHttp(0, () => ({ status: 200 }), "127.0.0.1");
|
|
55
|
+
const taken = first.native;
|
|
56
|
+
assert.ok(taken instanceof http.Server);
|
|
57
|
+
const addr = taken.address();
|
|
58
|
+
assert.ok(addr && typeof addr === "object");
|
|
59
|
+
try {
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
() => host.serveHttp(addr.port, () => ({ status: 200 }), "127.0.0.1"),
|
|
62
|
+
(err: NodeJS.ErrnoException) => err.code === "EADDRINUSE",
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
await first.stop();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
WatchHandle,
|
|
22
22
|
} from "../core/host.ts";
|
|
23
23
|
import { resolveModulePath } from "../core/module-path.ts";
|
|
24
|
+
import { LOOPBACK_HOST } from "../core/manifest.ts";
|
|
24
25
|
import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.ts";
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -137,6 +138,14 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
137
138
|
return [];
|
|
138
139
|
}
|
|
139
140
|
},
|
|
141
|
+
async listSubdirs(dir) {
|
|
142
|
+
try {
|
|
143
|
+
const entries = await readdir(abs(dir), { withFileTypes: true });
|
|
144
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
},
|
|
140
149
|
async exists(p) {
|
|
141
150
|
try {
|
|
142
151
|
await stat(abs(p));
|
|
@@ -166,8 +175,8 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
166
175
|
pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
167
176
|
await import(href);
|
|
168
177
|
},
|
|
169
|
-
async serveHttp(port, handler) {
|
|
170
|
-
return await startNodeServer(port, handler);
|
|
178
|
+
async serveHttp(port, handler, bindHost) {
|
|
179
|
+
return await startNodeServer(port, handler, bindHost);
|
|
171
180
|
},
|
|
172
181
|
watch(onChange) {
|
|
173
182
|
const onFsEvent = (_event: unknown, filename: string | Buffer | null) => {
|
|
@@ -216,7 +225,11 @@ function wrapNodeSqlite(db: DatabaseSync): SqliteDb {
|
|
|
216
225
|
};
|
|
217
226
|
}
|
|
218
227
|
|
|
219
|
-
async function startNodeServer(
|
|
228
|
+
async function startNodeServer(
|
|
229
|
+
port: number,
|
|
230
|
+
handler: HttpHandler,
|
|
231
|
+
bindHost?: string,
|
|
232
|
+
): Promise<HttpServer> {
|
|
220
233
|
const server = createServer(async (nreq, nres) => {
|
|
221
234
|
const chunks: Buffer[] = [];
|
|
222
235
|
for await (const c of nreq) {
|
|
@@ -263,7 +276,27 @@ async function startNodeServer(port: number, handler: HttpHandler): Promise<Http
|
|
|
263
276
|
sockets.add(socket);
|
|
264
277
|
socket.once("close", () => sockets.delete(socket));
|
|
265
278
|
});
|
|
266
|
-
|
|
279
|
+
// Bind to the requested interface (issue #235): `"127.0.0.1"` keeps the app loopback-only
|
|
280
|
+
// (secure default), `"0.0.0.0"` exposes it on the LAN. When no bind host is resolved we fail
|
|
281
|
+
// *closed* to loopback rather than inheriting Node's bind-all default, so a caller that omits
|
|
282
|
+
// it can never silently expose the server off-box.
|
|
283
|
+
const listenHost = bindHost ?? LOOPBACK_HOST;
|
|
284
|
+
// `server.listen` reports bind failures (EADDRINUSE/EACCES/…) via the `error` event, not the
|
|
285
|
+
// listening callback. Reject on the first such error so callers see the failure instead of
|
|
286
|
+
// hanging forever waiting for a `listening` that will never fire.
|
|
287
|
+
await new Promise<void>((res, rej) => {
|
|
288
|
+
const onError = (err: Error) => {
|
|
289
|
+
server.removeListener("listening", onListening);
|
|
290
|
+
rej(err);
|
|
291
|
+
};
|
|
292
|
+
const onListening = () => {
|
|
293
|
+
server.removeListener("error", onError);
|
|
294
|
+
res();
|
|
295
|
+
};
|
|
296
|
+
server.once("error", onError);
|
|
297
|
+
server.once("listening", onListening);
|
|
298
|
+
server.listen(port, listenHost);
|
|
299
|
+
});
|
|
267
300
|
const addr = server.address();
|
|
268
301
|
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
269
302
|
return {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AppManifest } from "./manifest.ts";
|
|
2
2
|
import type { EngineClient, HostContext } from "./host.ts";
|
|
3
3
|
import type { EngineSdkClient } from "../engine/sdk.ts";
|
|
4
|
-
import type { TemplateSource } from "./modules/templates.ts";
|
|
5
4
|
|
|
6
5
|
/** Everything a runtime module needs. Passed to each module's mount function. */
|
|
7
6
|
export interface RuntimeContext {
|
|
@@ -10,9 +9,6 @@ export interface RuntimeContext {
|
|
|
10
9
|
engine: EngineClient;
|
|
11
10
|
/** App root directory (paths in the manifest are relative to this). */
|
|
12
11
|
root: string;
|
|
13
|
-
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
14
|
-
* `name → content` map), merged over the manifest's `models.templates`. See `deployModels`. */
|
|
15
|
-
templates?: TemplateSource;
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
/** A mounted module returns a disposer so the runtime can tear it down cleanly. */
|
package/src/runtime/core/glob.ts
CHANGED
|
@@ -61,3 +61,27 @@ export async function expandPatterns(
|
|
|
61
61
|
}
|
|
62
62
|
return out;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
|
|
67
|
+
* the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
|
|
68
|
+
* never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
|
|
69
|
+
* deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
|
|
70
|
+
* descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
|
|
71
|
+
* top-level files. Returns root-prefixed paths, sorted for determinism.
|
|
72
|
+
*/
|
|
73
|
+
export async function discoverResources(
|
|
74
|
+
host: HostContext,
|
|
75
|
+
root: string,
|
|
76
|
+
dir: string,
|
|
77
|
+
): Promise<string[]> {
|
|
78
|
+
const base = joinPath(root, dir);
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
for (const name of await host.listDir(base)) out.push(joinPath(base, name));
|
|
81
|
+
const subdirs = host.listSubdirs ? await host.listSubdirs(base) : [];
|
|
82
|
+
for (const sub of subdirs.slice().sort()) {
|
|
83
|
+
const subBase = joinPath(base, sub);
|
|
84
|
+
for (const name of await host.listDir(subBase)) out.push(joinPath(subBase, name));
|
|
85
|
+
}
|
|
86
|
+
return out.sort();
|
|
87
|
+
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface HostContext {
|
|
|
80
80
|
readTextFile(path: string): Promise<string>;
|
|
81
81
|
/** List file names (not directories) directly under `dir`. Returns [] if missing. */
|
|
82
82
|
listDir(dir: string): Promise<string[]>;
|
|
83
|
+
/**
|
|
84
|
+
* List sub-directory names (not files) directly under `dir`. Returns [] if missing. Optional:
|
|
85
|
+
* the deploy-by-convention walk uses it to descend one level into `resources/<subdir>/` (a host
|
|
86
|
+
* that can't enumerate directories then deploys only the files directly under `resources/`).
|
|
87
|
+
*/
|
|
88
|
+
listSubdirs?(dir: string): Promise<string[]>;
|
|
83
89
|
/** True if the path exists (file or directory). */
|
|
84
90
|
exists(path: string): Promise<boolean>;
|
|
85
91
|
/** Open (creating if needed) a SQLite database at a filesystem path. */
|
|
@@ -99,8 +105,16 @@ export interface HostContext {
|
|
|
99
105
|
* connector mount then reports the workers as unsupported rather than crashing.
|
|
100
106
|
*/
|
|
101
107
|
importConnectorModule?(entry: string): Promise<void>;
|
|
102
|
-
/**
|
|
103
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Start an HTTP server. Routing is done by the caller inside `handler`. `bindHost` is the
|
|
110
|
+
* network interface to bind (e.g. `"127.0.0.1"` for loopback, `"0.0.0.0"` for all interfaces);
|
|
111
|
+
* the runtime always resolves it from the manifest `network.bind` setting (issue #235,
|
|
112
|
+
* loopback by default). It stays optional so hosts that can't bind-control still satisfy the
|
|
113
|
+
* contract, but the built-in node/deno adapters fail *closed* to loopback when it is omitted —
|
|
114
|
+
* they never inherit a bind-all default — so the secure-by-default guarantee can't silently
|
|
115
|
+
* regress.
|
|
116
|
+
*/
|
|
117
|
+
serveHttp(port: number, handler: HttpHandler, bindHost?: string): Promise<HttpServer>;
|
|
104
118
|
/**
|
|
105
119
|
* Recursively watch the app root for file changes, invoking `onChange` with the
|
|
106
120
|
* changed path (app-root-relative when the runtime reports it that way). Optional:
|
|
@@ -174,6 +188,20 @@ export function isBpmnError(err: unknown): err is { errorCode: string; message?:
|
|
|
174
188
|
);
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The canonical presence rule for a form identifier ({@link EngineClient.getForm}): an
|
|
193
|
+
* empty or whitespace-only value is treated as *absent*. Returns the *trimmed* value when
|
|
194
|
+
* present (so a padded `" form-123 "` resolves against the space-free deployed key),
|
|
195
|
+
* otherwise `undefined`. This is the single source of truth shared by `getForm`'s
|
|
196
|
+
* resolution gate ({@link EngineClient.getForm} adapters) and the `taskInbox`
|
|
197
|
+
* `/api/form` route's presence check, so the two cannot drift on what counts as "an
|
|
198
|
+
* identifier was provided" (e.g. a whitespace-only `?formKey= ` is absent in both).
|
|
199
|
+
*/
|
|
200
|
+
export function presentFormIdentifier(value: string | undefined): string | undefined {
|
|
201
|
+
const trimmed = value?.trim();
|
|
202
|
+
return trimmed ? trimmed : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
/** A registered worker subscription. */
|
|
178
206
|
export interface WorkerSubscription {
|
|
179
207
|
readonly jobType: string;
|
|
@@ -195,6 +223,37 @@ export interface ProcessInstanceSnapshot {
|
|
|
195
223
|
readonly state: ProcessInstanceState;
|
|
196
224
|
}
|
|
197
225
|
|
|
226
|
+
/**
|
|
227
|
+
* A single open user task as {@link EngineClient.searchUserTasks} reports it. Carries the
|
|
228
|
+
* task's identity plus its resolved form linkage so the `taskInbox` surface (ADR 0026) can
|
|
229
|
+
* fetch and render the linked `.form`. `formKey` is the engine's resolution of a
|
|
230
|
+
* `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form at task-creation
|
|
231
|
+
* time; `externalFormReference` is a form linked outside the deployment. Both are absent for a
|
|
232
|
+
* task with no linked form (the surface then falls back to the raw key-list + complete path).
|
|
233
|
+
*/
|
|
234
|
+
export interface UserTaskSummary {
|
|
235
|
+
readonly userTaskKey: string;
|
|
236
|
+
readonly elementId?: string;
|
|
237
|
+
readonly variables?: Record<string, unknown>;
|
|
238
|
+
/** The resolved key of the linked deployed form, if the task has one. */
|
|
239
|
+
readonly formKey?: string;
|
|
240
|
+
/** An external form reference (a form linked outside the deployment), if any. */
|
|
241
|
+
readonly externalFormReference?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A deployed form's form-js schema plus its identifying metadata, as
|
|
246
|
+
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
247
|
+
* (`{ type: "default", schemaVersion, components: [...] }`) that the surface renders
|
|
248
|
+
* client-side; `formKey`/`formId`/`version` identify the exact deployed form resolved.
|
|
249
|
+
*/
|
|
250
|
+
export interface FormSchema {
|
|
251
|
+
readonly formKey?: string;
|
|
252
|
+
readonly formId?: string;
|
|
253
|
+
readonly version?: number;
|
|
254
|
+
readonly schema: Record<string, unknown>;
|
|
255
|
+
}
|
|
256
|
+
|
|
198
257
|
/**
|
|
199
258
|
* EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
|
|
200
259
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
@@ -218,12 +277,23 @@ export interface EngineClient {
|
|
|
218
277
|
correlationKey?: string;
|
|
219
278
|
variables?: Record<string, unknown>;
|
|
220
279
|
}): Promise<void>;
|
|
221
|
-
/** Search open user tasks (optionally by process instance).
|
|
280
|
+
/** Search open user tasks (optionally by process instance). Each result carries the
|
|
281
|
+
* task's resolved form linkage (`formKey`/`externalFormReference`) when present. */
|
|
222
282
|
searchUserTasks(filter?: {
|
|
223
283
|
processInstanceKey?: string;
|
|
224
284
|
assignee?: string;
|
|
225
285
|
candidateGroup?: string;
|
|
226
|
-
}): Promise<
|
|
286
|
+
}): Promise<UserTaskSummary[]>;
|
|
287
|
+
/**
|
|
288
|
+
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
289
|
+
* `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
|
|
290
|
+
* fallback, by `formId`. An empty or whitespace-only identifier is treated as absent,
|
|
291
|
+
* so a blank `formKey` falls through to `formId`. How `formId` is addressed is
|
|
292
|
+
* adapter-specific: an adapter may pass it straight through as the lookup key, or
|
|
293
|
+
* resolve it to the latest deployed form's key. Returns `null` when no matching form
|
|
294
|
+
* exists — the caller then falls back to the no-form path.
|
|
295
|
+
*/
|
|
296
|
+
getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null>;
|
|
227
297
|
/** Complete a user task. */
|
|
228
298
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
229
299
|
/**
|