@nanobpm/urban 0.2.0 → 0.4.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 +13 -7
- package/package.json +3 -2
- package/src/index.ts +4 -1
- package/src/runtime/engine/nanosdk.test.ts +239 -85
- package/src/runtime/engine/nanosdk.ts +274 -78
- package/src/runtime/index.ts +7 -4
- package/src/runtime/run.ts +15 -14
- package/src/runtime/engine/rest.ts +0 -243
package/README.md
CHANGED
|
@@ -45,8 +45,10 @@ on Node < 23.6 the `urban` command re-executes with `--experimental-strip-types`
|
|
|
45
45
|
| `-v`, `--version` | print the version | |
|
|
46
46
|
|
|
47
47
|
The engine address comes from `$CAMUNDA_REST_ADDRESS` (default
|
|
48
|
-
`http://localhost:8080/v2`).
|
|
49
|
-
the `@nanobpm/nano-sdk`
|
|
48
|
+
`http://localhost:8080/v2`). Transport comes from `$CAMUNDA_TRANSPORT` (default
|
|
49
|
+
`auto`): the `@nanobpm/nano-sdk` client upgrades instance creation and job
|
|
50
|
+
serving to Falcon on a Nano server and falls back to REST elsewhere. Set it to
|
|
51
|
+
`rest`, `falcon`, or `embedded` to pin a specific transport.
|
|
50
52
|
|
|
51
53
|
### A typical session
|
|
52
54
|
|
|
@@ -78,19 +80,23 @@ surfaces and webhook triggers), and installs SIGINT/SIGTERM handlers for a
|
|
|
78
80
|
graceful shutdown. For full control, assemble the pieces yourself:
|
|
79
81
|
|
|
80
82
|
```ts
|
|
81
|
-
import { createUrbanApp, selectHost,
|
|
83
|
+
import { createUrbanApp, selectHost, createNanoSdkEngineClient } from "@nanobpm/urban";
|
|
82
84
|
|
|
83
85
|
const host = selectHost(); // picks the Node or Deno adapter
|
|
84
|
-
const engine =
|
|
86
|
+
const engine = await createNanoSdkEngineClient({
|
|
87
|
+
restAddress: process.env.CAMUNDA_REST_ADDRESS!,
|
|
88
|
+
transport: process.env.CAMUNDA_TRANSPORT, // "auto" (default) | "rest" | "falcon" | "embedded"
|
|
89
|
+
});
|
|
85
90
|
const app = await createUrbanApp({ host, engine, root: "." });
|
|
86
91
|
await app.start();
|
|
87
92
|
// ... later:
|
|
88
93
|
await app.stop(); // releases workers, server, datasources
|
|
89
94
|
```
|
|
90
95
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
The runtime has a single engine client, `SdkEngineClient`, backed by one
|
|
97
|
+
`@nanobpm/nano-sdk` client (a direct dependency). `createNanoSdkEngineClient`
|
|
98
|
+
selects the wire transport via `CAMUNDA_TRANSPORT`: `auto` (default) upgrades to
|
|
99
|
+
Falcon on a Nano server and falls back to REST elsewhere.
|
|
94
100
|
|
|
95
101
|
### Toolkit — derive artifacts (`urban gen`)
|
|
96
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"test:deno": "deno test -A --no-check src/"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@nanobpm/
|
|
48
|
+
"@nanobpm/nano-sdk": "^1.2.0",
|
|
49
|
+
"@nanobpm/workflow": "^0.6.0",
|
|
49
50
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
50
51
|
"create-urban-app": "^0.2.0"
|
|
51
52
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,124 +1,278 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createNanoSdkEngineClient,
|
|
5
|
+
requireProcessInstanceKey,
|
|
6
|
+
SdkEngineClient,
|
|
7
|
+
type NanoSdkActivatedJob,
|
|
8
|
+
type NanoSdkClient,
|
|
9
|
+
type NanoSdkJobWorker,
|
|
10
|
+
type NanoSdkJobWorkerConfig,
|
|
11
|
+
} from "./nanosdk.ts";
|
|
5
12
|
|
|
6
|
-
/** A
|
|
7
|
-
function
|
|
13
|
+
/** A fake nano-sdk client that records calls and lets a test drive its job worker. */
|
|
14
|
+
function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient & {
|
|
15
|
+
calls: string[];
|
|
16
|
+
deployments: File[][];
|
|
17
|
+
workers: {
|
|
18
|
+
cfg: NanoSdkJobWorkerConfig;
|
|
19
|
+
started: number;
|
|
20
|
+
stopped: number;
|
|
21
|
+
dispatch: (job: NanoSdkActivatedJob) => Promise<unknown> | unknown;
|
|
22
|
+
}[];
|
|
23
|
+
closed: number;
|
|
24
|
+
} {
|
|
8
25
|
const calls: string[] = [];
|
|
9
|
-
|
|
26
|
+
const deployments: File[][] = [];
|
|
27
|
+
const workers: {
|
|
28
|
+
cfg: NanoSdkJobWorkerConfig;
|
|
29
|
+
started: number;
|
|
30
|
+
stopped: number;
|
|
31
|
+
dispatch: (job: NanoSdkActivatedJob) => Promise<unknown> | unknown;
|
|
32
|
+
}[] = [];
|
|
33
|
+
let closed = 0;
|
|
34
|
+
|
|
35
|
+
const client: NanoSdkClient & {
|
|
36
|
+
calls: string[];
|
|
37
|
+
deployments: File[][];
|
|
38
|
+
workers: typeof workers;
|
|
39
|
+
closed: number;
|
|
40
|
+
} = {
|
|
10
41
|
calls,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
42
|
+
deployments,
|
|
43
|
+
workers,
|
|
44
|
+
get closed() {
|
|
45
|
+
return closed;
|
|
46
|
+
},
|
|
47
|
+
async createDeployment(input) {
|
|
48
|
+
calls.push("createDeployment");
|
|
49
|
+
deployments.push(input.resources);
|
|
50
|
+
return { deployments: input.resources.map((_, i) => ({ i })) };
|
|
14
51
|
},
|
|
15
|
-
async
|
|
16
|
-
calls.push("
|
|
17
|
-
return { processInstanceKey:
|
|
52
|
+
async createProcessInstance(input) {
|
|
53
|
+
calls.push("createProcessInstance");
|
|
54
|
+
return { processInstanceKey: 99, variables: input.variables };
|
|
18
55
|
},
|
|
19
|
-
async publishMessage() {
|
|
56
|
+
async publishMessage(input) {
|
|
20
57
|
calls.push("publishMessage");
|
|
58
|
+
return { key: 1, ...input };
|
|
21
59
|
},
|
|
22
60
|
async searchUserTasks() {
|
|
23
61
|
calls.push("searchUserTasks");
|
|
24
|
-
return [];
|
|
62
|
+
return { items: [] };
|
|
25
63
|
},
|
|
26
64
|
async completeUserTask() {
|
|
27
65
|
calls.push("completeUserTask");
|
|
66
|
+
return {};
|
|
28
67
|
},
|
|
29
|
-
|
|
30
|
-
calls.push("
|
|
31
|
-
|
|
68
|
+
createJobWorker(cfg) {
|
|
69
|
+
calls.push("createJobWorker");
|
|
70
|
+
const rec = { cfg, started: 0, stopped: 0, dispatch: cfg.jobHandler };
|
|
71
|
+
workers.push(rec);
|
|
72
|
+
const worker: NanoSdkJobWorker = {
|
|
73
|
+
start() {
|
|
74
|
+
rec.started++;
|
|
75
|
+
},
|
|
76
|
+
stop() {
|
|
77
|
+
rec.stopped++;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return worker;
|
|
32
81
|
},
|
|
33
|
-
|
|
34
|
-
|
|
82
|
+
close() {
|
|
83
|
+
closed++;
|
|
35
84
|
},
|
|
85
|
+
...overrides,
|
|
36
86
|
};
|
|
87
|
+
return client;
|
|
37
88
|
}
|
|
38
89
|
|
|
39
|
-
test("
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
90
|
+
test("requireProcessInstanceKey coerces and rejects empties", () => {
|
|
91
|
+
assert.equal(requireProcessInstanceKey(42), "42");
|
|
92
|
+
assert.equal(requireProcessInstanceKey("k"), "k");
|
|
93
|
+
assert.throws(() => requireProcessInstanceKey(null), /missing processInstanceKey/);
|
|
94
|
+
assert.throws(() => requireProcessInstanceKey(""), /missing processInstanceKey/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("deployResources builds web Files and calls createDeployment", async () => {
|
|
98
|
+
const client = fakeSdkClient();
|
|
99
|
+
const engine = new SdkEngineClient(client);
|
|
100
|
+
const res = await engine.deployResources([
|
|
101
|
+
{ name: "a.bpmn", content: "<x/>", contentType: "text/xml" },
|
|
102
|
+
{ name: "b.form", content: "{}", contentType: "application/json" },
|
|
103
|
+
]);
|
|
104
|
+
assert.deepEqual(res, { deployed: 2 });
|
|
105
|
+
assert.equal(client.deployments.length, 1);
|
|
106
|
+
const [f0, f1] = client.deployments[0];
|
|
107
|
+
assert.ok(f0 instanceof File);
|
|
108
|
+
assert.equal(f0.name, "a.bpmn");
|
|
109
|
+
assert.equal(f0.type, "text/xml");
|
|
110
|
+
assert.equal(await f0.text(), "<x/>");
|
|
111
|
+
assert.equal(f1.name, "b.form");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("createInstance routes through the SDK and coerces the key", async () => {
|
|
115
|
+
const client = fakeSdkClient();
|
|
116
|
+
const engine = new SdkEngineClient(client);
|
|
117
|
+
const res = await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
|
|
118
|
+
assert.equal(res.processInstanceKey, "99");
|
|
119
|
+
assert.deepEqual(res.variables, { a: 1 });
|
|
120
|
+
assert.ok(client.calls.includes("createProcessInstance"));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("createInstance throws when the SDK response omits the instance key", async () => {
|
|
124
|
+
const client = fakeSdkClient({
|
|
125
|
+
createProcessInstance: async () => ({ variables: { ok: true } }),
|
|
52
126
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
assert.ok(!fallback.calls.includes("createInstance"), "did not use REST for create");
|
|
127
|
+
const engine = new SdkEngineClient(client);
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
() => engine.createInstance({ processDefinitionId: "p" }),
|
|
130
|
+
/missing processInstanceKey/,
|
|
131
|
+
);
|
|
59
132
|
});
|
|
60
133
|
|
|
61
|
-
test("
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
134
|
+
test("publishMessage defaults correlationKey/variables", async () => {
|
|
135
|
+
let seen: Record<string, unknown> | undefined;
|
|
136
|
+
const client = fakeSdkClient({
|
|
137
|
+
publishMessage: async (input) => {
|
|
138
|
+
seen = input;
|
|
139
|
+
return {};
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const engine = new SdkEngineClient(client);
|
|
143
|
+
await engine.publishMessage({ name: "m" });
|
|
144
|
+
assert.deepEqual(seen, { name: "m", correlationKey: "", variables: {} });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("searchUserTasks passes zero-wait consistency and maps items", async () => {
|
|
148
|
+
let consistency: unknown;
|
|
149
|
+
const client = fakeSdkClient({
|
|
150
|
+
searchUserTasks: async (_input, c) => {
|
|
151
|
+
consistency = c;
|
|
152
|
+
return {
|
|
153
|
+
items: [
|
|
154
|
+
{ userTaskKey: 7, elementId: "task_a", variables: { x: 1 } },
|
|
155
|
+
{ key: "" }, // keyless → skipped
|
|
156
|
+
{ userTaskKey: "8" },
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
},
|
|
67
160
|
});
|
|
68
|
-
|
|
69
|
-
await engine
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"deployResources",
|
|
75
|
-
"publishMessage",
|
|
76
|
-
"searchUserTasks",
|
|
77
|
-
"completeUserTask",
|
|
78
|
-
"registerWorker",
|
|
161
|
+
const engine = new SdkEngineClient(client);
|
|
162
|
+
const tasks = await engine.searchUserTasks({ processInstanceKey: "pi" });
|
|
163
|
+
assert.deepEqual(consistency, { consistency: { waitUpToMs: 0 } });
|
|
164
|
+
assert.deepEqual(tasks, [
|
|
165
|
+
{ userTaskKey: "7", elementId: "task_a", variables: { x: 1 } },
|
|
166
|
+
{ userTaskKey: "8", elementId: undefined, variables: undefined },
|
|
79
167
|
]);
|
|
80
168
|
});
|
|
81
169
|
|
|
82
|
-
test("
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
170
|
+
test("completeUserTask routes through the SDK", async () => {
|
|
171
|
+
let seen: unknown;
|
|
172
|
+
const client = fakeSdkClient({
|
|
173
|
+
completeUserTask: async (input) => {
|
|
174
|
+
seen = input;
|
|
175
|
+
return {};
|
|
87
176
|
},
|
|
88
177
|
});
|
|
89
|
-
|
|
178
|
+
const engine = new SdkEngineClient(client);
|
|
179
|
+
await engine.completeUserTask("utk", { done: true });
|
|
180
|
+
assert.deepEqual(seen, { userTaskKey: "utk", variables: { done: true } });
|
|
90
181
|
});
|
|
91
182
|
|
|
92
|
-
test("
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
183
|
+
test("registerWorker creates a worker (autoStart false), starts it, and dispatches + completes", async () => {
|
|
184
|
+
const client = fakeSdkClient();
|
|
185
|
+
const engine = new SdkEngineClient(client);
|
|
186
|
+
const handled: Record<string, unknown>[] = [];
|
|
187
|
+
const sub = await engine.registerWorker(
|
|
188
|
+
"svc",
|
|
189
|
+
(job) => {
|
|
190
|
+
handled.push(job.variables);
|
|
191
|
+
return { out: job.variables.n };
|
|
192
|
+
},
|
|
193
|
+
{ maxParallelJobs: 4 },
|
|
194
|
+
);
|
|
195
|
+
assert.equal(sub.jobType, "svc");
|
|
196
|
+
const rec = client.workers[0];
|
|
197
|
+
assert.equal(rec.cfg.autoStart, false);
|
|
198
|
+
assert.equal(rec.cfg.workerName, "urban:svc");
|
|
199
|
+
assert.equal(rec.cfg.maxParallelJobs, 4);
|
|
200
|
+
assert.equal(rec.started, 1);
|
|
201
|
+
|
|
202
|
+
let completedWith: Record<string, unknown> | undefined;
|
|
203
|
+
await rec.dispatch({
|
|
204
|
+
jobKey: 12,
|
|
205
|
+
processInstanceKey: 34,
|
|
206
|
+
elementId: "e1",
|
|
207
|
+
variables: { n: 5 },
|
|
208
|
+
async complete(v?: Record<string, unknown>) {
|
|
209
|
+
completedWith = v;
|
|
210
|
+
return "receipt";
|
|
211
|
+
},
|
|
212
|
+
async fail() {
|
|
213
|
+
throw new Error("should not fail");
|
|
214
|
+
},
|
|
215
|
+
} as unknown as NanoSdkActivatedJob);
|
|
216
|
+
assert.deepEqual(handled, [{ n: 5 }]);
|
|
217
|
+
assert.deepEqual(completedWith, { out: 5 });
|
|
218
|
+
|
|
219
|
+
await sub.unsubscribe();
|
|
220
|
+
assert.equal(rec.stopped, 1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("registerWorker fails the job when the handler throws", async () => {
|
|
224
|
+
const client = fakeSdkClient();
|
|
225
|
+
const engine = new SdkEngineClient(client);
|
|
226
|
+
await engine.registerWorker("svc", () => {
|
|
227
|
+
throw new Error("boom");
|
|
104
228
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
229
|
+
const rec = client.workers[0];
|
|
230
|
+
let failBody: { errorMessage: string; retries?: number } | undefined;
|
|
231
|
+
await rec.dispatch({
|
|
232
|
+
jobKey: "j1",
|
|
233
|
+
variables: {},
|
|
234
|
+
async complete() {
|
|
235
|
+
throw new Error("should not complete");
|
|
236
|
+
},
|
|
237
|
+
async fail(body: { errorMessage: string; retries?: number }) {
|
|
238
|
+
failBody = body;
|
|
239
|
+
return "failed";
|
|
240
|
+
},
|
|
241
|
+
} as unknown as NanoSdkActivatedJob);
|
|
242
|
+
assert.equal(failBody?.errorMessage, "boom");
|
|
243
|
+
assert.equal(failBody?.retries, 0);
|
|
108
244
|
});
|
|
109
245
|
|
|
110
|
-
test("
|
|
111
|
-
const
|
|
246
|
+
test("close stops every worker and closes the SDK client", async () => {
|
|
247
|
+
const client = fakeSdkClient();
|
|
248
|
+
const engine = new SdkEngineClient(client);
|
|
249
|
+
await engine.registerWorker("a", async () => ({}));
|
|
250
|
+
await engine.registerWorker("b", async () => ({}));
|
|
251
|
+
await engine.close();
|
|
252
|
+
assert.equal(client.workers[0].stopped, 1);
|
|
253
|
+
assert.equal(client.workers[1].stopped, 1);
|
|
254
|
+
assert.equal(client.closed, 1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("createNanoSdkEngineClient uses an injected client", async () => {
|
|
258
|
+
const client = fakeSdkClient();
|
|
259
|
+
const engine = await createNanoSdkEngineClient({ restAddress: "http://x/v2", client });
|
|
260
|
+
await engine.createInstance({ processDefinitionId: "p" });
|
|
261
|
+
assert.ok(client.calls.includes("createProcessInstance"));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("createNanoSdkEngineClient uses an injected client factory with the resolved transport", async () => {
|
|
265
|
+
let seen: { restAddress: string; token?: string; transport?: string } | undefined;
|
|
266
|
+
const client = fakeSdkClient();
|
|
112
267
|
const engine = await createNanoSdkEngineClient({
|
|
113
268
|
restAddress: "http://x/v2",
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
269
|
+
token: "t",
|
|
270
|
+
transport: "falcon",
|
|
271
|
+
createClient: (o) => {
|
|
272
|
+
seen = o;
|
|
273
|
+
return client;
|
|
274
|
+
},
|
|
118
275
|
});
|
|
276
|
+
assert.deepEqual(seen, { restAddress: "http://x/v2", token: "t", transport: "falcon" });
|
|
119
277
|
assert.ok(engine);
|
|
120
|
-
await assert.rejects(
|
|
121
|
-
() => engine!.createInstance({ processDefinitionId: "p" }),
|
|
122
|
-
/missing processInstanceKey/,
|
|
123
|
-
);
|
|
124
278
|
});
|
|
@@ -1,19 +1,75 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The Urban runtime's single engine client. Every path to a Nano engine — deploy,
|
|
2
|
+
// instance creation, message publication, user tasks, and job workers — routes
|
|
3
|
+
// through one `@nanobpm/nano-sdk` client (ADR 0055). The SDK's `createCamundaClient`
|
|
4
|
+
// transparently upgrades the throughput-critical paths (process-instance creation and
|
|
5
|
+
// job serving) to the Falcon protocol on a Nano server and falls back to REST
|
|
6
|
+
// everywhere else, so the runtime and the IDE talk to the engine the same way with a
|
|
7
|
+
// single transport instead of a hand-rolled REST client plus a Falcon shim.
|
|
6
8
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// `@nanobpm/nano-sdk` is a direct dependency, but it is imported lazily (via an
|
|
10
|
+
// indirected specifier) so a caller that injects its own client — unit tests, the
|
|
11
|
+
// Deno smoke, or an author bringing the embedded transport — never loads it, keeping
|
|
12
|
+
// a dependency-free import graph for those paths.
|
|
11
13
|
|
|
12
|
-
import type {
|
|
13
|
-
|
|
14
|
+
import type {
|
|
15
|
+
EngineClient,
|
|
16
|
+
EngineJob,
|
|
17
|
+
JobHandler,
|
|
18
|
+
WorkerSubscription,
|
|
19
|
+
} from "../core/host.ts";
|
|
14
20
|
|
|
15
|
-
/**
|
|
21
|
+
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
22
|
+
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
23
|
+
export function requireProcessInstanceKey(key: string | number | null | undefined): string {
|
|
24
|
+
if (key == null || key === "") {
|
|
25
|
+
throw new Error("engine response missing processInstanceKey/key");
|
|
26
|
+
}
|
|
27
|
+
return String(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A job as delivered to a nano-sdk job handler: the frame fields plus the
|
|
31
|
+
* acknowledgement actions the handler must call. */
|
|
32
|
+
export interface NanoSdkActivatedJob {
|
|
33
|
+
jobKey: string;
|
|
34
|
+
type?: string;
|
|
35
|
+
processInstanceKey?: string | number;
|
|
36
|
+
elementId?: string;
|
|
37
|
+
variables?: Record<string, unknown>;
|
|
38
|
+
complete(variables?: Record<string, unknown>): Promise<unknown>;
|
|
39
|
+
fail(body: { errorMessage: string; retries?: number }): Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Config for a nano-sdk job worker (the subset this adapter sets). */
|
|
43
|
+
export interface NanoSdkJobWorkerConfig {
|
|
44
|
+
jobType: string;
|
|
45
|
+
jobHandler: (job: NanoSdkActivatedJob) => Promise<unknown> | unknown;
|
|
46
|
+
workerName?: string;
|
|
47
|
+
maxParallelJobs?: number;
|
|
48
|
+
jobTimeoutMs?: number;
|
|
49
|
+
pollTimeoutMs?: number;
|
|
50
|
+
fetchVariables?: string[];
|
|
51
|
+
/** Start polling immediately. This adapter sets it false and starts explicitly. */
|
|
52
|
+
autoStart?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The handle returned by `createJobWorker`. */
|
|
56
|
+
export interface NanoSdkJobWorker {
|
|
57
|
+
start(): void | Promise<void>;
|
|
58
|
+
stop(): void | Promise<void>;
|
|
59
|
+
stopGracefully?(opts?: { waitUpToMs?: number }): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The subset of the `@nanobpm/nano-sdk` (Camunda orchestration-cluster) client the
|
|
64
|
+
* engine adapter uses. `createCamundaClient` returns a superset of this, so a test —
|
|
65
|
+
* or an author bringing their own transport (e.g. the embedded engine) — can inject
|
|
66
|
+
* any object satisfying it.
|
|
67
|
+
*/
|
|
16
68
|
export interface NanoSdkClient {
|
|
69
|
+
createDeployment(
|
|
70
|
+
input: { resources: File[]; [k: string]: unknown },
|
|
71
|
+
options?: unknown,
|
|
72
|
+
): Promise<Record<string, unknown>>;
|
|
17
73
|
createProcessInstance(
|
|
18
74
|
input: {
|
|
19
75
|
processDefinitionId: string;
|
|
@@ -22,26 +78,205 @@ export interface NanoSdkClient {
|
|
|
22
78
|
},
|
|
23
79
|
options?: unknown,
|
|
24
80
|
): Promise<Record<string, unknown>>;
|
|
81
|
+
publishMessage(
|
|
82
|
+
input: { name: string; correlationKey?: string; variables?: Record<string, unknown> },
|
|
83
|
+
options?: unknown,
|
|
84
|
+
): Promise<Record<string, unknown>>;
|
|
85
|
+
searchUserTasks(
|
|
86
|
+
input: { filter?: Record<string, unknown> },
|
|
87
|
+
consistency?: unknown,
|
|
88
|
+
options?: unknown,
|
|
89
|
+
): Promise<Record<string, unknown>>;
|
|
90
|
+
completeUserTask(
|
|
91
|
+
input: { userTaskKey: string; variables?: Record<string, unknown> },
|
|
92
|
+
options?: unknown,
|
|
93
|
+
): Promise<unknown>;
|
|
94
|
+
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
25
95
|
close?(): void | Promise<void>;
|
|
26
96
|
}
|
|
27
97
|
|
|
98
|
+
type Log = (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
|
|
102
|
+
* message, and user-task calls map one-to-one onto the SDK's client methods; workers
|
|
103
|
+
* are the SDK's own job worker, which handles activation, completion, failure, and
|
|
104
|
+
* long-poll/backoff reconnect behind the same transport.
|
|
105
|
+
*/
|
|
106
|
+
export class SdkEngineClient implements EngineClient {
|
|
107
|
+
private readonly workers = new Set<NanoSdkJobWorker>();
|
|
108
|
+
private readonly client: NanoSdkClient;
|
|
109
|
+
private readonly log: Log;
|
|
110
|
+
|
|
111
|
+
constructor(client: NanoSdkClient, log: Log = () => {}) {
|
|
112
|
+
this.client = client;
|
|
113
|
+
this.log = log;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async deployResources(
|
|
117
|
+
resources: { name: string; content: string; contentType: string }[],
|
|
118
|
+
): Promise<{ deployed: number }> {
|
|
119
|
+
const files = resources.map((r) => new File([r.content], r.name, { type: r.contentType }));
|
|
120
|
+
await this.client.createDeployment({ resources: files });
|
|
121
|
+
return { deployed: resources.length };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async createInstance(input: {
|
|
125
|
+
processDefinitionId: string;
|
|
126
|
+
variables?: Record<string, unknown>;
|
|
127
|
+
awaitCompletion?: boolean;
|
|
128
|
+
}): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }> {
|
|
129
|
+
const body = await this.client.createProcessInstance({
|
|
130
|
+
processDefinitionId: input.processDefinitionId,
|
|
131
|
+
variables: input.variables ?? {},
|
|
132
|
+
awaitCompletion: input.awaitCompletion ?? false,
|
|
133
|
+
});
|
|
134
|
+
const key =
|
|
135
|
+
(body.processInstanceKey as string | number | undefined) ??
|
|
136
|
+
(body.key as string | number | undefined);
|
|
137
|
+
return {
|
|
138
|
+
processInstanceKey: requireProcessInstanceKey(key),
|
|
139
|
+
variables: (body.variables as Record<string, unknown> | undefined) ?? undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async publishMessage(input: {
|
|
144
|
+
name: string;
|
|
145
|
+
correlationKey?: string;
|
|
146
|
+
variables?: Record<string, unknown>;
|
|
147
|
+
}): Promise<void> {
|
|
148
|
+
await this.client.publishMessage({
|
|
149
|
+
name: input.name,
|
|
150
|
+
correlationKey: input.correlationKey ?? "",
|
|
151
|
+
variables: input.variables ?? {},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async searchUserTasks(filter?: {
|
|
156
|
+
processInstanceKey?: string;
|
|
157
|
+
assignee?: string;
|
|
158
|
+
candidateGroup?: string;
|
|
159
|
+
}): Promise<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]> {
|
|
160
|
+
// User tasks are an eventually consistent read; ask for zero-wait consistency so
|
|
161
|
+
// the search reflects what is currently visible without blocking.
|
|
162
|
+
const body = await this.client.searchUserTasks(
|
|
163
|
+
{ filter: (filter ?? {}) as Record<string, unknown> },
|
|
164
|
+
{ consistency: { waitUpToMs: 0 } },
|
|
165
|
+
);
|
|
166
|
+
const items = (body.items as Record<string, unknown>[] | undefined) ?? [];
|
|
167
|
+
return items.flatMap((it) => {
|
|
168
|
+
const userTaskKey = it.userTaskKey ?? it.key;
|
|
169
|
+
if (userTaskKey == null || userTaskKey === "") {
|
|
170
|
+
this.log("warn", "skipping user task with no key in engine response");
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return [{
|
|
174
|
+
userTaskKey: String(userTaskKey),
|
|
175
|
+
elementId: it.elementId as string | undefined,
|
|
176
|
+
variables: it.variables as Record<string, unknown> | undefined,
|
|
177
|
+
}];
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
|
|
182
|
+
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async registerWorker(
|
|
186
|
+
jobType: string,
|
|
187
|
+
handler: JobHandler,
|
|
188
|
+
options?: { workerName?: string; maxParallelJobs?: number; fetchVariables?: string[] },
|
|
189
|
+
): Promise<WorkerSubscription> {
|
|
190
|
+
const worker = this.client.createJobWorker({
|
|
191
|
+
jobType,
|
|
192
|
+
workerName: options?.workerName ?? `urban:${jobType}`,
|
|
193
|
+
maxParallelJobs: options?.maxParallelJobs ?? 8,
|
|
194
|
+
fetchVariables: options?.fetchVariables,
|
|
195
|
+
// Own the poll lifecycle here rather than leaving it to the SDK's auto-start
|
|
196
|
+
// default, so nothing polls until `start()` is called below.
|
|
197
|
+
autoStart: false,
|
|
198
|
+
jobHandler: async (job) => {
|
|
199
|
+
const rawKey = job.jobKey;
|
|
200
|
+
if (rawKey == null || rawKey === "") {
|
|
201
|
+
this.log("warn", `activation ${jobType}: skipping job with no jobKey in engine response`);
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const engineJob: EngineJob = {
|
|
205
|
+
jobKey: String(rawKey),
|
|
206
|
+
jobType,
|
|
207
|
+
processInstanceKey:
|
|
208
|
+
job.processInstanceKey != null ? String(job.processInstanceKey) : undefined,
|
|
209
|
+
elementId: job.elementId,
|
|
210
|
+
variables: job.variables ?? {},
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
const out = await handler(engineJob);
|
|
214
|
+
return await job.complete((out as Record<string, unknown> | undefined) ?? {});
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
this.log("error", `handler ${jobType} threw`, { err: message });
|
|
218
|
+
try {
|
|
219
|
+
// Best-effort failure report; the engine redelivers after the lock
|
|
220
|
+
// times out (at-least-once → handlers must be idempotent). Await +
|
|
221
|
+
// swallow so a rejected `fail` cannot escape as an unhandled rejection.
|
|
222
|
+
return await job.fail({ errorMessage: message.slice(0, 500), retries: 0 });
|
|
223
|
+
} catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
await worker.start();
|
|
230
|
+
this.workers.add(worker);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
jobType,
|
|
234
|
+
unsubscribe: async () => {
|
|
235
|
+
await this.stopWorker(worker);
|
|
236
|
+
this.workers.delete(worker);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Stop one worker, tolerating a Falcon worker whose transport never bound
|
|
242
|
+
* (detection still pending) — its `stop` throws, which must not fail teardown. */
|
|
243
|
+
private async stopWorker(worker: NanoSdkJobWorker): Promise<void> {
|
|
244
|
+
try {
|
|
245
|
+
await (worker.stopGracefully ? worker.stopGracefully() : worker.stop());
|
|
246
|
+
} catch {
|
|
247
|
+
/* worker never fully started (transport unbound); nothing to drain */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async close(): Promise<void> {
|
|
252
|
+
for (const w of this.workers) await this.stopWorker(w);
|
|
253
|
+
this.workers.clear();
|
|
254
|
+
try {
|
|
255
|
+
await this.client.close?.();
|
|
256
|
+
} catch {
|
|
257
|
+
/* transport already closed / never opened */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
28
262
|
export interface NanoSdkEngineOptions {
|
|
29
263
|
/** REST base, e.g. http://localhost:8080/v2. */
|
|
30
264
|
restAddress: string;
|
|
31
265
|
token?: string;
|
|
32
|
-
/** CAMUNDA_TRANSPORT: "auto" | "falcon" | "rest". Passed to createCamundaClient. */
|
|
266
|
+
/** CAMUNDA_TRANSPORT: "auto" | "falcon" | "rest" | "embedded". Passed to createCamundaClient. */
|
|
33
267
|
transport?: string;
|
|
34
|
-
log?:
|
|
268
|
+
log?: Log;
|
|
269
|
+
/** Test seam: inject a ready-made SDK client (or a compatible fake). */
|
|
270
|
+
client?: NanoSdkClient;
|
|
35
271
|
/** Test seam: provide the SDK client factory instead of importing @nanobpm/nano-sdk. */
|
|
36
272
|
createClient?: (opts: { restAddress: string; token?: string; transport?: string }) => NanoSdkClient;
|
|
37
|
-
/** Test seam / customisation: the cold-path client. Defaults to a RestEngineClient. */
|
|
38
|
-
fallback?: EngineClient;
|
|
39
273
|
}
|
|
40
274
|
|
|
41
275
|
async function importNanoSdk(): Promise<
|
|
42
276
|
(opts: Record<string, unknown>) => NanoSdkClient
|
|
43
277
|
> {
|
|
44
|
-
// Indirect the specifier so the (
|
|
278
|
+
// Indirect the specifier so the (lazily loaded) module is not resolved at
|
|
279
|
+
// typecheck time and stays out of the import graph for injected-client paths.
|
|
45
280
|
const spec = "@nanobpm/nano-sdk";
|
|
46
281
|
const mod = (await import(spec)) as {
|
|
47
282
|
createCamundaClient?: (opts: Record<string, unknown>) => NanoSdkClient;
|
|
@@ -53,71 +288,32 @@ async function importNanoSdk(): Promise<
|
|
|
53
288
|
}
|
|
54
289
|
|
|
55
290
|
/**
|
|
56
|
-
* Build
|
|
57
|
-
*
|
|
58
|
-
*
|
|
291
|
+
* Build the Urban runtime's `EngineClient`, backed by a single `@nanobpm/nano-sdk`
|
|
292
|
+
* client. Provide a `client`/`createClient` seam to inject a fake (tests, embedded
|
|
293
|
+
* transport); otherwise a client is constructed from `restAddress`/`token`/`transport`.
|
|
59
294
|
*/
|
|
60
295
|
export async function createNanoSdkEngineClient(
|
|
61
296
|
opts: NanoSdkEngineOptions,
|
|
62
|
-
): Promise<EngineClient
|
|
297
|
+
): Promise<EngineClient> {
|
|
63
298
|
const log = opts.log ?? (() => {});
|
|
64
299
|
let client: NanoSdkClient;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
} catch (err) {
|
|
83
|
-
log("warn", "nano-sdk unavailable; using REST transport", { error: String(err) });
|
|
84
|
-
return null;
|
|
300
|
+
if (opts.client) {
|
|
301
|
+
client = opts.client;
|
|
302
|
+
} else if (opts.createClient) {
|
|
303
|
+
client = opts.createClient({
|
|
304
|
+
restAddress: opts.restAddress,
|
|
305
|
+
token: opts.token,
|
|
306
|
+
transport: opts.transport,
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
const createCamundaClient = await importNanoSdk();
|
|
310
|
+
client = createCamundaClient({
|
|
311
|
+
config: {
|
|
312
|
+
CAMUNDA_REST_ADDRESS: opts.restAddress,
|
|
313
|
+
...(opts.token ? { CAMUNDA_TOKEN: opts.token } : {}),
|
|
314
|
+
CAMUNDA_TRANSPORT: opts.transport ?? "auto",
|
|
315
|
+
},
|
|
316
|
+
});
|
|
85
317
|
}
|
|
86
|
-
|
|
87
|
-
const fallback =
|
|
88
|
-
opts.fallback ?? new RestEngineClient({ baseUrl: opts.restAddress, token: opts.token, log });
|
|
89
|
-
|
|
90
|
-
const adapter: EngineClient = {
|
|
91
|
-
deployResources: (resources) => fallback.deployResources(resources),
|
|
92
|
-
|
|
93
|
-
async createInstance(input) {
|
|
94
|
-
const body = await client.createProcessInstance({
|
|
95
|
-
processDefinitionId: input.processDefinitionId,
|
|
96
|
-
variables: input.variables,
|
|
97
|
-
awaitCompletion: input.awaitCompletion ?? false,
|
|
98
|
-
});
|
|
99
|
-
const key =
|
|
100
|
-
(body.processInstanceKey as string | number | undefined) ?? (body.key as string | number | undefined);
|
|
101
|
-
return {
|
|
102
|
-
processInstanceKey: requireProcessInstanceKey(key),
|
|
103
|
-
variables: (body.variables as Record<string, unknown> | undefined) ?? undefined,
|
|
104
|
-
};
|
|
105
|
-
},
|
|
106
|
-
|
|
107
|
-
publishMessage: (input) => fallback.publishMessage(input),
|
|
108
|
-
searchUserTasks: (filter) => fallback.searchUserTasks(filter),
|
|
109
|
-
completeUserTask: (key, vars) => fallback.completeUserTask(key, vars),
|
|
110
|
-
|
|
111
|
-
registerWorker: (jobType: string, handler: JobHandler, options): Promise<WorkerSubscription> =>
|
|
112
|
-
fallback.registerWorker(jobType, handler, options),
|
|
113
|
-
|
|
114
|
-
async close() {
|
|
115
|
-
try {
|
|
116
|
-
await client.close?.();
|
|
117
|
-
} finally {
|
|
118
|
-
await fallback.close();
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
return adapter;
|
|
318
|
+
return new SdkEngineClient(client, log);
|
|
123
319
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -60,9 +60,12 @@ export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
|
60
60
|
export { createNodeHost } from "./adapters/node.ts";
|
|
61
61
|
export { createDenoHost } from "./adapters/deno.ts";
|
|
62
62
|
export { isDeno, selectHost } from "./adapters/detect.ts";
|
|
63
|
-
export {
|
|
64
|
-
export type {
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
export { createNanoSdkEngineClient, SdkEngineClient } from "./engine/nanosdk.ts";
|
|
64
|
+
export type {
|
|
65
|
+
NanoSdkClient,
|
|
66
|
+
NanoSdkEngineOptions,
|
|
67
|
+
NanoSdkJobWorker,
|
|
68
|
+
NanoSdkJobWorkerConfig,
|
|
69
|
+
} from "./engine/nanosdk.ts";
|
|
67
70
|
export { runFromEnv } from "./run.ts";
|
|
68
71
|
export type { RunOptions } from "./run.ts";
|
package/src/runtime/run.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// runFromEnv — the one-call entrypoint a scaffolded app's main and the `urban run` CLI both
|
|
2
|
-
// use: select the host for the current runtime, build
|
|
3
|
-
// the Urban app, start it, and install a graceful-shutdown handler.
|
|
2
|
+
// use: select the host for the current runtime, build the nano-sdk engine client from env,
|
|
3
|
+
// create the Urban app, start it, and install a graceful-shutdown handler.
|
|
4
4
|
|
|
5
5
|
import { createUrbanApp, type CreateUrbanAppOptions, type UrbanApp } from "./core/runtime.ts";
|
|
6
6
|
import { selectHost } from "./adapters/detect.ts";
|
|
7
|
-
import { RestEngineClient } from "./engine/rest.ts";
|
|
8
7
|
import { createNanoSdkEngineClient } from "./engine/nanosdk.ts";
|
|
9
8
|
import type { EngineClient } from "./core/host.ts";
|
|
10
9
|
|
|
@@ -14,9 +13,10 @@ export interface RunOptions extends Partial<CreateUrbanAppOptions> {
|
|
|
14
13
|
/** Engine REST base. Default $CAMUNDA_REST_ADDRESS or http://localhost:8080/v2. */
|
|
15
14
|
restAddress?: string;
|
|
16
15
|
/**
|
|
17
|
-
* Engine transport
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Engine transport passed to `@nanobpm/nano-sdk`: "auto" (default) upgrades
|
|
17
|
+
* process-instance creation and job serving to the Falcon protocol on a Nano
|
|
18
|
+
* server and falls back to REST elsewhere; "falcon" forces it; "rest" never
|
|
19
|
+
* upgrades; "embedded" runs an in-process engine. Overridable via $CAMUNDA_TRANSPORT.
|
|
20
20
|
*/
|
|
21
21
|
transport?: string;
|
|
22
22
|
/** Install SIGINT/SIGTERM handlers to stop the app. Default true. */
|
|
@@ -27,15 +27,16 @@ export async function runFromEnv(opts: RunOptions = {}): Promise<UrbanApp> {
|
|
|
27
27
|
const host = opts.host ?? selectHost({ cwd: opts.root });
|
|
28
28
|
const restAddress =
|
|
29
29
|
opts.restAddress ?? host.env("CAMUNDA_REST_ADDRESS") ?? "http://localhost:8080/v2";
|
|
30
|
-
const transport = opts.transport ?? host.env("CAMUNDA_TRANSPORT") ?? "
|
|
30
|
+
const transport = opts.transport ?? host.env("CAMUNDA_TRANSPORT") ?? "auto";
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
const engine: EngineClient =
|
|
33
|
+
opts.engine ??
|
|
34
|
+
(await createNanoSdkEngineClient({
|
|
35
|
+
restAddress,
|
|
36
|
+
token: host.env("CAMUNDA_TOKEN"),
|
|
37
|
+
transport,
|
|
38
|
+
log: host.log,
|
|
39
|
+
}));
|
|
39
40
|
|
|
40
41
|
const app = await createUrbanApp({
|
|
41
42
|
host,
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
// A REST-backed EngineClient using only global `fetch`/`FormData`/`AbortController` — all
|
|
2
|
-
// present in both Node 18+ and Deno, so this stays runtime-agnostic. Workers drain via the
|
|
3
|
-
// v2 job-activation long-poll (no Falcon push dependency); the SDK-backed client can add
|
|
4
|
-
// the push path later behind the same EngineClient seam.
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
EngineClient,
|
|
8
|
-
EngineJob,
|
|
9
|
-
JobHandler,
|
|
10
|
-
WorkerSubscription,
|
|
11
|
-
} from "../core/host.ts";
|
|
12
|
-
|
|
13
|
-
export interface RestEngineOptions {
|
|
14
|
-
/** Base URL including the /v2 suffix, e.g. http://localhost:8080/v2. */
|
|
15
|
-
baseUrl: string;
|
|
16
|
-
/** Optional bearer token. */
|
|
17
|
-
token?: string;
|
|
18
|
-
/** Long-poll request timeout (ms) for job activation. Default 15000. */
|
|
19
|
-
longPollMs?: number;
|
|
20
|
-
log?: (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function normBase(u: string): string {
|
|
24
|
-
const t = u.replace(/\/+$/, "");
|
|
25
|
-
return /\/v\d+$/.test(t) ? t : `${t}/v2`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
29
|
-
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
30
|
-
export function requireProcessInstanceKey(key: string | number | null | undefined): string {
|
|
31
|
-
if (key == null || key === "") {
|
|
32
|
-
throw new Error("engine response missing processInstanceKey/key");
|
|
33
|
-
}
|
|
34
|
-
return String(key);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export class RestEngineClient implements EngineClient {
|
|
38
|
-
private readonly base: string;
|
|
39
|
-
private readonly token?: string;
|
|
40
|
-
private readonly longPollMs: number;
|
|
41
|
-
private readonly log: NonNullable<RestEngineOptions["log"]>;
|
|
42
|
-
private readonly subs = new Set<{ stop: () => void }>();
|
|
43
|
-
|
|
44
|
-
constructor(opts: RestEngineOptions) {
|
|
45
|
-
this.base = normBase(opts.baseUrl);
|
|
46
|
-
this.token = opts.token;
|
|
47
|
-
this.longPollMs = opts.longPollMs ?? 15000;
|
|
48
|
-
this.log = opts.log ?? (() => {});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
private headers(json = true): Record<string, string> {
|
|
52
|
-
const h: Record<string, string> = {};
|
|
53
|
-
if (json) h["content-type"] = "application/json";
|
|
54
|
-
if (this.token) h["authorization"] = `Bearer ${this.token}`;
|
|
55
|
-
return h;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
private async req(path: string, body: unknown, init?: RequestInit): Promise<Response> {
|
|
59
|
-
const res = await fetch(`${this.base}${path}`, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
headers: this.headers(),
|
|
62
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
63
|
-
...init,
|
|
64
|
-
});
|
|
65
|
-
return res;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private async okJson<T>(path: string, body: unknown): Promise<T> {
|
|
69
|
-
const res = await this.req(path, body);
|
|
70
|
-
if (!res.ok) throw new Error(`${path} → ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
71
|
-
const text = await res.text();
|
|
72
|
-
return (text ? JSON.parse(text) : {}) as T;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async deployResources(
|
|
76
|
-
resources: { name: string; content: string; contentType: string }[],
|
|
77
|
-
): Promise<{ deployed: number }> {
|
|
78
|
-
const form = new FormData();
|
|
79
|
-
for (const r of resources) {
|
|
80
|
-
form.append("resources", new Blob([r.content], { type: r.contentType }), r.name);
|
|
81
|
-
}
|
|
82
|
-
const headers: Record<string, string> = {};
|
|
83
|
-
if (this.token) headers["authorization"] = `Bearer ${this.token}`;
|
|
84
|
-
const res = await fetch(`${this.base}/deployments`, { method: "POST", headers, body: form });
|
|
85
|
-
if (!res.ok) {
|
|
86
|
-
throw new Error(`deploy → ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
87
|
-
}
|
|
88
|
-
return { deployed: resources.length };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async createInstance(input: {
|
|
92
|
-
processDefinitionId: string;
|
|
93
|
-
variables?: Record<string, unknown>;
|
|
94
|
-
awaitCompletion?: boolean;
|
|
95
|
-
}): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }> {
|
|
96
|
-
const r = await this.okJson<{ processInstanceKey?: string; key?: string; variables?: Record<string, unknown> }>(
|
|
97
|
-
"/process-instances",
|
|
98
|
-
{
|
|
99
|
-
processDefinitionId: input.processDefinitionId,
|
|
100
|
-
variables: input.variables ?? {},
|
|
101
|
-
awaitCompletion: input.awaitCompletion ?? false,
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
return { processInstanceKey: requireProcessInstanceKey(r.processInstanceKey ?? r.key), variables: r.variables };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async publishMessage(input: {
|
|
108
|
-
name: string;
|
|
109
|
-
correlationKey?: string;
|
|
110
|
-
variables?: Record<string, unknown>;
|
|
111
|
-
}): Promise<void> {
|
|
112
|
-
await this.okJson("/messages/publication", {
|
|
113
|
-
name: input.name,
|
|
114
|
-
correlationKey: input.correlationKey ?? "",
|
|
115
|
-
variables: input.variables ?? {},
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async searchUserTasks(filter?: {
|
|
120
|
-
processInstanceKey?: string;
|
|
121
|
-
assignee?: string;
|
|
122
|
-
candidateGroup?: string;
|
|
123
|
-
}): Promise<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]> {
|
|
124
|
-
const r = await this.okJson<{ items?: Record<string, unknown>[] }>("/user-tasks/search", {
|
|
125
|
-
filter: filter ?? {},
|
|
126
|
-
});
|
|
127
|
-
return (r.items ?? []).flatMap((it) => {
|
|
128
|
-
const userTaskKey = it.userTaskKey ?? it.key;
|
|
129
|
-
if (userTaskKey == null || userTaskKey === "") {
|
|
130
|
-
this.log("warn", "skipping user task with no key in engine response");
|
|
131
|
-
return [];
|
|
132
|
-
}
|
|
133
|
-
return [{
|
|
134
|
-
userTaskKey: String(userTaskKey),
|
|
135
|
-
elementId: it.elementId as string | undefined,
|
|
136
|
-
variables: it.variables as Record<string, unknown> | undefined,
|
|
137
|
-
}];
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
|
|
142
|
-
const res = await this.req(`/user-tasks/${userTaskKey}/completion`, { variables: variables ?? {} });
|
|
143
|
-
if (!res.ok && res.status !== 204) {
|
|
144
|
-
throw new Error(`complete user-task → ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async registerWorker(
|
|
149
|
-
jobType: string,
|
|
150
|
-
handler: JobHandler,
|
|
151
|
-
options?: { workerName?: string; maxParallelJobs?: number; fetchVariables?: string[] },
|
|
152
|
-
): Promise<WorkerSubscription> {
|
|
153
|
-
const workerName = options?.workerName ?? `urban:${jobType}`;
|
|
154
|
-
const maxJobs = options?.maxParallelJobs ?? 8;
|
|
155
|
-
let running = true;
|
|
156
|
-
const control = { stop: () => (running = false) };
|
|
157
|
-
this.subs.add(control);
|
|
158
|
-
|
|
159
|
-
const activateOnce = async (): Promise<Record<string, unknown>[]> => {
|
|
160
|
-
const ctrl = new AbortController();
|
|
161
|
-
const timer = setTimeout(() => ctrl.abort(), this.longPollMs + 5000);
|
|
162
|
-
try {
|
|
163
|
-
const res = await this.req(
|
|
164
|
-
"/jobs/activation",
|
|
165
|
-
{
|
|
166
|
-
type: jobType,
|
|
167
|
-
worker: workerName,
|
|
168
|
-
timeout: 5 * 60 * 1000,
|
|
169
|
-
maxJobsToActivate: maxJobs,
|
|
170
|
-
requestTimeout: this.longPollMs,
|
|
171
|
-
fetchVariable: options?.fetchVariables ?? [],
|
|
172
|
-
},
|
|
173
|
-
{ signal: ctrl.signal },
|
|
174
|
-
);
|
|
175
|
-
if (!res.ok) {
|
|
176
|
-
if (res.status !== 408) {
|
|
177
|
-
this.log("warn", `activation ${jobType} → ${res.status}`);
|
|
178
|
-
}
|
|
179
|
-
return [];
|
|
180
|
-
}
|
|
181
|
-
const body = (await res.json()) as { jobs?: Record<string, unknown>[] };
|
|
182
|
-
return body.jobs ?? [];
|
|
183
|
-
} catch (err) {
|
|
184
|
-
if (running) this.log("warn", `activation ${jobType} error`, { err: String(err) });
|
|
185
|
-
return [];
|
|
186
|
-
} finally {
|
|
187
|
-
clearTimeout(timer);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
const runOne = async (raw: Record<string, unknown>): Promise<void> => {
|
|
192
|
-
const rawKey = raw.jobKey ?? raw.key;
|
|
193
|
-
if (rawKey == null || rawKey === "") {
|
|
194
|
-
this.log("warn", `activation ${jobType}: skipping job with no jobKey in engine response`);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
const key = String(rawKey);
|
|
198
|
-
const job: EngineJob = {
|
|
199
|
-
jobKey: key,
|
|
200
|
-
jobType,
|
|
201
|
-
processInstanceKey: raw.processInstanceKey ? String(raw.processInstanceKey) : undefined,
|
|
202
|
-
elementId: raw.elementId as string | undefined,
|
|
203
|
-
variables: (raw.variables as Record<string, unknown>) ?? {},
|
|
204
|
-
};
|
|
205
|
-
try {
|
|
206
|
-
const out = await handler(job);
|
|
207
|
-
const res = await this.req(`/jobs/${key}/completion`, { variables: out ?? {} });
|
|
208
|
-
if (!res.ok && res.status !== 204) {
|
|
209
|
-
this.log("warn", `complete ${jobType} → ${res.status}`);
|
|
210
|
-
}
|
|
211
|
-
} catch (err) {
|
|
212
|
-
await this.req(`/jobs/${key}/failure`, {
|
|
213
|
-
retries: 0,
|
|
214
|
-
errorMessage: String(err).slice(0, 500),
|
|
215
|
-
}).catch(() => {});
|
|
216
|
-
this.log("error", `handler ${jobType} threw`, { err: String(err) });
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const loop = async (): Promise<void> => {
|
|
221
|
-
while (running) {
|
|
222
|
-
const jobs = await activateOnce();
|
|
223
|
-
if (!running) break;
|
|
224
|
-
if (jobs.length === 0) continue;
|
|
225
|
-
await Promise.all(jobs.map(runOne));
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
void loop();
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
jobType,
|
|
232
|
-
unsubscribe: async () => {
|
|
233
|
-
control.stop();
|
|
234
|
-
this.subs.delete(control);
|
|
235
|
-
},
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async close(): Promise<void> {
|
|
240
|
-
for (const s of this.subs) s.stop();
|
|
241
|
-
this.subs.clear();
|
|
242
|
-
}
|
|
243
|
-
}
|