@nanobpm/urban 0.1.0 → 0.2.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 +93 -16
- package/package.json +16 -11
- package/src/cli.ts +6 -5
- package/src/index.ts +46 -0
- package/src/runtime/adapters/deno.ts +124 -0
- package/src/runtime/adapters/detect.ts +22 -0
- package/src/runtime/adapters/node.ts +119 -0
- package/src/runtime/core/context.ts +35 -0
- package/src/runtime/core/glob.test.ts +45 -0
- package/src/runtime/core/glob.ts +63 -0
- package/src/runtime/core/host.ts +130 -0
- package/src/runtime/core/manifest.test.ts +31 -0
- package/src/runtime/core/manifest.ts +112 -0
- package/src/runtime/core/modules/datasource.ts +220 -0
- package/src/runtime/core/modules/deploy.ts +41 -0
- package/src/runtime/core/modules/security.ts +53 -0
- package/src/runtime/core/modules/surfaces.test.ts +42 -0
- package/src/runtime/core/modules/surfaces.ts +103 -0
- package/src/runtime/core/modules/triggers.ts +143 -0
- package/src/runtime/core/modules/units.test.ts +22 -0
- package/src/runtime/core/modules/workers.ts +96 -0
- package/src/runtime/core/purity.test.ts +31 -0
- package/src/runtime/core/router.test.ts +16 -0
- package/src/runtime/core/router.ts +55 -0
- package/src/runtime/core/runtime.test.ts +258 -0
- package/src/runtime/core/runtime.ts +213 -0
- package/src/runtime/core/validate.test.ts +73 -0
- package/src/runtime/core/validate.ts +129 -0
- package/src/runtime/engine/nanosdk.test.ts +124 -0
- package/src/runtime/engine/nanosdk.ts +123 -0
- package/src/runtime/engine/rest.ts +243 -0
- package/src/runtime/index.ts +68 -0
- package/src/runtime/run.ts +91 -0
- package/src/runtime/schema/nano-app.schema.json +471 -0
- package/src/toolkit/artifact.ts +29 -0
- package/src/toolkit/derivers/migrations.test.ts +50 -0
- package/src/toolkit/derivers/migrations.ts +99 -0
- package/src/toolkit/derivers/worker-io.test.ts +75 -0
- package/src/toolkit/derivers/worker-io.ts +176 -0
- package/src/toolkit/fsio.test.ts +21 -0
- package/src/toolkit/fsio.ts +36 -0
- package/src/toolkit/gen.test.ts +85 -0
- package/src/toolkit/gen.ts +109 -0
- package/src/toolkit/index.ts +37 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createNanoSdkEngineClient } from "./nanosdk.ts";
|
|
4
|
+
import type { EngineClient } from "../core/host.ts";
|
|
5
|
+
|
|
6
|
+
/** A fallback that records which cold-path methods were used. */
|
|
7
|
+
function recordingFallback(): EngineClient & { calls: string[] } {
|
|
8
|
+
const calls: string[] = [];
|
|
9
|
+
return {
|
|
10
|
+
calls,
|
|
11
|
+
async deployResources() {
|
|
12
|
+
calls.push("deployResources");
|
|
13
|
+
return { deployed: 0 };
|
|
14
|
+
},
|
|
15
|
+
async createInstance() {
|
|
16
|
+
calls.push("createInstance");
|
|
17
|
+
return { processInstanceKey: "REST" };
|
|
18
|
+
},
|
|
19
|
+
async publishMessage() {
|
|
20
|
+
calls.push("publishMessage");
|
|
21
|
+
},
|
|
22
|
+
async searchUserTasks() {
|
|
23
|
+
calls.push("searchUserTasks");
|
|
24
|
+
return [];
|
|
25
|
+
},
|
|
26
|
+
async completeUserTask() {
|
|
27
|
+
calls.push("completeUserTask");
|
|
28
|
+
},
|
|
29
|
+
async registerWorker(jobType: string) {
|
|
30
|
+
calls.push("registerWorker");
|
|
31
|
+
return { jobType, unsubscribe: async () => {} };
|
|
32
|
+
},
|
|
33
|
+
async close() {
|
|
34
|
+
calls.push("close");
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("createInstance is routed through the nano-sdk client (Falcon hot path)", async () => {
|
|
40
|
+
let sdkCreated = 0;
|
|
41
|
+
const fallback = recordingFallback();
|
|
42
|
+
const engine = await createNanoSdkEngineClient({
|
|
43
|
+
restAddress: "http://x/v2",
|
|
44
|
+
fallback,
|
|
45
|
+
createClient: () => ({
|
|
46
|
+
createProcessInstance: async (input) => {
|
|
47
|
+
sdkCreated++;
|
|
48
|
+
assert.equal(input.processDefinitionId, "p");
|
|
49
|
+
return { processInstanceKey: 42, variables: { ok: true } };
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
assert.ok(engine);
|
|
54
|
+
const res = await engine!.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
|
|
55
|
+
assert.equal(sdkCreated, 1);
|
|
56
|
+
assert.equal(res.processInstanceKey, "42");
|
|
57
|
+
assert.deepEqual(res.variables, { ok: true });
|
|
58
|
+
assert.ok(!fallback.calls.includes("createInstance"), "did not use REST for create");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("cold paths delegate to the REST fallback", async () => {
|
|
62
|
+
const fallback = recordingFallback();
|
|
63
|
+
const engine = await createNanoSdkEngineClient({
|
|
64
|
+
restAddress: "http://x/v2",
|
|
65
|
+
fallback,
|
|
66
|
+
createClient: () => ({ createProcessInstance: async () => ({ processInstanceKey: 1 }) }),
|
|
67
|
+
});
|
|
68
|
+
await engine!.deployResources([]);
|
|
69
|
+
await engine!.publishMessage({ name: "m" });
|
|
70
|
+
await engine!.searchUserTasks();
|
|
71
|
+
await engine!.completeUserTask("k");
|
|
72
|
+
await engine!.registerWorker("t", async () => ({}));
|
|
73
|
+
assert.deepEqual(fallback.calls, [
|
|
74
|
+
"deployResources",
|
|
75
|
+
"publishMessage",
|
|
76
|
+
"searchUserTasks",
|
|
77
|
+
"completeUserTask",
|
|
78
|
+
"registerWorker",
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns null when the SDK cannot be loaded (so callers fall back to REST)", async () => {
|
|
83
|
+
const engine = await createNanoSdkEngineClient({
|
|
84
|
+
restAddress: "http://x/v2",
|
|
85
|
+
createClient: () => {
|
|
86
|
+
throw new Error("boom");
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
assert.equal(engine, null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("close tears down both the SDK client and the fallback", async () => {
|
|
93
|
+
const fallback = recordingFallback();
|
|
94
|
+
let sdkClosed = 0;
|
|
95
|
+
const engine = await createNanoSdkEngineClient({
|
|
96
|
+
restAddress: "http://x/v2",
|
|
97
|
+
fallback,
|
|
98
|
+
createClient: () => ({
|
|
99
|
+
createProcessInstance: async () => ({ processInstanceKey: 1 }),
|
|
100
|
+
close: () => {
|
|
101
|
+
sdkClosed++;
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
await engine!.close();
|
|
106
|
+
assert.equal(sdkClosed, 1);
|
|
107
|
+
assert.ok(fallback.calls.includes("close"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("createInstance throws when the SDK response omits the instance key", async () => {
|
|
111
|
+
const fallback = recordingFallback();
|
|
112
|
+
const engine = await createNanoSdkEngineClient({
|
|
113
|
+
restAddress: "http://x/v2",
|
|
114
|
+
fallback,
|
|
115
|
+
createClient: () => ({
|
|
116
|
+
createProcessInstance: async () => ({ variables: { ok: true } }) as unknown as { processInstanceKey: number },
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
assert.ok(engine);
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => engine!.createInstance({ processDefinitionId: "p" }),
|
|
122
|
+
/missing processInstanceKey/,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// nano-sdk engine adapter. The console wires every generated app to `@nanobpm/nano-sdk`'s
|
|
2
|
+
// `createCamundaClient`, which upgrades the throughput-critical path — process-instance
|
|
3
|
+
// creation — to the Falcon protocol (falling back to REST). This adapter lets the Urban
|
|
4
|
+
// runtime use that same client, so the runtime and the IDE talk to the engine the same way
|
|
5
|
+
// (ADR 0053).
|
|
6
|
+
//
|
|
7
|
+
// Scope: the adapter routes instance creation (the hot path Falcon actually upgrades) through
|
|
8
|
+
// the SDK client, and delegates the cold paths (deploy, publish, user tasks, workers) to a
|
|
9
|
+
// REST fallback with the same address/token. `@nanobpm/nano-sdk` is an OPTIONAL dependency —
|
|
10
|
+
// imported lazily — so the runtime keeps a dependency-free core and REST-only default.
|
|
11
|
+
|
|
12
|
+
import type { EngineClient, JobHandler, WorkerSubscription } from "../core/host.ts";
|
|
13
|
+
import { RestEngineClient, requireProcessInstanceKey } from "./rest.ts";
|
|
14
|
+
|
|
15
|
+
/** The subset of the `@nanobpm/nano-sdk` client the adapter uses. */
|
|
16
|
+
export interface NanoSdkClient {
|
|
17
|
+
createProcessInstance(
|
|
18
|
+
input: {
|
|
19
|
+
processDefinitionId: string;
|
|
20
|
+
variables?: Record<string, unknown>;
|
|
21
|
+
awaitCompletion?: boolean;
|
|
22
|
+
},
|
|
23
|
+
options?: unknown,
|
|
24
|
+
): Promise<Record<string, unknown>>;
|
|
25
|
+
close?(): void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NanoSdkEngineOptions {
|
|
29
|
+
/** REST base, e.g. http://localhost:8080/v2. */
|
|
30
|
+
restAddress: string;
|
|
31
|
+
token?: string;
|
|
32
|
+
/** CAMUNDA_TRANSPORT: "auto" | "falcon" | "rest". Passed to createCamundaClient. */
|
|
33
|
+
transport?: string;
|
|
34
|
+
log?: (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
|
|
35
|
+
/** Test seam: provide the SDK client factory instead of importing @nanobpm/nano-sdk. */
|
|
36
|
+
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
|
+
}
|
|
40
|
+
|
|
41
|
+
async function importNanoSdk(): Promise<
|
|
42
|
+
(opts: Record<string, unknown>) => NanoSdkClient
|
|
43
|
+
> {
|
|
44
|
+
// Indirect the specifier so the (optional, peer) module is not resolved at typecheck time.
|
|
45
|
+
const spec = "@nanobpm/nano-sdk";
|
|
46
|
+
const mod = (await import(spec)) as {
|
|
47
|
+
createCamundaClient?: (opts: Record<string, unknown>) => NanoSdkClient;
|
|
48
|
+
};
|
|
49
|
+
if (typeof mod.createCamundaClient !== "function") {
|
|
50
|
+
throw new Error("@nanobpm/nano-sdk does not export createCamundaClient");
|
|
51
|
+
}
|
|
52
|
+
return mod.createCamundaClient;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build an EngineClient backed by `@nanobpm/nano-sdk` for instance creation (Falcon) and a REST
|
|
57
|
+
* fallback for everything else. Returns `null` if the SDK cannot be loaded (and no test seam was
|
|
58
|
+
* given), so callers can fall back to plain REST.
|
|
59
|
+
*/
|
|
60
|
+
export async function createNanoSdkEngineClient(
|
|
61
|
+
opts: NanoSdkEngineOptions,
|
|
62
|
+
): Promise<EngineClient | null> {
|
|
63
|
+
const log = opts.log ?? (() => {});
|
|
64
|
+
let client: NanoSdkClient;
|
|
65
|
+
try {
|
|
66
|
+
if (opts.createClient) {
|
|
67
|
+
client = opts.createClient({
|
|
68
|
+
restAddress: opts.restAddress,
|
|
69
|
+
token: opts.token,
|
|
70
|
+
transport: opts.transport,
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
const createCamundaClient = await importNanoSdk();
|
|
74
|
+
client = createCamundaClient({
|
|
75
|
+
config: {
|
|
76
|
+
CAMUNDA_REST_ADDRESS: opts.restAddress,
|
|
77
|
+
CAMUNDA_TOKEN: opts.token,
|
|
78
|
+
CAMUNDA_TRANSPORT: opts.transport ?? "auto",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log("warn", "nano-sdk unavailable; using REST transport", { error: String(err) });
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
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;
|
|
123
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Public API for the Urban runtime (@nanobpm/urban, subpath ./runtime).
|
|
2
|
+
|
|
3
|
+
// Core runtime
|
|
4
|
+
export { createUrbanApp } from "./core/runtime.ts";
|
|
5
|
+
export type {
|
|
6
|
+
CreateUrbanAppOptions,
|
|
7
|
+
MountFlags,
|
|
8
|
+
UrbanApp,
|
|
9
|
+
} from "./core/runtime.ts";
|
|
10
|
+
|
|
11
|
+
// Manifest
|
|
12
|
+
export {
|
|
13
|
+
expandEnv,
|
|
14
|
+
expandEnvString,
|
|
15
|
+
loadManifest,
|
|
16
|
+
parseManifest,
|
|
17
|
+
workerJobType,
|
|
18
|
+
} from "./core/manifest.ts";
|
|
19
|
+
export type {
|
|
20
|
+
AppManifest,
|
|
21
|
+
DataSource,
|
|
22
|
+
DomainType,
|
|
23
|
+
SurfaceDecl,
|
|
24
|
+
TriggerDecl,
|
|
25
|
+
TypeField,
|
|
26
|
+
WorkerDecl,
|
|
27
|
+
} from "./core/manifest.ts";
|
|
28
|
+
|
|
29
|
+
// Validation
|
|
30
|
+
export {
|
|
31
|
+
collectManifestIssues,
|
|
32
|
+
ManifestValidationError,
|
|
33
|
+
validateManifest,
|
|
34
|
+
} from "./core/validate.ts";
|
|
35
|
+
export type { ValidationIssue } from "./core/validate.ts";
|
|
36
|
+
|
|
37
|
+
// Host + engine contracts (for custom hosts / tests)
|
|
38
|
+
export type {
|
|
39
|
+
EngineClient,
|
|
40
|
+
EngineJob,
|
|
41
|
+
HostContext,
|
|
42
|
+
HttpHandler,
|
|
43
|
+
HttpRequest,
|
|
44
|
+
HttpResponse,
|
|
45
|
+
HttpServer,
|
|
46
|
+
JobHandler,
|
|
47
|
+
SqliteDb,
|
|
48
|
+
WorkerSubscription,
|
|
49
|
+
} from "./core/host.ts";
|
|
50
|
+
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
51
|
+
|
|
52
|
+
// Data layer
|
|
53
|
+
export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
|
54
|
+
export type { ProvisionedSource } from "./core/modules/datasource.ts";
|
|
55
|
+
export type { AppJobHandler } from "./core/modules/workers.ts";
|
|
56
|
+
export { resolveHandler } from "./core/modules/workers.ts";
|
|
57
|
+
export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
58
|
+
|
|
59
|
+
// Adapters + engine + run entrypoint
|
|
60
|
+
export { createNodeHost } from "./adapters/node.ts";
|
|
61
|
+
export { createDenoHost } from "./adapters/deno.ts";
|
|
62
|
+
export { isDeno, selectHost } from "./adapters/detect.ts";
|
|
63
|
+
export { RestEngineClient } from "./engine/rest.ts";
|
|
64
|
+
export type { RestEngineOptions } from "./engine/rest.ts";
|
|
65
|
+
export { createNanoSdkEngineClient } from "./engine/nanosdk.ts";
|
|
66
|
+
export type { NanoSdkEngineOptions, NanoSdkClient } from "./engine/nanosdk.ts";
|
|
67
|
+
export { runFromEnv } from "./run.ts";
|
|
68
|
+
export type { RunOptions } from "./run.ts";
|
|
@@ -0,0 +1,91 @@
|
|
|
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 a REST engine client from env, create
|
|
3
|
+
// the Urban app, start it, and install a graceful-shutdown handler.
|
|
4
|
+
|
|
5
|
+
import { createUrbanApp, type CreateUrbanAppOptions, type UrbanApp } from "./core/runtime.ts";
|
|
6
|
+
import { selectHost } from "./adapters/detect.ts";
|
|
7
|
+
import { RestEngineClient } from "./engine/rest.ts";
|
|
8
|
+
import { createNanoSdkEngineClient } from "./engine/nanosdk.ts";
|
|
9
|
+
import type { EngineClient } from "./core/host.ts";
|
|
10
|
+
|
|
11
|
+
export interface RunOptions extends Partial<CreateUrbanAppOptions> {
|
|
12
|
+
/** App root. Default ".". */
|
|
13
|
+
root?: string;
|
|
14
|
+
/** Engine REST base. Default $CAMUNDA_REST_ADDRESS or http://localhost:8080/v2. */
|
|
15
|
+
restAddress?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Engine transport: "rest" (default) uses the built-in REST client; "auto" | "falcon" try
|
|
18
|
+
* `@nanobpm/nano-sdk` (Falcon on instance creation, REST fallback), falling back to REST if the
|
|
19
|
+
* SDK is not installed. Overridable via $CAMUNDA_TRANSPORT.
|
|
20
|
+
*/
|
|
21
|
+
transport?: string;
|
|
22
|
+
/** Install SIGINT/SIGTERM handlers to stop the app. Default true. */
|
|
23
|
+
handleSignals?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runFromEnv(opts: RunOptions = {}): Promise<UrbanApp> {
|
|
27
|
+
const host = opts.host ?? selectHost({ cwd: opts.root });
|
|
28
|
+
const restAddress =
|
|
29
|
+
opts.restAddress ?? host.env("CAMUNDA_REST_ADDRESS") ?? "http://localhost:8080/v2";
|
|
30
|
+
const transport = opts.transport ?? host.env("CAMUNDA_TRANSPORT") ?? "rest";
|
|
31
|
+
|
|
32
|
+
let engine: EngineClient | undefined = opts.engine;
|
|
33
|
+
if (!engine && transport !== "rest") {
|
|
34
|
+
engine =
|
|
35
|
+
(await createNanoSdkEngineClient({ restAddress, token: host.env("CAMUNDA_TOKEN"), transport, log: host.log })) ??
|
|
36
|
+
undefined;
|
|
37
|
+
}
|
|
38
|
+
engine ??= new RestEngineClient({ baseUrl: restAddress, token: host.env("CAMUNDA_TOKEN"), log: host.log });
|
|
39
|
+
|
|
40
|
+
const app = await createUrbanApp({
|
|
41
|
+
host,
|
|
42
|
+
engine,
|
|
43
|
+
// The host is anchored at opts.root (cwd), so its APIs are already
|
|
44
|
+
// root-relative; passing opts.root here as well would double-prefix every
|
|
45
|
+
// path (e.g. "<root>/<root>/nano.app.json"). Keep root "." for the app.
|
|
46
|
+
root: ".",
|
|
47
|
+
manifest: opts.manifest,
|
|
48
|
+
manifestPath: opts.manifestPath,
|
|
49
|
+
port: opts.port,
|
|
50
|
+
mount: opts.mount,
|
|
51
|
+
});
|
|
52
|
+
await app.start();
|
|
53
|
+
|
|
54
|
+
if (opts.handleSignals !== false) installSignalHandlers(() => app.stop());
|
|
55
|
+
return app;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function installSignalHandlers(stop: () => Promise<void>): void {
|
|
59
|
+
let stopping = false;
|
|
60
|
+
const onSignal = () => {
|
|
61
|
+
if (stopping) return;
|
|
62
|
+
stopping = true;
|
|
63
|
+
// Neither Deno.addSignalListener nor process.on awaits the callback, so a
|
|
64
|
+
// rejection from stop() would surface as an unhandled rejection during
|
|
65
|
+
// shutdown. Register a non-async listener and swallow/log any rejection.
|
|
66
|
+
Promise.resolve()
|
|
67
|
+
.then(stop)
|
|
68
|
+
.catch((err) => {
|
|
69
|
+
try {
|
|
70
|
+
console.error("error during shutdown:", err);
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore logging failures during teardown */
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const g = globalThis as {
|
|
77
|
+
Deno?: { addSignalListener(sig: string, cb: () => void): void };
|
|
78
|
+
process?: { on(sig: string, cb: () => void): void };
|
|
79
|
+
};
|
|
80
|
+
if (g.Deno) {
|
|
81
|
+
try {
|
|
82
|
+
g.Deno.addSignalListener("SIGINT", onSignal);
|
|
83
|
+
g.Deno.addSignalListener("SIGTERM", onSignal);
|
|
84
|
+
} catch {
|
|
85
|
+
/* signal listeners may be unavailable (e.g. Windows) */
|
|
86
|
+
}
|
|
87
|
+
} else if (g.process) {
|
|
88
|
+
g.process.on("SIGINT", onSignal);
|
|
89
|
+
g.process.on("SIGTERM", onSignal);
|
|
90
|
+
}
|
|
91
|
+
}
|