@bpmnkit/engine 0.1.15 → 0.1.19

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 CHANGED
@@ -6,6 +6,8 @@
6
6
  [![npm](https://img.shields.io/npm/v/@bpmnkit/engine?style=flat-square&color=6244d7)](https://www.npmjs.com/package/@bpmnkit/engine)
7
7
  [![license](https://img.shields.io/npm/l/@bpmnkit/engine?style=flat-square)](https://github.com/bpmnkit/monorepo/blob/main/LICENSE)
8
8
  [![typescript](https://img.shields.io/badge/TypeScript-strict-6244d7?style=flat-square&logo=typescript&logoColor=white)](https://github.com/bpmnkit/monorepo)
9
+ [![ai-assisted](https://img.shields.io/badge/AI--assisted-claude-8b5cf6?style=flat-square)](https://github.com/bpmnkit/monorepo)
10
+ [![experimental](https://img.shields.io/badge/status-experimental-f59e0b?style=flat-square)](https://github.com/bpmnkit/monorepo)
9
11
 
10
12
  [Website](https://bpmnkit.com) · [Documentation](https://docs.bpmnkit.com) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/engine/CHANGELOG.md)
11
13
  </div>
package/dist/engine.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { BpmnDefinitions, DmnDefinitions, FormDefinition } from "@bpmnkit/core";
2
2
  import { ProcessInstance } from "./instance.js";
3
+ import type { SecretResolver } from "./secrets.js";
3
4
  import type { JobHandler } from "./types.js";
4
5
  /** Options for {@link Engine.start}. */
5
6
  export interface StartOptions {
@@ -9,11 +10,18 @@ export interface StartOptions {
9
10
  */
10
11
  beforeComplete?: (elementId: string) => Promise<void>;
11
12
  }
13
+ /** Options for the {@link Engine} constructor. */
14
+ export interface EngineOptions {
15
+ /** Resolver for `{{secrets.NAME}}` placeholders in connector configurations. */
16
+ secretResolver?: SecretResolver;
17
+ }
12
18
  export declare class Engine {
13
19
  private readonly processes;
14
20
  private readonly decisions;
15
21
  private readonly forms;
16
22
  private readonly workers;
23
+ private readonly secretResolver;
24
+ constructor(options?: EngineOptions);
17
25
  /**
18
26
  * Deploy BPMN processes, DMN decisions, and form definitions.
19
27
  * Calling deploy multiple times merges into the registry.
package/dist/engine.js CHANGED
@@ -4,6 +4,10 @@ export class Engine {
4
4
  decisions = new Map();
5
5
  forms = new Map();
6
6
  workers = new Map();
7
+ secretResolver;
8
+ constructor(options) {
9
+ this.secretResolver = options?.secretResolver;
10
+ }
7
11
  /**
8
12
  * Deploy BPMN processes, DMN decisions, and form definitions.
9
13
  * Calling deploy multiple times merges into the registry.
@@ -40,7 +44,7 @@ export class Engine {
40
44
  if (process === undefined) {
41
45
  throw new Error(`Process "${processId}" is not deployed`);
42
46
  }
43
- const instance = new ProcessInstance(process, this.decisions, this.forms, this.workers, variables ?? {});
47
+ const instance = new ProcessInstance(process, this.decisions, this.forms, this.workers, variables ?? {}, this.secretResolver);
44
48
  if (options?.beforeComplete !== undefined) {
45
49
  instance.beforeComplete = options.beforeComplete;
46
50
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { Engine } from "./engine.js";
2
- export type { StartOptions } from "./engine.js";
2
+ export type { StartOptions, EngineOptions } from "./engine.js";
3
+ export { EnvSecretResolver, resolveSecretString } from "./secrets.js";
4
+ export type { SecretResolver } from "./secrets.js";
3
5
  export { ProcessInstance } from "./instance.js";
4
6
  export type { ProcessEvent, Job, JobHandler } from "./types.js";
5
7
  export { VariableStore } from "./variables.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Engine } from "./engine.js";
2
+ export { EnvSecretResolver, resolveSecretString } from "./secrets.js";
2
3
  export { ProcessInstance } from "./instance.js";
3
4
  export { VariableStore } from "./variables.js";
4
5
  export { evaluateDecision } from "./dmn.js";
@@ -1,4 +1,5 @@
1
1
  import type { BpmnProcess, DmnDecision, FormDefinition } from "@bpmnkit/core";
2
+ import type { SecretResolver } from "./secrets.js";
2
3
  import type { JobHandler, ProcessEvent } from "./types.js";
3
4
  type InstanceState = "active" | "completed" | "terminated" | "failed";
4
5
  export declare class ProcessInstance {
@@ -25,13 +26,14 @@ export declare class ProcessInstance {
25
26
  private readonly decisions;
26
27
  private readonly forms;
27
28
  private readonly jobWorkers;
29
+ private readonly secretResolver;
28
30
  /**
29
31
  * Optional hook called just before an element completes (token moves on).
30
32
  * Returning a Promise lets the caller pause execution — useful for
31
33
  * step-by-step simulation. Set via {@link Engine.start} options.
32
34
  */
33
35
  beforeComplete?: (elementId: string) => Promise<void>;
34
- constructor(process: BpmnProcess, decisions: Map<string, DmnDecision>, forms: Map<string, FormDefinition>, jobWorkers: Map<string, JobHandler>, initialVars: Record<string, unknown>);
36
+ constructor(process: BpmnProcess, decisions: Map<string, DmnDecision>, forms: Map<string, FormDefinition>, jobWorkers: Map<string, JobHandler>, initialVars: Record<string, unknown>, secretResolver?: SecretResolver);
35
37
  get state(): InstanceState;
36
38
  get error(): string | undefined;
37
39
  get activeElements(): string[];
package/dist/instance.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { generateId } from "@bpmnkit/core";
2
2
  import { evaluate, parseExpression } from "@bpmnkit/feel";
3
3
  import { evaluateDecision } from "./dmn.js";
4
+ import { resolveSecretString } from "./secrets.js";
4
5
  import { scheduleTimer } from "./timers.js";
5
6
  import { VariableStore } from "./variables.js";
6
7
  import { parseZeebeExt } from "./zeebe.js";
@@ -29,18 +30,20 @@ export class ProcessInstance {
29
30
  decisions;
30
31
  forms;
31
32
  jobWorkers;
33
+ secretResolver;
32
34
  /**
33
35
  * Optional hook called just before an element completes (token moves on).
34
36
  * Returning a Promise lets the caller pause execution — useful for
35
37
  * step-by-step simulation. Set via {@link Engine.start} options.
36
38
  */
37
39
  beforeComplete;
38
- constructor(process, decisions, forms, jobWorkers, initialVars) {
40
+ constructor(process, decisions, forms, jobWorkers, initialVars, secretResolver) {
39
41
  this.id = generateId("pi");
40
42
  this.processId = process.id;
41
43
  this.decisions = decisions;
42
44
  this.forms = forms;
43
45
  this.jobWorkers = jobWorkers;
46
+ this.secretResolver = secretResolver;
44
47
  this.variables = new VariableStore();
45
48
  this.rootScopeId = `scope_${this.id}`;
46
49
  this.variables.createScope(this.rootScopeId);
@@ -186,10 +189,27 @@ export class ProcessInstance {
186
189
  const ext = parseZeebeExt(el.extensionElements);
187
190
  if (ext.ioMapping) {
188
191
  for (const inp of ext.ioMapping.inputs) {
189
- const val = this.evalFeel(inp.source, scopeId, {
190
- elementId: el.id,
191
- property: `input:${inp.target}`,
192
- });
192
+ let val;
193
+ if (this.secretResolver !== undefined && inp.source.includes("{{secrets.")) {
194
+ const resolved = await resolveSecretString(inp.source, this.secretResolver);
195
+ // If source starts with "=", it's a FEEL expression — evaluate after secret substitution.
196
+ // Otherwise it's a literal string — use the resolved value directly.
197
+ if (inp.source.trimStart().startsWith("=")) {
198
+ val = this.evalFeel(resolved, scopeId, {
199
+ elementId: el.id,
200
+ property: `input:${inp.target}`,
201
+ });
202
+ }
203
+ else {
204
+ val = resolved;
205
+ }
206
+ }
207
+ else {
208
+ val = this.evalFeel(inp.source, scopeId, {
209
+ elementId: el.id,
210
+ property: `input:${inp.target}`,
211
+ });
212
+ }
193
213
  this.variables.setLocal(scopeId, inp.target, val);
194
214
  this.emit({ type: "variable:set", name: inp.target, value: val, scopeId });
195
215
  }
@@ -314,7 +334,17 @@ export class ProcessInstance {
314
334
  return;
315
335
  }
316
336
  const jobId = generateId("job");
317
- const headers = ext.taskHeaders ?? {};
337
+ const rawHeaders = ext.taskHeaders ?? {};
338
+ let headers;
339
+ if (this.secretResolver !== undefined) {
340
+ headers = {};
341
+ for (const [k, v] of Object.entries(rawHeaders)) {
342
+ headers[k] = await resolveSecretString(v, this.secretResolver);
343
+ }
344
+ }
345
+ else {
346
+ headers = rawHeaders;
347
+ }
318
348
  const vars = this.variables.getAll(ctx.scopeId);
319
349
  let jobError;
320
350
  await new Promise((resolve) => {
@@ -376,8 +406,14 @@ export class ProcessInstance {
376
406
  if (cd === undefined)
377
407
  return;
378
408
  const decision = this.decisions.get(cd.decisionId);
379
- if (decision === undefined)
409
+ if (decision === undefined) {
410
+ this.emit({
411
+ type: "element:failed",
412
+ elementId: _el.id,
413
+ error: `DMN decision '${cd.decisionId}' not found. Deploy the DMN before running the process.`,
414
+ });
380
415
  return;
416
+ }
381
417
  const result = evaluateDecision(decision, this.variables.getAll(scopeId));
382
418
  this.variables.set(scopeId, cd.resultVariable, result);
383
419
  this.emit({ type: "variable:set", name: cd.resultVariable, value: result, scopeId });
@@ -36,6 +36,13 @@ export interface ScenarioResult {
36
36
  visitedElements: string[];
37
37
  /** Final variable state. */
38
38
  finalVariables: Record<string, unknown>;
39
+ /** FEEL expressions evaluated during the run, in order. */
40
+ feelEvals: Array<{
41
+ elementId: string;
42
+ property: string;
43
+ expression: string;
44
+ result: unknown;
45
+ }>;
39
46
  /** Errors collected during the run. */
40
47
  errors: Array<{
41
48
  elementId?: string;
package/dist/scenario.js CHANGED
@@ -21,6 +21,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
21
21
  passed: false,
22
22
  visitedElements: [],
23
23
  finalVariables: {},
24
+ feelEvals: [],
24
25
  errors: [{ message: "No process found in definitions." }],
25
26
  failures: [{ field: "processId", expected: "a deployed process", actual: undefined }],
26
27
  durationMs: Date.now() - startMs,
@@ -42,6 +43,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
42
43
  }
43
44
  const visitedElements = [];
44
45
  const variableState = new Map();
46
+ const feelEvals = [];
45
47
  const errors = [];
46
48
  let settled = false;
47
49
  let timeoutHandle;
@@ -95,6 +97,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
95
97
  passed: failures.length === 0,
96
98
  visitedElements,
97
99
  finalVariables,
100
+ feelEvals,
98
101
  errors,
99
102
  failures,
100
103
  durationMs: Date.now() - startMs,
@@ -121,6 +124,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
121
124
  passed: false,
122
125
  visitedElements: [],
123
126
  finalVariables: {},
127
+ feelEvals: [],
124
128
  errors: [{ message: msg }],
125
129
  failures: [{ field: "start", expected: "process to start", actual: msg }],
126
130
  durationMs: Date.now() - startMs,
@@ -134,6 +138,14 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
134
138
  else if (evt.type === "variable:set") {
135
139
  variableState.set(evt.name, evt.value);
136
140
  }
141
+ else if (evt.type === "feel:evaluated") {
142
+ feelEvals.push({
143
+ elementId: evt.elementId,
144
+ property: evt.property,
145
+ expression: evt.expression,
146
+ result: evt.result,
147
+ });
148
+ }
137
149
  else if (evt.type === "element:failed") {
138
150
  errors.push({ elementId: evt.elementId, message: evt.error });
139
151
  }
@@ -0,0 +1,18 @@
1
+ /** Interface for resolving secret values by name. */
2
+ export interface SecretResolver {
3
+ resolve(name: string): Promise<string | undefined>;
4
+ }
5
+ /**
6
+ * Replace all `{{secrets.NAME}}` placeholders in `value` using the given resolver.
7
+ * Returns the original string unchanged if no placeholders are present.
8
+ * Throws if any referenced secret is not configured.
9
+ */
10
+ export declare function resolveSecretString(value: string, resolver: SecretResolver): Promise<string>;
11
+ /**
12
+ * Secret resolver that reads from `process.env`.
13
+ * Works in Node.js; always returns `undefined` in browser contexts.
14
+ */
15
+ export declare class EnvSecretResolver implements SecretResolver {
16
+ resolve(name: string): Promise<string | undefined>;
17
+ }
18
+ //# sourceMappingURL=secrets.d.ts.map
@@ -0,0 +1,35 @@
1
+ const SECRET_RE = /\{\{secrets\.([^}]+)\}\}/g;
2
+ /**
3
+ * Replace all `{{secrets.NAME}}` placeholders in `value` using the given resolver.
4
+ * Returns the original string unchanged if no placeholders are present.
5
+ * Throws if any referenced secret is not configured.
6
+ */
7
+ export async function resolveSecretString(value, resolver) {
8
+ if (!value.includes("{{secrets."))
9
+ return value;
10
+ // Collect all unique names first to allow parallel resolution
11
+ const names = new Set();
12
+ for (const m of value.matchAll(SECRET_RE)) {
13
+ if (m[1])
14
+ names.add(m[1]);
15
+ }
16
+ const resolved = new Map();
17
+ await Promise.all([...names].map(async (name) => {
18
+ const val = await resolver.resolve(name);
19
+ if (val === undefined) {
20
+ throw new Error(`Secret "{{secrets.${name}}}" is not configured`);
21
+ }
22
+ resolved.set(name, val);
23
+ }));
24
+ return value.replace(SECRET_RE, (_match, name) => resolved.get(name) ?? "");
25
+ }
26
+ /**
27
+ * Secret resolver that reads from `process.env`.
28
+ * Works in Node.js; always returns `undefined` in browser contexts.
29
+ */
30
+ export class EnvSecretResolver {
31
+ async resolve(name) {
32
+ return typeof process !== "undefined" ? process.env[name] : undefined;
33
+ }
34
+ }
35
+ //# sourceMappingURL=secrets.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Scenario runner backed by the WasmEngine (reebe-wasm).
3
+ *
4
+ * Works in both browser and Node.js:
5
+ * - Browser: WASM is loaded via the default export (fetch-based).
6
+ * - Node.js: WASM bytes are read from disk via initSync to avoid
7
+ * Node's lack of fetch() support for file:// URLs.
8
+ *
9
+ * Creates a fresh WasmEngine per scenario for clean isolation, deploys the
10
+ * current BPMN XML (and all referenced DMN/BPMN resources recursively), drives
11
+ * jobs to completion using mock outputs from the scenario definition, then
12
+ * asserts expectations.
13
+ */
14
+ export interface ScenarioLike {
15
+ id: string;
16
+ name: string;
17
+ processId?: string;
18
+ inputs?: Record<string, unknown>;
19
+ mocks?: Record<string, {
20
+ outputs?: Record<string, unknown>;
21
+ error?: string;
22
+ }>;
23
+ expect?: {
24
+ path?: string[];
25
+ variables?: Record<string, unknown>;
26
+ };
27
+ }
28
+ export interface ScenarioResultLike {
29
+ scenarioId: string;
30
+ scenarioName: string;
31
+ passed: boolean;
32
+ visitedElements: string[];
33
+ finalVariables: Record<string, unknown>;
34
+ feelEvals: Array<{
35
+ elementId: string;
36
+ property: string;
37
+ expression: string;
38
+ result: unknown;
39
+ }>;
40
+ errors: Array<{
41
+ elementId?: string;
42
+ message: string;
43
+ }>;
44
+ failures: Array<{
45
+ field: string;
46
+ expected: unknown;
47
+ actual: unknown;
48
+ }>;
49
+ durationMs: number;
50
+ }
51
+ export declare function runScenarioWasm(xml: string, scenario: ScenarioLike, getDecisionDmn?: (decisionId: string) => string | null, getProcessBpmn?: (processId: string) => string | null): Promise<ScenarioResultLike>;
52
+ //# sourceMappingURL=wasm-runner.d.ts.map
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Scenario runner backed by the WasmEngine (reebe-wasm).
3
+ *
4
+ * Works in both browser and Node.js:
5
+ * - Browser: WASM is loaded via the default export (fetch-based).
6
+ * - Node.js: WASM bytes are read from disk via initSync to avoid
7
+ * Node's lack of fetch() support for file:// URLs.
8
+ *
9
+ * Creates a fresh WasmEngine per scenario for clean isolation, deploys the
10
+ * current BPMN XML (and all referenced DMN/BPMN resources recursively), drives
11
+ * jobs to completion using mock outputs from the scenario definition, then
12
+ * asserts expectations.
13
+ */
14
+ // ── WASM initialisation ───────────────────────────────────────────────────────
15
+ let wasmReady = false;
16
+ async function ensureWasmInit() {
17
+ if (wasmReady)
18
+ return;
19
+ const mod = await import("@bpmnkit/reebe-wasm");
20
+ // Node.js: fetch() does not support file:// URLs generated by wasm-bindgen,
21
+ // so we read the .wasm bytes from disk and use initSync.
22
+ if (typeof process !== "undefined" && typeof process.versions?.node === "string") {
23
+ const { readFileSync } = await import("node:fs");
24
+ const { createRequire } = await import("node:module");
25
+ const { dirname, resolve } = await import("node:path");
26
+ const req = createRequire(import.meta.url);
27
+ const jsPath = req.resolve("@bpmnkit/reebe-wasm");
28
+ const wasmPath = resolve(dirname(jsPath), "reebe_wasm_bg.wasm");
29
+ mod.initSync({ module: readFileSync(wasmPath) });
30
+ }
31
+ else {
32
+ await mod.default();
33
+ }
34
+ wasmReady = true;
35
+ }
36
+ // ── Recursive dependency deployment ──────────────────────────────────────────
37
+ function deployDependencies(xml, engine, getDecisionDmn, getProcessBpmn, deployed) {
38
+ const missingDecisions = [];
39
+ // DMN decisions referenced via decisionId="..."
40
+ if (getDecisionDmn) {
41
+ for (const [, id] of xml.matchAll(/decisionId="([^"]+)"/g)) {
42
+ if (!id || deployed.has(`dmn:${id}`))
43
+ continue;
44
+ deployed.add(`dmn:${id}`);
45
+ const dmnXml = getDecisionDmn(id);
46
+ if (dmnXml) {
47
+ try {
48
+ engine.deploy(dmnXml);
49
+ }
50
+ catch {
51
+ missingDecisions.push(id);
52
+ }
53
+ }
54
+ else {
55
+ missingDecisions.push(id);
56
+ }
57
+ }
58
+ }
59
+ // Sub-processes / call activities referenced via calledElement="..."
60
+ if (getProcessBpmn) {
61
+ for (const [, id] of xml.matchAll(/calledElement="([^"]+)"/g)) {
62
+ if (!id || deployed.has(`bpmn:${id}`))
63
+ continue;
64
+ deployed.add(`bpmn:${id}`);
65
+ const bpmnXml = getProcessBpmn(id);
66
+ if (bpmnXml) {
67
+ const nested = deployDependencies(bpmnXml, engine, getDecisionDmn, getProcessBpmn, deployed);
68
+ missingDecisions.push(...nested);
69
+ engine.deploy(bpmnXml);
70
+ }
71
+ }
72
+ }
73
+ return missingDecisions;
74
+ }
75
+ // ── BPMN helpers ─────────────────────────────────────────────────────────────
76
+ /**
77
+ * Extract condition expressions keyed by sequence flow ID from BPMN XML.
78
+ * Used to reconstruct FEEL evaluations from SEQUENCE_FLOW_TAKEN event log records.
79
+ */
80
+ function extractFlowConditions(bpmnXml) {
81
+ const result = new Map();
82
+ // Strategy: find each conditionExpression element, then look backward to the
83
+ // nearest enclosing <sequenceFlow id="..."> opening tag. Using a greedy
84
+ // prefix ([\s\S]*) forces the match to the LAST occurrence before the
85
+ // conditionExpression, which is always the immediately enclosing flow.
86
+ // This avoids the self-closing `/>` confusion that plagues forward-matching regexes.
87
+ const condPattern = /<(?:[a-zA-Z]+:)?conditionExpression[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?conditionExpression>/g;
88
+ for (const condMatch of bpmnXml.matchAll(condPattern)) {
89
+ const rawExpr = condMatch[1]?.trim() ?? "";
90
+ const condition = rawExpr.startsWith("<![CDATA[") && rawExpr.endsWith("]]>") ? rawExpr.slice(9, -3) : rawExpr;
91
+ // Slice everything before this conditionExpression and find the last sequenceFlow id
92
+ const before = bpmnXml.slice(0, condMatch.index);
93
+ const flowMatch = before.match(/[\s\S]*<(?:[a-zA-Z]+:)?sequenceFlow\b[^>]*\bid="([^"]+)"/);
94
+ if (flowMatch?.[1]) {
95
+ result.set(flowMatch[1], condition);
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+ /**
101
+ * Ensure BRT result variables are propagated to the parent process scope.
102
+ *
103
+ * When a BPMN `businessRuleTask` has both `zeebe:calledDecision resultVariable="X"`
104
+ * and a `zeebe:ioMapping` element, the WASM engine only propagates explicitly mapped
105
+ * output variables. Without an output entry for X, the result stays in the element
106
+ * scope (scope_key = element instance key) and is cleaned up when the BRT completes —
107
+ * never reaching the process instance scope where the runner's snapshot reads variables.
108
+ *
109
+ * This function rewrites the XML so that each such BRT has an explicit
110
+ * `<zeebe:output source="=X" target="X"/>` entry, mirroring real Zeebe behaviour
111
+ * where resultVariable is always stored at the parent scope.
112
+ */
113
+ function ensureBrtResultVariableOutputs(xml) {
114
+ const brtPattern = /(<(?:[a-zA-Z]+:)?businessRuleTask\b[^>]*>)([\s\S]*?)(<\/(?:[a-zA-Z]+:)?businessRuleTask>)/g;
115
+ return xml.replace(brtPattern, (_match, open, body, close) => {
116
+ const rvMatch = /resultVariable="([^"]+)"/.exec(body);
117
+ if (!rvMatch?.[1])
118
+ return open + body + close; // no calledDecision resultVariable
119
+ const resultVar = rvMatch[1];
120
+ // Already has an output entry for this variable? Nothing to do
121
+ if (new RegExp(`<(?:[a-zA-Z]+:)?output\\b[^>]*target="${resultVar}"`).test(body))
122
+ return open + body + close;
123
+ const outputEntry = `<zeebe:output source="=${resultVar}" target="${resultVar}"/>`;
124
+ const hasIoMapping = /<(?:[a-zA-Z]+:)?ioMapping\b/.test(body);
125
+ let patched;
126
+ if (!hasIoMapping) {
127
+ // No ioMapping at all — inject one before the closing extensionElements tag
128
+ patched = body.replace(/(<\/(?:[a-zA-Z]+:)?extensionElements>)/, `<zeebe:ioMapping>${outputEntry}</zeebe:ioMapping>$1`);
129
+ }
130
+ else if (/<(?:[a-zA-Z]+:)?ioMapping\s*\/>/.test(body)) {
131
+ // Self-closing <zeebe:ioMapping/> — expand and inject
132
+ patched = body.replace(/<(?:[a-zA-Z]+:)?ioMapping\s*\/>/, `<zeebe:ioMapping>${outputEntry}</zeebe:ioMapping>`);
133
+ }
134
+ else {
135
+ // Open/close <zeebe:ioMapping>…</zeebe:ioMapping> — inject before closing tag
136
+ patched = body.replace(/(<\/(?:[a-zA-Z]+:)?ioMapping>)/, `${outputEntry}$1`);
137
+ }
138
+ return open + patched + close;
139
+ });
140
+ }
141
+ function extractBrtInfos(bpmnXml) {
142
+ const infos = [];
143
+ const pattern = /<(?:[a-zA-Z]+:)?businessRuleTask\b([^>]*)>([\s\S]*?)<\/(?:[a-zA-Z]+:)?businessRuleTask>/g;
144
+ for (const m of bpmnXml.matchAll(pattern)) {
145
+ const attrs = m[1] ?? "";
146
+ const body = m[2] ?? "";
147
+ const idMatch = /\bid="([^"]+)"/.exec(attrs);
148
+ const decisionIdMatch = /decisionId="([^"]+)"/.exec(body);
149
+ const resultVarMatch = /resultVariable="([^"]+)"/.exec(body);
150
+ if (idMatch?.[1] && decisionIdMatch?.[1] && resultVarMatch?.[1]) {
151
+ infos.push({
152
+ elementId: idMatch[1],
153
+ decisionId: decisionIdMatch[1],
154
+ resultVar: resultVarMatch[1],
155
+ });
156
+ }
157
+ }
158
+ return infos;
159
+ }
160
+ function parseDmnDecisionTable(dmnXml, decisionId) {
161
+ const decisionMatch = new RegExp(`<(?:[a-zA-Z]+:)?decision\\b[^>]*\\bid="${decisionId}"[^>]*>[\\s\\S]*?<\\/(?:[a-zA-Z]+:)?decision>`).exec(dmnXml);
162
+ if (!decisionMatch)
163
+ return null;
164
+ const decisionBody = decisionMatch[0];
165
+ const tableMatch = /<(?:[a-zA-Z]+:)?decisionTable\b([^>]*)>([\s\S]*?)<\/(?:[a-zA-Z]+:)?decisionTable>/.exec(decisionBody);
166
+ if (!tableMatch)
167
+ return null;
168
+ const hitPolicy = /hitPolicy="([^"]*)"/.exec(tableMatch[1] ?? "")?.[1] ?? "UNIQUE";
169
+ const tableBody = tableMatch[2] ?? "";
170
+ const extractText = (xml) => /<(?:[a-zA-Z]+:)?text\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?text>/.exec(xml)?.[1]?.trim() ?? "";
171
+ const inputs = [];
172
+ for (const m of tableBody.matchAll(/<(?:[a-zA-Z]+:)?input\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?input>/g)) {
173
+ const exprBody = /<(?:[a-zA-Z]+:)?inputExpression\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?inputExpression>/.exec(m[1] ?? "")?.[1] ?? "";
174
+ inputs.push(extractText(exprBody));
175
+ }
176
+ const outputs = [];
177
+ for (const m of tableBody.matchAll(/<(?:[a-zA-Z]+:)?output\b([^/>]*?)(?:\/>|>[\s\S]*?<\/(?:[a-zA-Z]+:)?output>)/g)) {
178
+ outputs.push(/\bname="([^"]*)"/.exec(m[1] ?? "")?.[1] ?? "");
179
+ }
180
+ const rules = [];
181
+ for (const ruleMatch of tableBody.matchAll(/<(?:[a-zA-Z]+:)?rule\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?rule>/g)) {
182
+ const ruleBody = ruleMatch[1] ?? "";
183
+ const inputEntries = [];
184
+ for (const ie of ruleBody.matchAll(/<(?:[a-zA-Z]+:)?inputEntry\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?inputEntry>/g))
185
+ inputEntries.push(extractText(ie[1] ?? ""));
186
+ const outputEntries = [];
187
+ for (const oe of ruleBody.matchAll(/<(?:[a-zA-Z]+:)?outputEntry\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?outputEntry>/g))
188
+ outputEntries.push(extractText(oe[1] ?? ""));
189
+ rules.push({ inputEntries, outputEntries });
190
+ }
191
+ return { hitPolicy, inputs, outputs, rules };
192
+ }
193
+ function evalDmnDecisionTable(table, vars) {
194
+ const feel = feelModule;
195
+ if (!feel)
196
+ return null;
197
+ const ctx = { vars: vars };
198
+ const collectResults = [];
199
+ for (const rule of table.rules) {
200
+ let matches = true;
201
+ for (let i = 0; i < table.inputs.length; i++) {
202
+ const inputExpr = table.inputs[i] ?? "";
203
+ const testExpr = rule.inputEntries[i] ?? "";
204
+ if (!testExpr)
205
+ continue; // empty = any
206
+ const inputParsed = feel.parseExpression(inputExpr);
207
+ if (!inputParsed.ast) {
208
+ matches = false;
209
+ break;
210
+ }
211
+ const inputVal = feel.evaluate(inputParsed.ast, ctx);
212
+ const testParsed = feel.parseUnaryTests(testExpr);
213
+ if (!testParsed.ast) {
214
+ matches = false;
215
+ break;
216
+ }
217
+ if (!feel.evaluateUnaryTests(testParsed.ast, inputVal, ctx)) {
218
+ matches = false;
219
+ break;
220
+ }
221
+ }
222
+ if (!matches)
223
+ continue;
224
+ if (table.outputs.length === 1) {
225
+ const expr = rule.outputEntries[0] ?? "";
226
+ if (expr) {
227
+ const parsed = feel.parseExpression(expr);
228
+ if (parsed.ast)
229
+ collectResults.push(feel.evaluate(parsed.ast, ctx));
230
+ }
231
+ }
232
+ else {
233
+ const row = {};
234
+ for (let i = 0; i < table.outputs.length; i++) {
235
+ const name = table.outputs[i];
236
+ const expr = rule.outputEntries[i] ?? "";
237
+ if (name && expr) {
238
+ const parsed = feel.parseExpression(expr);
239
+ if (parsed.ast)
240
+ row[name] = feel.evaluate(parsed.ast, ctx);
241
+ }
242
+ }
243
+ collectResults.push(row);
244
+ }
245
+ }
246
+ // Apply hit policy
247
+ if (table.hitPolicy === "FIRST")
248
+ return collectResults[0] ?? null;
249
+ if (table.hitPolicy === "UNIQUE" || table.hitPolicy === "ANY")
250
+ return collectResults[0] ?? null;
251
+ return collectResults; // COLLECT, RULE ORDER, etc.
252
+ }
253
+ // Lazily cached feel module reference (avoids repeated dynamic imports)
254
+ let feelModule = null;
255
+ async function ensureFeelModule() {
256
+ if (feelModule)
257
+ return;
258
+ feelModule = await import("@bpmnkit/feel");
259
+ }
260
+ /**
261
+ * For visited BRTs that have a resultVariable, evaluate the DMN decision table
262
+ * directly using the FEEL engine and return {resultVar: value} pairs.
263
+ *
264
+ * The WASM browser engine cleans up BRT local-scope variables on element
265
+ * completion and does not propagate the resultVariable to the process scope
266
+ * snapshot. Evaluating the DMN with the FEEL engine directly sidesteps this
267
+ * limitation.
268
+ */
269
+ async function evaluateBrtResultVariables(bpmnXml, visitedElementIds, vars, getDecisionDmn) {
270
+ if (!getDecisionDmn)
271
+ return {};
272
+ const allBrts = extractBrtInfos(bpmnXml);
273
+ const visitedBrts = allBrts.filter((b) => visitedElementIds.includes(b.elementId));
274
+ if (visitedBrts.length === 0)
275
+ return {};
276
+ await ensureFeelModule();
277
+ const result = {};
278
+ for (const { decisionId, resultVar } of visitedBrts) {
279
+ const dmnXml = getDecisionDmn(decisionId);
280
+ if (!dmnXml)
281
+ continue;
282
+ const table = parseDmnDecisionTable(dmnXml, decisionId);
283
+ if (!table)
284
+ continue;
285
+ result[resultVar] = evalDmnDecisionTable(table, vars);
286
+ }
287
+ return result;
288
+ }
289
+ // ── Runner ────────────────────────────────────────────────────────────────────
290
+ const MAX_ROUNDS = 200;
291
+ const TIMEOUT_MS = 10_000;
292
+ export async function runScenarioWasm(xml, scenario, getDecisionDmn, getProcessBpmn) {
293
+ const startMs = Date.now();
294
+ await ensureWasmInit();
295
+ const mod = await import("@bpmnkit/reebe-wasm");
296
+ const engine = new mod.WasmEngine();
297
+ try {
298
+ const deployed = new Set();
299
+ const missingDecisions = deployDependencies(xml, engine, getDecisionDmn, getProcessBpmn, deployed);
300
+ // Deploy main BPMN (with BRT result variable output mappings patched in)
301
+ const deployResult = engine.deploy(ensureBrtResultVariableOutputs(xml));
302
+ const processId = scenario.processId ?? deployResult.deployments[0]?.bpmnProcessId;
303
+ if (!processId) {
304
+ return fail(scenario, startMs, "No process found in deployed BPMN.");
305
+ }
306
+ // Start instance
307
+ engine.create_process_instance(processId, JSON.stringify(scenario.inputs ?? {}));
308
+ // Drive jobs to completion
309
+ for (let round = 0; round < MAX_ROUNDS; round++) {
310
+ if (Date.now() - startMs > TIMEOUT_MS)
311
+ break;
312
+ const snap = engine.snapshot();
313
+ const pi = snap.processInstances.find((p) => p.bpmn_process_id === processId);
314
+ if (!pi || pi.state !== "ACTIVE")
315
+ break;
316
+ const activatable = snap.jobs.filter((j) => j.state === "ACTIVATABLE" && j.process_instance_key === pi.key);
317
+ if (activatable.length === 0)
318
+ break;
319
+ for (const job of activatable) {
320
+ try {
321
+ engine.activate_job(job.key, "test-worker", 30_000);
322
+ const mock = scenario.mocks?.[job.job_type];
323
+ if (mock?.error !== undefined) {
324
+ engine.fail_job(job.key, 0, mock.error);
325
+ }
326
+ else {
327
+ engine.complete_job(job.key, JSON.stringify(mock?.outputs ?? {}));
328
+ }
329
+ }
330
+ catch {
331
+ // Job may have already been handled — skip
332
+ }
333
+ }
334
+ }
335
+ // Collect final state
336
+ const snap = engine.snapshot();
337
+ const pi = snap.processInstances.find((p) => p.bpmn_process_id === processId);
338
+ if (!pi)
339
+ return fail(scenario, startMs, "Process instance not found after run.");
340
+ const timedOut = pi.state === "ACTIVE" && Date.now() - startMs >= TIMEOUT_MS;
341
+ const visitedElements = snap.elementInstances
342
+ .filter((e) => e.process_instance_key === pi.key)
343
+ .map((e) => e.element_id);
344
+ // Collect variables: merge snapshot state (deduplicated, final values) with
345
+ // VARIABLE.CREATED events from the event log (catches DMN-produced variables
346
+ // that may not survive snapshot filtering due to scope key differences).
347
+ const piKeyStr = String(pi.key);
348
+ const finalVariables = {};
349
+ for (const rec of snap.eventLog) {
350
+ if (rec.record_type === "EVENT" &&
351
+ rec.value_type === "VARIABLE" &&
352
+ rec.intent === "CREATED" &&
353
+ String(rec.payload.processInstanceKey) === piKeyStr) {
354
+ const name = rec.payload.name;
355
+ if (name !== undefined)
356
+ finalVariables[name] = rec.payload.value;
357
+ }
358
+ }
359
+ // Snapshot variables override (they represent the final upserted state)
360
+ for (const v of snap.variables.filter((v) => v.process_instance_key === pi.key)) {
361
+ finalVariables[v.name] = v.value;
362
+ }
363
+ // BRT resultVariable post-hoc evaluation: the WASM browser engine cleans up
364
+ // BRT local-scope variables on element completion so they never reach the
365
+ // process-scope snapshot. Evaluate each visited BRT's DMN directly with
366
+ // the FEEL engine and inject the result (snapshot value wins if present).
367
+ const brtResultVars = await evaluateBrtResultVariables(xml, visitedElements, finalVariables, getDecisionDmn);
368
+ for (const [k, v] of Object.entries(brtResultVars)) {
369
+ if (!(k in finalVariables))
370
+ finalVariables[k] = v;
371
+ }
372
+ // Reconstruct FEEL evaluations from SEQUENCE_FLOW_TAKEN events.
373
+ // The WASM engine evaluates conditions but doesn't emit dedicated FEEL records;
374
+ // we pair each taken flow with its conditionExpression from the BPMN XML.
375
+ const flowConditions = extractFlowConditions(xml);
376
+ const feelEvals = [];
377
+ for (const rec of snap.eventLog) {
378
+ if (rec.record_type === "EVENT" &&
379
+ rec.value_type === "PROCESS_INSTANCE" &&
380
+ rec.intent === "SEQUENCE_FLOW_TAKEN" &&
381
+ String(rec.payload.processInstanceKey) === piKeyStr) {
382
+ const flowId = rec.payload.elementId;
383
+ const sourceId = rec.payload.sourceElementId;
384
+ if (!flowId)
385
+ continue;
386
+ const expression = flowConditions.get(flowId);
387
+ if (expression !== undefined) {
388
+ feelEvals.push({
389
+ elementId: sourceId ?? flowId,
390
+ property: "condition",
391
+ expression,
392
+ result: true,
393
+ });
394
+ }
395
+ }
396
+ }
397
+ const errors = snap.incidents
398
+ .filter((i) => i.process_instance_key === pi.key)
399
+ .map((i) => ({ elementId: i.element_id, message: i.error_message ?? i.error_type }));
400
+ for (const id of missingDecisions) {
401
+ errors.push({ message: `DMN decision '${id}' not found. Create the DMN in the Models view.` });
402
+ }
403
+ if (timedOut) {
404
+ errors.push({ message: `Scenario timed out after ${TIMEOUT_MS}ms` });
405
+ }
406
+ // Evaluate assertions
407
+ const failures = [];
408
+ if (scenario.expect?.path !== undefined) {
409
+ let cursor = 0;
410
+ for (const expectedId of scenario.expect.path) {
411
+ const idx = visitedElements.indexOf(expectedId, cursor);
412
+ if (idx === -1) {
413
+ failures.push({
414
+ field: "path",
415
+ expected: expectedId,
416
+ actual: `not found in [${visitedElements.join(", ")}]`,
417
+ });
418
+ }
419
+ else {
420
+ cursor = idx + 1;
421
+ }
422
+ }
423
+ }
424
+ if (scenario.expect?.variables !== undefined) {
425
+ for (const [key, expectedValue] of Object.entries(scenario.expect.variables)) {
426
+ const actualValue = finalVariables[key];
427
+ if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) {
428
+ failures.push({ field: `variables.${key}`, expected: expectedValue, actual: actualValue });
429
+ }
430
+ }
431
+ }
432
+ return {
433
+ scenarioId: scenario.id,
434
+ scenarioName: scenario.name,
435
+ passed: errors.length === 0 && failures.length === 0,
436
+ visitedElements,
437
+ finalVariables,
438
+ feelEvals,
439
+ errors,
440
+ failures,
441
+ durationMs: Date.now() - startMs,
442
+ };
443
+ }
444
+ finally {
445
+ engine.free();
446
+ }
447
+ }
448
+ function fail(scenario, startMs, message) {
449
+ return {
450
+ scenarioId: scenario.id,
451
+ scenarioName: scenario.name,
452
+ passed: false,
453
+ visitedElements: [],
454
+ finalVariables: {},
455
+ feelEvals: [],
456
+ errors: [{ message }],
457
+ failures: [{ field: "start", expected: "process to deploy and start", actual: message }],
458
+ durationMs: Date.now() - startMs,
459
+ };
460
+ }
461
+ //# sourceMappingURL=wasm-runner.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmnkit/engine",
3
- "version": "0.1.15",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -8,6 +8,10 @@
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
10
  "import": "./dist/index.js"
11
+ },
12
+ "./wasm-runner": {
13
+ "types": "./dist/wasm-runner.d.ts",
14
+ "import": "./dist/wasm-runner.js"
11
15
  }
12
16
  },
13
17
  "files": [
@@ -17,8 +21,9 @@
17
21
  "dist/**/*.d.ts"
18
22
  ],
19
23
  "dependencies": {
20
- "@bpmnkit/core": "0.0.16",
21
- "@bpmnkit/feel": "0.0.13"
24
+ "@bpmnkit/feel": "0.0.16",
25
+ "@bpmnkit/reebe-wasm": "npm:reebe-wasm@0.1.0",
26
+ "@bpmnkit/core": "0.0.20"
22
27
  },
23
28
  "description": "Lightweight BPMN 2.0 process execution engine for browsers and Node.js — zero dependencies",
24
29
  "keywords": [