@nanobpm/urban 0.17.1 → 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 +131 -5
- 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/fsio.js +31 -2
- package/dist/toolkit/gen.d.ts +28 -0
- package/dist/toolkit/gen.js +55 -12
- package/dist/toolkit/index.d.ts +5 -1
- package/dist/toolkit/index.js +4 -1
- package/dist/toolkit/models.d.ts +63 -0
- package/dist/toolkit/models.js +142 -0
- package/package.json +2 -2
- package/src/cli.ts +128 -5
- 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/fsio.test.ts +23 -0
- package/src/toolkit/fsio.ts +22 -2
- package/src/toolkit/gen.test.ts +76 -1
- package/src/toolkit/gen.ts +81 -13
- package/src/toolkit/index.ts +16 -1
- package/src/toolkit/models.test.ts +159 -0
- package/src/toolkit/models.ts +193 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The in-process `@nanobpm/worker` shim (ADR 0050, in-process port).
|
|
2
|
+
//
|
|
3
|
+
// A connector pack's `worker.ts` is a self-contained program that imports the
|
|
4
|
+
// host-provided `@nanobpm/worker` SDK and calls `defineWorker({ type, handle })`.
|
|
5
|
+
// The old Rust host ran each such worker as its own supervised child process. The
|
|
6
|
+
// @nanobpm/urban runtime hosts app workers IN-PROCESS, so it hosts connector
|
|
7
|
+
// workers the same way: it aliases the pack's `@nanobpm/worker` import to THIS
|
|
8
|
+
// module, imports the pack entry, and reads back the registered handler(s) —
|
|
9
|
+
// which it then registers on the engine like any other worker.
|
|
10
|
+
//
|
|
11
|
+
// `defineWorker` here does NOT connect to anything. It only records the handler in
|
|
12
|
+
// a module-level registry that the runtime drains (`drainDefinedWorkers`). Because
|
|
13
|
+
// the pack and the runtime resolve `@nanobpm/worker` to the SAME module URL, they
|
|
14
|
+
// share this registry instance.
|
|
15
|
+
//
|
|
16
|
+
// This module is deliberately runtime-agnostic (no `node:*`, no `Deno`): it is the
|
|
17
|
+
// contract surface, and the Node/Deno specifics of aliasing the import live in the
|
|
18
|
+
// adapters.
|
|
19
|
+
|
|
20
|
+
/** One activated job handed to a connector worker's `handle` (the subset the
|
|
21
|
+
* `@nanobpm/worker` contract exposes; mirrors packages/connector-slack/types). */
|
|
22
|
+
export interface ConnectorWorkerJob {
|
|
23
|
+
readonly jobKey: string;
|
|
24
|
+
readonly type: string;
|
|
25
|
+
readonly processInstanceKey: string;
|
|
26
|
+
readonly elementId?: string;
|
|
27
|
+
readonly variables: Record<string, unknown>;
|
|
28
|
+
/** Complete the job, optionally with output variables. */
|
|
29
|
+
complete(variables?: Record<string, unknown>): Promise<void>;
|
|
30
|
+
/** Fail the job (retryable). */
|
|
31
|
+
fail(message: string, retries?: number): Promise<void>;
|
|
32
|
+
/** Raise a BPMN error (routed to an error boundary event). */
|
|
33
|
+
error(code: string, message?: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The options a connector worker passes to `defineWorker`. */
|
|
37
|
+
export interface ConnectorWorkerOptions {
|
|
38
|
+
type: string;
|
|
39
|
+
maxParallelJobs?: number;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
/** Ignored in-process: the runtime owns the engine connection. Accepted so a
|
|
42
|
+
* pack authored against the standalone SDK still type-checks and imports. */
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
worker?: string;
|
|
45
|
+
handle(job: ConnectorWorkerJob): Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A worker the runtime will register on the engine, drained from the registry. */
|
|
49
|
+
export interface DefinedConnectorWorker {
|
|
50
|
+
type: string;
|
|
51
|
+
maxParallelJobs?: number;
|
|
52
|
+
handle: ConnectorWorkerOptions["handle"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Module-level registry. Shared across every importer of this module URL, which is
|
|
56
|
+
// exactly how the pack's `defineWorker` call reaches the runtime's drain: both
|
|
57
|
+
// resolve `@nanobpm/worker` to this file.
|
|
58
|
+
const registered: DefinedConnectorWorker[] = [];
|
|
59
|
+
|
|
60
|
+
/** The `@nanobpm/worker` entrypoint the pack calls. Records the handler; does not
|
|
61
|
+
* connect. */
|
|
62
|
+
export function defineWorker(opts: ConnectorWorkerOptions): void {
|
|
63
|
+
if (!opts || typeof opts.type !== "string" || opts.type.length === 0) {
|
|
64
|
+
throw new Error("defineWorker: `type` is required");
|
|
65
|
+
}
|
|
66
|
+
if (typeof opts.handle !== "function") {
|
|
67
|
+
throw new Error(`defineWorker("${opts.type}"): handle must be a function`);
|
|
68
|
+
}
|
|
69
|
+
registered.push({ type: opts.type, maxParallelJobs: opts.maxParallelJobs, handle: opts.handle });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Return every worker registered since the last drain, and clear the registry.
|
|
73
|
+
* The runtime calls this immediately after importing one pack entry, so the
|
|
74
|
+
* returned set belongs to that pack. */
|
|
75
|
+
export function drainDefinedWorkers(): DefinedConnectorWorker[] {
|
|
76
|
+
const out = registered.splice(0, registered.length);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -68,6 +68,16 @@ export interface HostContext {
|
|
|
68
68
|
* between runtimes, so it lives behind the host seam.
|
|
69
69
|
*/
|
|
70
70
|
importModule(path: string): Promise<Record<string, unknown>>;
|
|
71
|
+
/**
|
|
72
|
+
* Import a connector pack's worker `entry` (ADR 0050) with the bare
|
|
73
|
+
* `@nanobpm/worker` specifier the pack imports aliased to the runtime's
|
|
74
|
+
* in-process shim (`connector-worker-sdk.ts`), so its top-level
|
|
75
|
+
* `defineWorker(...)` registers into the shared registry. `entry` is an
|
|
76
|
+
* absolute path (already resolved from the installed pack). Hosts that cannot
|
|
77
|
+
* alias a bare specifier (or run no connector workers) may omit this; the
|
|
78
|
+
* connector mount then reports the workers as unsupported rather than crashing.
|
|
79
|
+
*/
|
|
80
|
+
importConnectorModule?(entry: string): Promise<void>;
|
|
71
81
|
/** Start an HTTP server. Routing is done by the caller inside `handler`. */
|
|
72
82
|
serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
|
|
73
83
|
/**
|
|
@@ -100,6 +110,36 @@ export type JobHandler = (
|
|
|
100
110
|
job: EngineJob,
|
|
101
111
|
) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
102
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A BPMN error raised by a job handler. When a handler throws this, the engine
|
|
115
|
+
* adapter reports it to the engine as a BPMN error (`throwError`) — routed to a
|
|
116
|
+
* matching error boundary/event subprocess — instead of a plain job failure
|
|
117
|
+
* (which retries then raises an incident). This is the runtime face of a
|
|
118
|
+
* connector worker's `job.error(code, message)` (ADR 0050): a non-retryable,
|
|
119
|
+
* modelled outcome. Handlers that throw any other error are failed (retried).
|
|
120
|
+
*/
|
|
121
|
+
export class BpmnError extends Error {
|
|
122
|
+
readonly errorCode: string;
|
|
123
|
+
constructor(errorCode: string, message?: string) {
|
|
124
|
+
super(message ?? errorCode);
|
|
125
|
+
this.name = "BpmnError";
|
|
126
|
+
this.errorCode = errorCode;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Structural guard: true for a {@link BpmnError} or any error carrying a
|
|
131
|
+
* non-empty string `errorCode`, so the check survives module-duplication (a
|
|
132
|
+
* BpmnError constructed against a different copy of this module still routes as
|
|
133
|
+
* a BPMN error rather than a generic failure). */
|
|
134
|
+
export function isBpmnError(err: unknown): err is { errorCode: string; message?: string } {
|
|
135
|
+
return (
|
|
136
|
+
typeof err === "object" &&
|
|
137
|
+
err !== null &&
|
|
138
|
+
typeof (err as { errorCode?: unknown }).errorCode === "string" &&
|
|
139
|
+
(err as { errorCode: string }).errorCode.length > 0
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
103
143
|
/** A registered worker subscription. */
|
|
104
144
|
export interface WorkerSubscription {
|
|
105
145
|
readonly jobType: string;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
+
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
5
|
+
import { BpmnError } from "../host.ts";
|
|
6
|
+
import type { AppManifest } from "../manifest.ts";
|
|
7
|
+
import { defineWorker } from "../../connector-worker-sdk.ts";
|
|
8
|
+
import { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors } from "./connectors.ts";
|
|
9
|
+
|
|
10
|
+
/** A tiny engine that records registrations and can deliver a job to a handler. */
|
|
11
|
+
class MiniEngine {
|
|
12
|
+
workers = new Map<string, JobHandler>();
|
|
13
|
+
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
14
|
+
this.workers.set(jobType, handler);
|
|
15
|
+
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
16
|
+
}
|
|
17
|
+
deliver(jobType: string, job: EngineJob) {
|
|
18
|
+
const h = this.workers.get(jobType);
|
|
19
|
+
if (!h) throw new Error(`no worker for ${jobType}`);
|
|
20
|
+
return h(job);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FakeHostOptions {
|
|
25
|
+
files?: Record<string, string>;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
/** Called on importConnectorModule(entry): register the pack's workers. */
|
|
28
|
+
onImport?: (entry: string) => void;
|
|
29
|
+
/** Omit importConnectorModule entirely (host cannot host connectors). */
|
|
30
|
+
noConnectorHost?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeCtx(
|
|
34
|
+
manifest: Partial<AppManifest>,
|
|
35
|
+
engine: MiniEngine,
|
|
36
|
+
opts: FakeHostOptions = {},
|
|
37
|
+
): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
|
|
38
|
+
const logs: Array<{ level: string; msg: string }> = [];
|
|
39
|
+
const files = opts.files ?? {};
|
|
40
|
+
const host: Record<string, unknown> = {
|
|
41
|
+
runtime: "node",
|
|
42
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
43
|
+
exists: async (p: string) => p in files,
|
|
44
|
+
readTextFile: async (p: string) => {
|
|
45
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
46
|
+
return files[p];
|
|
47
|
+
},
|
|
48
|
+
importModule: () => Promise.reject(new Error("no modules in this test")),
|
|
49
|
+
};
|
|
50
|
+
if (!opts.noConnectorHost) {
|
|
51
|
+
host.importConnectorModule = async (entry: string) => {
|
|
52
|
+
opts.onImport?.(entry);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const ctx = {
|
|
56
|
+
root: "/app",
|
|
57
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest } as AppManifest,
|
|
58
|
+
engine: engine as unknown as RuntimeContext["engine"],
|
|
59
|
+
host,
|
|
60
|
+
} as unknown as RuntimeContext;
|
|
61
|
+
return { ctx, logs };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeApp(env: Record<string, string> = {}): AppApi {
|
|
65
|
+
return { env: (n: string) => env[n], log: () => {} } as unknown as AppApi;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PKG = "@nanobpm/nano-ide-connector-slack";
|
|
69
|
+
const PACK_ID = "nano-ide-connector-slack";
|
|
70
|
+
|
|
71
|
+
function slackFiles(): Record<string, string> {
|
|
72
|
+
return {
|
|
73
|
+
"/app/package.json": JSON.stringify({ dependencies: { [PKG]: "^1.0.0" } }),
|
|
74
|
+
[`/app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
|
|
75
|
+
id: PACK_ID,
|
|
76
|
+
workers: [
|
|
77
|
+
{
|
|
78
|
+
type: "slack:send-message",
|
|
79
|
+
entry: "worker.ts",
|
|
80
|
+
configFields: [{ key: "botToken", env: "SLACK_BOT_TOKEN" }],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("resolveInstalledConnectors indexes packs by their manifest id", async () => {
|
|
88
|
+
const { ctx } = makeCtx({}, new MiniEngine(), { files: slackFiles() });
|
|
89
|
+
const packs = await resolveInstalledConnectors(ctx);
|
|
90
|
+
assert.deepEqual([...packs.keys()], [PACK_ID]);
|
|
91
|
+
assert.equal(packs.get(PACK_ID)?.dir, `node_modules/${PKG}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("mountConnectors imports the pack entry and registers the drained worker", async () => {
|
|
95
|
+
const engine = new MiniEngine();
|
|
96
|
+
let importedEntry: string | undefined;
|
|
97
|
+
const { ctx, logs } = makeCtx(
|
|
98
|
+
{ workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
|
|
99
|
+
engine,
|
|
100
|
+
{
|
|
101
|
+
files: slackFiles(),
|
|
102
|
+
onImport: (entry) => {
|
|
103
|
+
importedEntry = entry;
|
|
104
|
+
defineWorker({
|
|
105
|
+
type: "slack:send-message",
|
|
106
|
+
async handle(job) {
|
|
107
|
+
await job.complete({ echoed: job.variables.text });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
const handle = await mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "xoxb-1" }));
|
|
114
|
+
assert.deepEqual(handle.jobTypes, ["slack:send-message"]);
|
|
115
|
+
assert.equal(importedEntry, `/app/node_modules/${PKG}/worker.ts`);
|
|
116
|
+
assert.ok(logs.some((l) => l.msg === "connector worker registered"));
|
|
117
|
+
|
|
118
|
+
const out = await engine.deliver("slack:send-message", {
|
|
119
|
+
jobKey: "j1",
|
|
120
|
+
jobType: "slack:send-message",
|
|
121
|
+
variables: { text: "hi" },
|
|
122
|
+
});
|
|
123
|
+
assert.deepEqual(out, { echoed: "hi" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("mountConnectors skips a worker whose required env is unset", async () => {
|
|
127
|
+
const engine = new MiniEngine();
|
|
128
|
+
const { ctx, logs } = makeCtx(
|
|
129
|
+
{ workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
|
|
130
|
+
engine,
|
|
131
|
+
{ files: slackFiles(), onImport: () => assert.fail("must not import when env is missing") },
|
|
132
|
+
);
|
|
133
|
+
const handle = await mountConnectors(ctx, makeApp()); // no SLACK_BOT_TOKEN
|
|
134
|
+
assert.deepEqual(handle.jobTypes, []);
|
|
135
|
+
assert.ok(logs.some((l) => l.msg === "skipping connector worker: required env unset"));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("mountConnectors fails closed when the connector pack is not installed", async () => {
|
|
139
|
+
const engine = new MiniEngine();
|
|
140
|
+
const { ctx } = makeCtx(
|
|
141
|
+
{ workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
|
|
142
|
+
engine,
|
|
143
|
+
{ files: { "/app/package.json": JSON.stringify({ dependencies: {} }) } },
|
|
144
|
+
);
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
() => mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "x" })),
|
|
147
|
+
/not installed/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("mountConnectors reports unsupported when the host cannot host connectors", async () => {
|
|
152
|
+
const engine = new MiniEngine();
|
|
153
|
+
const { ctx, logs } = makeCtx(
|
|
154
|
+
{ workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
|
|
155
|
+
engine,
|
|
156
|
+
{ files: slackFiles(), noConnectorHost: true },
|
|
157
|
+
);
|
|
158
|
+
const handle = await mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "x" }));
|
|
159
|
+
assert.deepEqual(handle.jobTypes, []);
|
|
160
|
+
assert.ok(logs.some((l) => l.msg === "skipping connector worker: host cannot host connector packs"));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("resolveInstalledConnectors surfaces a malformed app package.json", async () => {
|
|
164
|
+
const { ctx } = makeCtx({}, new MiniEngine(), {
|
|
165
|
+
files: { "/app/package.json": "{ not json" },
|
|
166
|
+
});
|
|
167
|
+
await assert.rejects(() => resolveInstalledConnectors(ctx), /failed to parse package\.json/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("mountConnectors fails closed on a duplicate worker type in one pack entry", async () => {
|
|
171
|
+
const engine = new MiniEngine();
|
|
172
|
+
const { ctx } = makeCtx(
|
|
173
|
+
{ workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
|
|
174
|
+
engine,
|
|
175
|
+
{
|
|
176
|
+
files: slackFiles(),
|
|
177
|
+
onImport: () => {
|
|
178
|
+
defineWorker({ type: "slack:send-message", async handle(job) { await job.complete({}); } });
|
|
179
|
+
defineWorker({ type: "slack:send-message", async handle(job) { await job.complete({}); } });
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
await assert.rejects(
|
|
184
|
+
() => mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "x" })),
|
|
185
|
+
/more than once/,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("adaptConnectorHandler maps complete/fail/error(BpmnError) and a returned value", async () => {
|
|
190
|
+
const job: EngineJob = { jobKey: "j", jobType: "t", variables: {} };
|
|
191
|
+
|
|
192
|
+
const completes = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.complete({ a: 1 })) });
|
|
193
|
+
assert.deepEqual(await completes(job), { a: 1 });
|
|
194
|
+
|
|
195
|
+
const returns = adaptConnectorHandler({ type: "t", handle: async () => ({ b: 2 }) });
|
|
196
|
+
assert.deepEqual(await returns(job), { b: 2 });
|
|
197
|
+
|
|
198
|
+
const fails = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.fail("nope")) });
|
|
199
|
+
await assert.rejects(() => Promise.resolve(fails(job)), /nope/);
|
|
200
|
+
|
|
201
|
+
const errors = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.error("CODE", "boom")) });
|
|
202
|
+
await assert.rejects(
|
|
203
|
+
() => Promise.resolve(errors(job)),
|
|
204
|
+
(e: unknown) => e instanceof BpmnError && e.errorCode === "CODE",
|
|
205
|
+
);
|
|
206
|
+
});
|