@nanobpm/urban 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +64 -2
- package/dist/runtime/adapters/node.js +61 -1
- package/dist/runtime/connector-worker-sdk.d.ts +39 -0
- package/dist/runtime/connector-worker-sdk.js +40 -0
- package/dist/runtime/core/host.d.ts +30 -0
- package/dist/runtime/core/host.js +26 -1
- package/dist/runtime/core/modules/connectors.d.ts +53 -0
- package/dist/runtime/core/modules/connectors.js +204 -0
- package/dist/runtime/core/modules/datasource.js +20 -5
- package/dist/runtime/core/modules/workers.js +5 -0
- package/dist/runtime/core/runtime.js +7 -0
- package/dist/runtime/core/validate.js +34 -8
- package/dist/runtime/engine/nanosdk.d.ts +7 -0
- package/dist/runtime/engine/nanosdk.js +27 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +5 -0
- package/dist/toolkit/addConnector.d.ts +29 -0
- package/dist/toolkit/addConnector.js +170 -0
- package/dist/toolkit/index.d.ts +2 -0
- package/dist/toolkit/index.js +1 -0
- package/package.json +2 -2
- package/src/cli.ts +60 -2
- package/src/runtime/adapters/node.ts +64 -1
- package/src/runtime/connector-worker-sdk.test.ts +56 -0
- package/src/runtime/connector-worker-sdk.ts +78 -0
- package/src/runtime/core/host.ts +40 -0
- package/src/runtime/core/modules/connectors.test.ts +206 -0
- package/src/runtime/core/modules/connectors.ts +269 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
- package/src/runtime/core/modules/datasource.ts +24 -5
- package/src/runtime/core/modules/workers.test.ts +13 -0
- package/src/runtime/core/modules/workers.ts +4 -0
- package/src/runtime/core/runtime.ts +6 -0
- package/src/runtime/core/validate.test.ts +24 -0
- package/src/runtime/core/validate.ts +32 -8
- package/src/runtime/engine/nanosdk.test.ts +76 -0
- package/src/runtime/engine/nanosdk.ts +30 -0
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/addConnector.test.ts +161 -0
- package/src/toolkit/addConnector.ts +233 -0
- package/src/toolkit/index.ts +3 -0
|
@@ -8,6 +8,7 @@ import { makeRouter } from "./router.js";
|
|
|
8
8
|
import { deployModels } from "./modules/deploy.js";
|
|
9
9
|
import { provisionData, DataLayer } from "./modules/datasource.js";
|
|
10
10
|
import { mountWorkers } from "./modules/workers.js";
|
|
11
|
+
import { mountConnectors } from "./modules/connectors.js";
|
|
11
12
|
import { mountSurfaces } from "./modules/surfaces.js";
|
|
12
13
|
import { mountTriggers } from "./modules/triggers.js";
|
|
13
14
|
import { mountSecurity } from "./modules/security.js";
|
|
@@ -122,6 +123,12 @@ export async function createUrbanApp(opts) {
|
|
|
122
123
|
const w = await mountWorkers(ctx, api);
|
|
123
124
|
mounted.push(w);
|
|
124
125
|
describe.workers = w.describe?.();
|
|
126
|
+
// Connector-pack workers share the `workers` flag: same lifecycle, same
|
|
127
|
+
// engine, distinguished only by a `connector` field on the declaration.
|
|
128
|
+
const c = await mountConnectors(ctx, api);
|
|
129
|
+
mounted.push(c);
|
|
130
|
+
if (c.jobTypes.length > 0)
|
|
131
|
+
describe.connectors = c.describe?.();
|
|
125
132
|
}
|
|
126
133
|
const routes = [];
|
|
127
134
|
if (flags.surfaces) {
|
|
@@ -56,15 +56,41 @@ export function collectManifestIssues(m) {
|
|
|
56
56
|
if (!workerJobType(w)) {
|
|
57
57
|
issues.push({ path: `workers[${i}]`, message: "missing taskType" });
|
|
58
58
|
}
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
// A worker is backed by exactly one of: a `handler` file, an `llm` binding,
|
|
60
|
+
// or an installed `connector` pack (ADR 0050). The schema models these as a
|
|
61
|
+
// oneOf; enforce the "exactly one" here without forcing `handler`. Whether a
|
|
62
|
+
// `connector` actually resolves to an installed pack is a runtime seam check
|
|
63
|
+
// (mountConnectors), not something this static manifest check can see.
|
|
64
|
+
const backings = [];
|
|
65
|
+
if (typeof w?.handler === "string" && w.handler.length > 0)
|
|
66
|
+
backings.push("handler");
|
|
67
|
+
if (typeof w?.llm === "string" && w.llm.length > 0)
|
|
68
|
+
backings.push("llm");
|
|
69
|
+
if (typeof w?.connector === "string" && w.connector.length > 0)
|
|
70
|
+
backings.push("connector");
|
|
71
|
+
if (backings.length === 0) {
|
|
72
|
+
issues.push({
|
|
73
|
+
path: `workers[${i}]`,
|
|
74
|
+
message: "worker requires a `handler`, `llm`, or `connector`",
|
|
75
|
+
});
|
|
65
76
|
}
|
|
66
|
-
else if (
|
|
67
|
-
issues.push({
|
|
77
|
+
else if (backings.length > 1) {
|
|
78
|
+
issues.push({
|
|
79
|
+
path: `workers[${i}]`,
|
|
80
|
+
message: `worker declares ${backings.join(" + ")} (mutually exclusive)`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// A `connection` must reference a declared top-level `connections` entry.
|
|
84
|
+
if (typeof w?.connection === "string" && w.connection.length > 0) {
|
|
85
|
+
const conns = man.connections && typeof man.connections === "object" && !Array.isArray(man.connections)
|
|
86
|
+
? man.connections
|
|
87
|
+
: undefined;
|
|
88
|
+
if (!conns || !(w.connection in conns)) {
|
|
89
|
+
issues.push({
|
|
90
|
+
path: `workers[${i}].connection`,
|
|
91
|
+
message: `no such connection "${w.connection}" (add it to connections)`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
68
94
|
}
|
|
69
95
|
});
|
|
70
96
|
}
|
|
@@ -16,6 +16,13 @@ export interface NanoSdkActivatedJob {
|
|
|
16
16
|
errorMessage: string;
|
|
17
17
|
retries?: number;
|
|
18
18
|
}): Promise<unknown>;
|
|
19
|
+
/** Raise a BPMN error (Zeebe `ThrowError`) routed to an error boundary/event.
|
|
20
|
+
* Present on the nano-sdk enriched job at runtime; typed here so the adapter
|
|
21
|
+
* can honour a handler's {@link BpmnError} (ADR 0050). */
|
|
22
|
+
error?(e: {
|
|
23
|
+
errorCode: string;
|
|
24
|
+
errorMessage?: string;
|
|
25
|
+
}): Promise<unknown>;
|
|
19
26
|
}
|
|
20
27
|
/** Config for a nano-sdk job worker (the subset this adapter sets). */
|
|
21
28
|
export interface NanoSdkJobWorkerConfig {
|
|
@@ -18,6 +18,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
18
18
|
}
|
|
19
19
|
return path;
|
|
20
20
|
};
|
|
21
|
+
import { isBpmnError } from "../core/host.js";
|
|
21
22
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
22
23
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
23
24
|
export function requireProcessInstanceKey(key) {
|
|
@@ -121,6 +122,32 @@ export class SdkEngineClient {
|
|
|
121
122
|
return await job.complete(out ?? {});
|
|
122
123
|
}
|
|
123
124
|
catch (err) {
|
|
125
|
+
// A handler-raised BPMN error is a modelled, non-retryable outcome:
|
|
126
|
+
// report it as a BPMN error (routed to an error boundary/event) rather
|
|
127
|
+
// than a plain failure. If the transport lacks `error` (older SDK) — or
|
|
128
|
+
// the `error` call itself fails — fall through to the `fail` path below
|
|
129
|
+
// so the job is still acknowledged rather than left to lock-timeout.
|
|
130
|
+
if (isBpmnError(err) && typeof job.error === "function") {
|
|
131
|
+
// `message` is typed loosely (the guard survives module duplication),
|
|
132
|
+
// so coerce to a string before slicing to avoid a secondary crash.
|
|
133
|
+
const raw = err.message;
|
|
134
|
+
const message = typeof raw === "string" && raw.length > 0 ? raw : err.errorCode;
|
|
135
|
+
this.log("info", `handler ${jobType} raised BPMN error`, {
|
|
136
|
+
errorCode: err.errorCode,
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
return await job.error({
|
|
140
|
+
errorCode: err.errorCode,
|
|
141
|
+
errorMessage: message.slice(0, 500),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (errReportErr) {
|
|
145
|
+
this.log("warn", `handler ${jobType}: BPMN error report failed, failing the job`, {
|
|
146
|
+
err: errReportErr instanceof Error ? errReportErr.message : String(errReportErr),
|
|
147
|
+
});
|
|
148
|
+
// fall through to the fail path
|
|
149
|
+
}
|
|
150
|
+
}
|
|
124
151
|
const message = err instanceof Error ? err.message : String(err);
|
|
125
152
|
this.log("error", `handler ${jobType} threw`, { err: message });
|
|
126
153
|
try {
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
|
|
|
6
6
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
7
7
|
export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
8
8
|
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
9
|
+
export { BpmnError, isBpmnError } from "./core/host.ts";
|
|
10
|
+
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
|
|
11
|
+
export type { ConnectorsHandle } from "./core/modules/connectors.ts";
|
|
9
12
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
|
10
13
|
export type { ProvisionedSource } from "./core/modules/datasource.ts";
|
|
11
14
|
export { makeGateway, Table } from "./core/modules/gateway.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -5,6 +5,11 @@ export { createUrbanApp } from "./core/runtime.js";
|
|
|
5
5
|
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
|
|
6
6
|
// Validation
|
|
7
7
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
|
|
8
|
+
// BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
|
|
9
|
+
// error routed to an error boundary (ADR 0050), instead of a retryable failure.
|
|
10
|
+
export { BpmnError, isBpmnError } from "./core/host.js";
|
|
11
|
+
// Connector packs (ADR 0050, in-process port)
|
|
12
|
+
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
|
|
8
13
|
// Data layer
|
|
9
14
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
|
|
10
15
|
export { makeGateway, Table } from "./core/modules/gateway.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { GenIO } from "./gen.ts";
|
|
2
|
+
export interface AddConnectorOptions {
|
|
3
|
+
/** App root directory. */
|
|
4
|
+
root: string;
|
|
5
|
+
/** The installed npm package name (already present under node_modules). */
|
|
6
|
+
pkg: string;
|
|
7
|
+
/** Manifest filename under root. Default "nano.app.json". */
|
|
8
|
+
manifestFile?: string;
|
|
9
|
+
io: GenIO;
|
|
10
|
+
}
|
|
11
|
+
export interface AddConnectorResult {
|
|
12
|
+
/** The pack id (`nano-ide.ext.json` `id`) written to each worker's `connector`. */
|
|
13
|
+
packId: string;
|
|
14
|
+
/** Workers considered, with whether one was already wired (idempotent). */
|
|
15
|
+
wired: {
|
|
16
|
+
taskType: string;
|
|
17
|
+
alreadyPresent: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
/** The named connection created/reused, if the pack needs credentials. */
|
|
20
|
+
connection?: string;
|
|
21
|
+
/** Env vars the operator must set for the enabled workers to run. */
|
|
22
|
+
requiredEnv: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read the installed pack manifest, then wire its workers into the app manifest.
|
|
26
|
+
* Idempotent: re-running never duplicates a `workers[]`/`connections[]` entry.
|
|
27
|
+
* The app manifest object is mutated in place and written back.
|
|
28
|
+
*/
|
|
29
|
+
export declare function addConnector(opts: AddConnectorOptions): Promise<AddConnectorResult>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// addConnector — enable an installed connector pack into an app's nano.app.json.
|
|
2
|
+
//
|
|
3
|
+
// A connector pack ships a `nano-ide.ext.json` manifest declaring one or more
|
|
4
|
+
// workers (a `taskType`, a worker `entry`, and its config fields). "Enabling" it
|
|
5
|
+
// means the pure, idempotent manifest edit the `urban add` CLI performs after
|
|
6
|
+
// `npm install <pkg>` (ADR 0050 §5): for every worker the pack declares, append a
|
|
7
|
+
// pack-backed `workers[]` entry (`{ taskType, connector: <pack-id>, connection }`)
|
|
8
|
+
// and, when the pack needs credentials, a named `connections[]` entry whose values
|
|
9
|
+
// are env-pointer templates (never inline secrets, ADR 0025 §1 / 0027 §5).
|
|
10
|
+
//
|
|
11
|
+
// This module only reads/writes files through the GenIO port — installing the
|
|
12
|
+
// package (a subprocess) is the CLI's job, so this stays runtime-agnostic and
|
|
13
|
+
// unit-testable.
|
|
14
|
+
const MANIFEST_FILE = "nano-ide.ext.json";
|
|
15
|
+
function joinPath(a, b) {
|
|
16
|
+
// Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
|
|
17
|
+
// root does not produce an invalid path.
|
|
18
|
+
return `${a.replace(/[/\\]+$/, "")}/${b.replace(/^[/\\]+/, "")}`;
|
|
19
|
+
}
|
|
20
|
+
/** A stable, slug-ish connection name for a pack id (kebab, no leading scope). */
|
|
21
|
+
function connectionNameFor(packId) {
|
|
22
|
+
const base = packId.includes("/") ? packId.slice(packId.lastIndexOf("/") + 1) : packId;
|
|
23
|
+
return base.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || packId;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read the installed pack manifest, then wire its workers into the app manifest.
|
|
27
|
+
* Idempotent: re-running never duplicates a `workers[]`/`connections[]` entry.
|
|
28
|
+
* The app manifest object is mutated in place and written back.
|
|
29
|
+
*/
|
|
30
|
+
export async function addConnector(opts) {
|
|
31
|
+
const { root, pkg, io } = opts;
|
|
32
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
33
|
+
const packManifestPath = joinPath(joinPath(joinPath(root, "node_modules"), pkg), MANIFEST_FILE);
|
|
34
|
+
if (!(await io.exists(packManifestPath))) {
|
|
35
|
+
throw new Error(`"${pkg}" is not an Urban connector: no ${MANIFEST_FILE} found at ${packManifestPath}. ` +
|
|
36
|
+
`Install the package first (\`npm install ${pkg}\`), and check it is a connector pack.`);
|
|
37
|
+
}
|
|
38
|
+
let pack;
|
|
39
|
+
try {
|
|
40
|
+
pack = JSON.parse(await io.readText(packManifestPath));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
throw new Error(`failed to parse ${packManifestPath}: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof pack.id !== "string" || !pack.id) {
|
|
46
|
+
throw new Error(`${packManifestPath} has no string "id" (a pack must declare its id).`);
|
|
47
|
+
}
|
|
48
|
+
const specs = Array.isArray(pack.workers) ? pack.workers : [];
|
|
49
|
+
if (specs.length === 0) {
|
|
50
|
+
throw new Error(`connector pack "${pack.id}" declares no workers[] — nothing to enable.`);
|
|
51
|
+
}
|
|
52
|
+
// `type` comes from JSON; a malformed pack must fail loudly rather than wire an
|
|
53
|
+
// invalid worker entry (or manufacture a confusing conflict).
|
|
54
|
+
for (const spec of specs) {
|
|
55
|
+
if (typeof spec.type !== "string" || spec.type.length === 0) {
|
|
56
|
+
throw new Error(`connector pack "${pack.id}" has a worker with a missing/non-string "type" in ${packManifestPath}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const appManifestPath = joinPath(root, manifestFile);
|
|
60
|
+
if (!(await io.exists(appManifestPath))) {
|
|
61
|
+
throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
|
|
62
|
+
}
|
|
63
|
+
let app;
|
|
64
|
+
try {
|
|
65
|
+
app = JSON.parse(await io.readText(appManifestPath));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw new Error(`failed to parse ${appManifestPath}: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
if (app === null || typeof app !== "object" || Array.isArray(app)) {
|
|
71
|
+
throw new Error(`${appManifestPath} is not a JSON object.`);
|
|
72
|
+
}
|
|
73
|
+
if (app.workers !== undefined && !Array.isArray(app.workers)) {
|
|
74
|
+
throw new Error(`${appManifestPath} "workers" must be an array.`);
|
|
75
|
+
}
|
|
76
|
+
if (app.connections !== undefined &&
|
|
77
|
+
(app.connections === null || typeof app.connections !== "object" || Array.isArray(app.connections))) {
|
|
78
|
+
throw new Error(`${appManifestPath} "connections" must be an object.`);
|
|
79
|
+
}
|
|
80
|
+
const workers = (app.workers ??= []);
|
|
81
|
+
// Required env across the pack's workers (env-pointer fields without a default).
|
|
82
|
+
const envFields = [];
|
|
83
|
+
const requiredEnvSet = new Set();
|
|
84
|
+
for (const spec of specs) {
|
|
85
|
+
for (const f of spec.configFields ?? []) {
|
|
86
|
+
if (f.env) {
|
|
87
|
+
envFields.push(f);
|
|
88
|
+
if (f.default === undefined || f.default === "")
|
|
89
|
+
requiredEnvSet.add(f.env);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Create/reuse a named connection when the pack carries credential config, so the
|
|
94
|
+
// required env pointers are documented in the manifest (secrets stay templates).
|
|
95
|
+
let connection;
|
|
96
|
+
if (envFields.length > 0) {
|
|
97
|
+
connection = connectionNameFor(pack.id);
|
|
98
|
+
const connections = (app.connections ??= {});
|
|
99
|
+
const existing = connections[connection];
|
|
100
|
+
if (existing === undefined) {
|
|
101
|
+
const conn = { type: pack.id };
|
|
102
|
+
for (const f of envFields) {
|
|
103
|
+
if (!(f.key in conn) && f.env)
|
|
104
|
+
conn[f.key] = `\${${f.env}}`;
|
|
105
|
+
}
|
|
106
|
+
connections[connection] = conn;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// A pre-existing connection of this name must be a compatible object of the
|
|
110
|
+
// same pack `type`; otherwise wiring workers to it would produce an invalid
|
|
111
|
+
// or misleading manifest. Fail loudly instead of silently reusing it.
|
|
112
|
+
if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
|
|
113
|
+
throw new Error(`connection "${connection}" already exists and is not an object.`);
|
|
114
|
+
}
|
|
115
|
+
const conn = existing;
|
|
116
|
+
if (conn.type !== undefined && conn.type !== pack.id) {
|
|
117
|
+
throw new Error(`connection "${connection}" already exists with type "${String(conn.type)}", ` +
|
|
118
|
+
`not "${pack.id}".`);
|
|
119
|
+
}
|
|
120
|
+
// Backfill the pack's `type` and any missing env-pointer templates.
|
|
121
|
+
if (conn.type === undefined)
|
|
122
|
+
conn.type = pack.id;
|
|
123
|
+
for (const f of envFields) {
|
|
124
|
+
if (!(f.key in conn) && f.env)
|
|
125
|
+
conn[f.key] = `\${${f.env}}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const wired = [];
|
|
130
|
+
for (const spec of specs) {
|
|
131
|
+
const existing = workers.find((w) => w.taskType === spec.type);
|
|
132
|
+
if (existing) {
|
|
133
|
+
// A same-taskType worker backed by a handler/llm or a different connector is
|
|
134
|
+
// a real conflict — enabling this pack would not take effect. Fail loudly
|
|
135
|
+
// instead of reporting a false success.
|
|
136
|
+
if (existing.connector !== pack.id) {
|
|
137
|
+
const backing = existing.handler
|
|
138
|
+
? `handler "${existing.handler}"`
|
|
139
|
+
: existing.llm
|
|
140
|
+
? `llm "${existing.llm}"`
|
|
141
|
+
: existing.connector
|
|
142
|
+
? `connector "${existing.connector}"`
|
|
143
|
+
: "another backing";
|
|
144
|
+
throw new Error(`worker "${spec.type}" is already declared with ${backing}; ` +
|
|
145
|
+
`remove it before enabling connector "${pack.id}" for that taskType.`);
|
|
146
|
+
}
|
|
147
|
+
wired.push({ taskType: spec.type, alreadyPresent: true });
|
|
148
|
+
// Heal an entry that references the pack but predates the connection.
|
|
149
|
+
if (connection && !existing.connection) {
|
|
150
|
+
existing.connection = connection;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const entry = {
|
|
155
|
+
taskType: spec.type,
|
|
156
|
+
connector: pack.id,
|
|
157
|
+
};
|
|
158
|
+
if (connection)
|
|
159
|
+
entry.connection = connection;
|
|
160
|
+
workers.push(entry);
|
|
161
|
+
wired.push({ taskType: spec.type, alreadyPresent: false });
|
|
162
|
+
}
|
|
163
|
+
await io.writeText(appManifestPath, JSON.stringify(app, null, 2) + "\n");
|
|
164
|
+
return {
|
|
165
|
+
packId: pack.id,
|
|
166
|
+
wired,
|
|
167
|
+
connection,
|
|
168
|
+
requiredEnv: [...requiredEnvSet],
|
|
169
|
+
};
|
|
170
|
+
}
|
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -29,3 +29,5 @@ export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffo
|
|
|
29
29
|
export type { ScaffoldWorker, SkippedWorker, SkipReason, StubManifestEntry, WorkerScaffoldPlan, WorkerStubPlan, } from "./scaffold/workers.ts";
|
|
30
30
|
export { scaffoldWorkers } from "./scaffold.ts";
|
|
31
31
|
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
|
32
|
+
export { addConnector } from "./addConnector.ts";
|
|
33
|
+
export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";
|
package/dist/toolkit/index.js
CHANGED
|
@@ -25,3 +25,4 @@ export { deriveModels, isWorkflow, bpmnFilename, processesOutDir, DEFAULT_WORKFL
|
|
|
25
25
|
// Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
|
|
26
26
|
export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.js";
|
|
27
27
|
export { scaffoldWorkers } from "./scaffold.js";
|
|
28
|
+
export { addConnector } from "./addConnector.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@nanobpm/nano-app-schema": "^0.1.0",
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
|
-
"@nanobpm/workflow": "^0.
|
|
67
|
+
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
69
69
|
"create-urban-app": "^0.5.0"
|
|
70
70
|
},
|
package/src/cli.ts
CHANGED
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
selectHost,
|
|
21
21
|
} from "./runtime/index.ts";
|
|
22
22
|
import { scaffold, slugify } from "create-urban-app";
|
|
23
|
-
import { createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
|
|
23
|
+
import { addConnector, createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
|
|
24
24
|
import { pathToFileURL } from "node:url";
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
26
27
|
|
|
27
28
|
// Read the package version from package.json (one hop up from both src/cli.ts and the
|
|
28
29
|
// compiled dist/cli.js) so `urban --version` never drifts from the published version.
|
|
@@ -47,6 +48,7 @@ interface Flags {
|
|
|
47
48
|
deno: boolean;
|
|
48
49
|
models: boolean;
|
|
49
50
|
stdout: boolean;
|
|
51
|
+
install: boolean;
|
|
50
52
|
style?: "model" | "code";
|
|
51
53
|
help: boolean;
|
|
52
54
|
version: boolean;
|
|
@@ -54,7 +56,7 @@ interface Flags {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function parse(argv: string[]): Flags {
|
|
57
|
-
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, help: false, version: false, _: [] };
|
|
59
|
+
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
|
|
58
60
|
const need = (i: number, flag: string): string => {
|
|
59
61
|
const v = argv[i];
|
|
60
62
|
if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
|
|
@@ -70,6 +72,8 @@ function parse(argv: string[]): Flags {
|
|
|
70
72
|
else if (a === "--no-models") f.models = false;
|
|
71
73
|
else if (a === "--models") f.models = true;
|
|
72
74
|
else if (a === "--stdout") f.stdout = true;
|
|
75
|
+
else if (a === "--no-install") f.install = false;
|
|
76
|
+
else if (a === "--install") f.install = true;
|
|
73
77
|
else if (a === "--code-first") f.style = "code";
|
|
74
78
|
else if (a === "--style") {
|
|
75
79
|
const v = need(++i, a);
|
|
@@ -103,6 +107,7 @@ Usage:
|
|
|
103
107
|
urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
|
|
104
108
|
(--stdout: print {models,incomplete} JSON without writing)
|
|
105
109
|
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
110
|
+
urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
|
|
106
111
|
urban run materialize + serve the app
|
|
107
112
|
urban dev run + watch sources, hot-reload on change
|
|
108
113
|
urban deploy deploy models only, then exit
|
|
@@ -281,6 +286,57 @@ async function cmdNew(f: Flags): Promise<number> {
|
|
|
281
286
|
return 0;
|
|
282
287
|
}
|
|
283
288
|
|
|
289
|
+
async function cmdAdd(f: Flags): Promise<number> {
|
|
290
|
+
const pkg = f._[1];
|
|
291
|
+
if (!pkg) {
|
|
292
|
+
console.error("usage: urban add <pkg> [--no-install]");
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
// Install the connector pack (a subprocess — the toolkit stays runtime-agnostic).
|
|
296
|
+
if (f.install) {
|
|
297
|
+
console.log(`Installing ${pkg} …`);
|
|
298
|
+
const res = spawnSync("npm", ["install", pkg], {
|
|
299
|
+
cwd: f.root,
|
|
300
|
+
stdio: "inherit",
|
|
301
|
+
});
|
|
302
|
+
if (res.error) {
|
|
303
|
+
console.error(`npm install failed: ${res.error.message}`);
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
if (res.signal) {
|
|
307
|
+
console.error(`npm install was terminated by signal ${res.signal}`);
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
if (res.status !== 0) {
|
|
311
|
+
console.error(`npm install exited with code ${res.status}`);
|
|
312
|
+
return res.status ?? 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const result = await addConnector({
|
|
318
|
+
root: f.root,
|
|
319
|
+
pkg,
|
|
320
|
+
manifestFile: f.manifest,
|
|
321
|
+
io: createNodeGenIO(),
|
|
322
|
+
});
|
|
323
|
+
const added = result.wired.filter((w) => !w.alreadyPresent);
|
|
324
|
+
const kept = result.wired.filter((w) => w.alreadyPresent);
|
|
325
|
+
console.log(`✔ enabled connector "${result.packId}" from ${pkg}`);
|
|
326
|
+
for (const w of added) console.log(` + worker ${w.taskType}`);
|
|
327
|
+
for (const w of kept) console.log(` = worker ${w.taskType} (already wired)`);
|
|
328
|
+
if (result.connection) console.log(` connection: ${result.connection}`);
|
|
329
|
+
if (result.requiredEnv.length > 0) {
|
|
330
|
+
console.log(`\nSet these environment variables before \`urban run\`:`);
|
|
331
|
+
for (const name of result.requiredEnv) console.log(` ${name}`);
|
|
332
|
+
}
|
|
333
|
+
return 0;
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error(`✖ ${(err as Error).message}`);
|
|
336
|
+
return 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
284
340
|
export async function main(argv: string[]): Promise<number> {
|
|
285
341
|
let f: Flags;
|
|
286
342
|
try {
|
|
@@ -310,6 +366,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
310
366
|
return cmdDerive(f);
|
|
311
367
|
case "stubs":
|
|
312
368
|
return cmdStubs(f);
|
|
369
|
+
case "add":
|
|
370
|
+
return cmdAdd(f);
|
|
313
371
|
case "run":
|
|
314
372
|
return cmdRun(f);
|
|
315
373
|
case "dev":
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// (with deno.ts) allowed to touch a concrete runtime.
|
|
3
3
|
|
|
4
4
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
5
6
|
import { watch as fsWatch, type FSWatcher } from "node:fs";
|
|
6
7
|
import { createServer } from "node:http";
|
|
8
|
+
import { register } from "node:module";
|
|
7
9
|
import { isAbsolute, resolve } from "node:path";
|
|
8
|
-
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
11
|
import { DatabaseSync } from "node:sqlite";
|
|
10
12
|
import type {
|
|
11
13
|
HostContext,
|
|
@@ -28,6 +30,57 @@ export interface NodeHostOptions {
|
|
|
28
30
|
importNonce?: string;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/** Resolve the in-process `@nanobpm/worker` shim URL relative to this adapter,
|
|
34
|
+
* tolerating both the compiled package (`.js`, the published/`urban run` path)
|
|
35
|
+
* and a from-source run (`.ts`). */
|
|
36
|
+
function connectorShimUrl(): string {
|
|
37
|
+
const js = new URL("../connector-worker-sdk.js", import.meta.url);
|
|
38
|
+
if (existsSync(fileURLToPath(js))) return js.href;
|
|
39
|
+
return new URL("../connector-worker-sdk.ts", import.meta.url).href;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let connectorHooksRegistered = false;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register (once per process) the ESM customization hooks that let a connector
|
|
46
|
+
* pack's worker `entry` be imported in-process (ADR 0050, in-process port):
|
|
47
|
+
* - a **resolve** hook aliasing the bare `@nanobpm/worker` specifier the pack
|
|
48
|
+
* imports to the runtime's shim, so its `defineWorker(...)` registers into the
|
|
49
|
+
* registry the runtime drains;
|
|
50
|
+
* - a **load** hook that strips TypeScript from ANY `.ts` module resolved under
|
|
51
|
+
* `node_modules` (Node refuses to type-strip there by default). In practice
|
|
52
|
+
* only a connector pack's `.ts` entry and its `.ts` imports travel this path,
|
|
53
|
+
* but the hook is process-global once registered, so it applies to every
|
|
54
|
+
* subsequent `node_modules` `.ts` import — a safe superset (Node would
|
|
55
|
+
* otherwise throw on such a file). The module's real URL is preserved so its
|
|
56
|
+
* own bare imports (npm deps) still resolve.
|
|
57
|
+
* The hooks run on a separate thread, so the shim URL is baked into the hook
|
|
58
|
+
* source; the shim itself loads on the main thread (shared registry instance).
|
|
59
|
+
*/
|
|
60
|
+
function ensureConnectorHooks(): void {
|
|
61
|
+
if (connectorHooksRegistered) return;
|
|
62
|
+
const shim = JSON.stringify(connectorShimUrl());
|
|
63
|
+
const src =
|
|
64
|
+
`import { readFileSync } from "node:fs";\n` +
|
|
65
|
+
`import { fileURLToPath } from "node:url";\n` +
|
|
66
|
+
`import { stripTypeScriptTypes } from "node:module";\n` +
|
|
67
|
+
`const SHIM = ${shim};\n` +
|
|
68
|
+
`export async function resolve(spec, ctx, next) {\n` +
|
|
69
|
+
` if (spec === "@nanobpm/worker") return { url: SHIM, shortCircuit: true };\n` +
|
|
70
|
+
` return next(spec, ctx);\n` +
|
|
71
|
+
`}\n` +
|
|
72
|
+
`export async function load(url, ctx, next) {\n` +
|
|
73
|
+
` const path = url.split("?")[0];\n` +
|
|
74
|
+
` if (path.endsWith(".ts") && path.includes("/node_modules/")) {\n` +
|
|
75
|
+
` const source = stripTypeScriptTypes(readFileSync(fileURLToPath(path), "utf8"), { mode: "strip" });\n` +
|
|
76
|
+
` return { format: "module", source, shortCircuit: true };\n` +
|
|
77
|
+
` }\n` +
|
|
78
|
+
` return next(url, ctx);\n` +
|
|
79
|
+
`}\n`;
|
|
80
|
+
register("data:text/javascript," + encodeURIComponent(src));
|
|
81
|
+
connectorHooksRegistered = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
32
85
|
const cwd = opts.cwd ?? process.cwd();
|
|
33
86
|
const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
@@ -69,6 +122,16 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
69
122
|
pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
70
123
|
return import(href) as Promise<Record<string, unknown>>;
|
|
71
124
|
},
|
|
125
|
+
async importConnectorModule(entry) {
|
|
126
|
+
ensureConnectorHooks();
|
|
127
|
+
// Import for side effects only: the pack's top-level `defineWorker(...)` runs
|
|
128
|
+
// and registers into the shim registry, which the caller drains. Apply the
|
|
129
|
+
// dev `importNonce` cache-buster (as importModule does) so a hot-reloaded
|
|
130
|
+
// pack entry is re-evaluated instead of served stale from the ESM cache.
|
|
131
|
+
const href =
|
|
132
|
+
pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
133
|
+
await import(href);
|
|
134
|
+
},
|
|
72
135
|
async serveHttp(port, handler) {
|
|
73
136
|
return await startNodeServer(port, handler);
|
|
74
137
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
defineWorker,
|
|
5
|
+
drainDefinedWorkers,
|
|
6
|
+
type ConnectorWorkerJob,
|
|
7
|
+
} from "./connector-worker-sdk.ts";
|
|
8
|
+
|
|
9
|
+
test("defineWorker records handlers that drainDefinedWorkers returns then clears", () => {
|
|
10
|
+
drainDefinedWorkers(); // start clean
|
|
11
|
+
defineWorker({ type: "a:one", maxParallelJobs: 3, handle: async () => ({}) });
|
|
12
|
+
defineWorker({ type: "a:two", handle: async () => {} });
|
|
13
|
+
const first = drainDefinedWorkers();
|
|
14
|
+
assert.deepEqual(
|
|
15
|
+
first.map((w) => w.type),
|
|
16
|
+
["a:one", "a:two"],
|
|
17
|
+
);
|
|
18
|
+
assert.equal(first[0].maxParallelJobs, 3);
|
|
19
|
+
// Draining empties the registry: a second drain sees nothing.
|
|
20
|
+
assert.deepEqual(drainDefinedWorkers(), []);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("defineWorker rejects a missing type or non-function handle", () => {
|
|
24
|
+
assert.throws(() => defineWorker({ type: "", handle: async () => ({}) }), /type` is required/);
|
|
25
|
+
assert.throws(
|
|
26
|
+
() => defineWorker({ type: "x" } as unknown as Parameters<typeof defineWorker>[0]),
|
|
27
|
+
/handle must be a function/,
|
|
28
|
+
);
|
|
29
|
+
// A failed defineWorker must not leave a partial registration behind.
|
|
30
|
+
assert.deepEqual(drainDefinedWorkers(), []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a registered handle receives the job facade and can complete", async () => {
|
|
34
|
+
drainDefinedWorkers();
|
|
35
|
+
let seen: ConnectorWorkerJob | undefined;
|
|
36
|
+
defineWorker({
|
|
37
|
+
type: "a:three",
|
|
38
|
+
async handle(job) {
|
|
39
|
+
seen = job;
|
|
40
|
+
await job.complete({ ok: true });
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const [w] = drainDefinedWorkers();
|
|
44
|
+
const completed: Record<string, unknown>[] = [];
|
|
45
|
+
await w.handle({
|
|
46
|
+
jobKey: "j1",
|
|
47
|
+
type: "a:three",
|
|
48
|
+
processInstanceKey: "p1",
|
|
49
|
+
variables: { n: 1 },
|
|
50
|
+
complete: async (v) => void completed.push(v ?? {}),
|
|
51
|
+
fail: async () => {},
|
|
52
|
+
error: async () => {},
|
|
53
|
+
});
|
|
54
|
+
assert.equal(seen?.jobKey, "j1");
|
|
55
|
+
assert.deepEqual(completed, [{ ok: true }]);
|
|
56
|
+
});
|