@wooksjs/event-wf 0.7.14 → 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +145 -242
- package/dist/index.d.ts +95 -22
- package/dist/index.mjs +108 -237
- package/package.json +9 -9
package/dist/index.cjs
CHANGED
|
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
|
2
2
|
let _wooksjs_event_core = require("@wooksjs/event-core");
|
|
3
3
|
let _wooksjs_event_http = require("@wooksjs/event-http");
|
|
4
4
|
let _wooksjs_http_body = require("@wooksjs/http-body");
|
|
5
|
-
let
|
|
5
|
+
let _prostojs_wf_outlets = require("@prostojs/wf/outlets");
|
|
6
6
|
let _prostojs_wf = require("@prostojs/wf");
|
|
7
7
|
let wooks = require("wooks");
|
|
8
8
|
|
|
@@ -57,8 +57,6 @@ function resumeWfContext(options, seeds, fn) {
|
|
|
57
57
|
//#region packages/event-wf/src/outlets/outlet-context.ts
|
|
58
58
|
/** Registered outlet handlers, keyed by name */
|
|
59
59
|
const outletsRegistryKey = (0, _wooksjs_event_core.key)("wf.outlets.registry");
|
|
60
|
-
/** Active state strategy for current request */
|
|
61
|
-
const stateStrategyKey = (0, _wooksjs_event_core.key)("wf.outlets.stateStrategy");
|
|
62
60
|
/** Finished response set by workflow steps */
|
|
63
61
|
const wfFinishedKey = (0, _wooksjs_event_core.key)("wf.outlets.finished");
|
|
64
62
|
|
|
@@ -72,7 +70,6 @@ const wfFinishedKey = (0, _wooksjs_event_core.key)("wf.outlets.finished");
|
|
|
72
70
|
* where steps need to inspect or modify outlet state directly.
|
|
73
71
|
*/
|
|
74
72
|
const useWfOutlet = (0, _wooksjs_event_core.defineWook)((ctx) => ({
|
|
75
|
-
getStateStrategy: () => ctx.get(stateStrategyKey),
|
|
76
73
|
getOutlets: () => ctx.get(outletsRegistryKey),
|
|
77
74
|
getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
|
|
78
75
|
}));
|
|
@@ -96,8 +93,81 @@ const useWfFinished = (0, _wooksjs_event_core.defineWook)((ctx) => ({
|
|
|
96
93
|
get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
|
|
97
94
|
}));
|
|
98
95
|
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region packages/event-wf/src/strategy-context.ts
|
|
98
|
+
/** Name of the strategy currently active for the running workflow. */
|
|
99
|
+
const stateStrategyNameKey = (0, _wooksjs_event_core.key)("wf.strategyName");
|
|
100
|
+
/** Strategy names must match this regex (validated at swap call + config). */
|
|
101
|
+
const STRATEGY_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region packages/event-wf/src/outlets/use-wf-strategy.ts
|
|
105
|
+
/**
|
|
106
|
+
* Composable for inspecting / swapping the active state strategy name from
|
|
107
|
+
* within a workflow step. The new name applies to the NEXT pause — it travels
|
|
108
|
+
* back to the outlet trigger via `output.inputRequired.stateStrategy`, and
|
|
109
|
+
* persists in the issued token's prefix.
|
|
110
|
+
*
|
|
111
|
+
* The composable only validates the name FORMAT (regex). Existence in the
|
|
112
|
+
* trigger's strategy registry is validated at pause time by the trigger
|
|
113
|
+
* itself — `swapStrategy('typo')` here will not throw; the trigger will
|
|
114
|
+
* throw on pause when it can't find 'typo' in its registry.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* // Step that escalates from in-memory state to a durable KV store before
|
|
119
|
+
* // pausing for a long-running approval:
|
|
120
|
+
* app.step('await-approval', {
|
|
121
|
+
* handler: () => {
|
|
122
|
+
* swapStrategy('kv')
|
|
123
|
+
* return outletHttp({ fields: ['decision'] })
|
|
124
|
+
* },
|
|
125
|
+
* })
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
const useWfStrategy = (0, _wooksjs_event_core.defineWook)((ctx) => ({
|
|
129
|
+
current: () => ctx.get(stateStrategyNameKey),
|
|
130
|
+
swap: (name) => {
|
|
131
|
+
if (!STRATEGY_NAME_RE.test(name)) throw new Error(`swapStrategy: invalid name '${name}' — must match ${STRATEGY_NAME_RE}`);
|
|
132
|
+
ctx.set(stateStrategyNameKey, name);
|
|
133
|
+
}
|
|
134
|
+
}));
|
|
135
|
+
/**
|
|
136
|
+
* Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
|
|
137
|
+
* can write `return swapStrategy('kv')` when no outlet pause follows.
|
|
138
|
+
*/
|
|
139
|
+
function swapStrategy(name) {
|
|
140
|
+
useWfStrategy().swap(name);
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
//#endregion
|
|
100
144
|
//#region packages/event-wf/src/outlets/trigger.ts
|
|
145
|
+
function wrapToken(name, raw) {
|
|
146
|
+
return `${name}.${raw}`;
|
|
147
|
+
}
|
|
148
|
+
function unwrapToken(token) {
|
|
149
|
+
const i = token.indexOf(".");
|
|
150
|
+
if (i <= 0 || i === token.length - 1) return null;
|
|
151
|
+
return {
|
|
152
|
+
name: token.slice(0, i),
|
|
153
|
+
raw: token.slice(i + 1)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function normalizeStateConfig(state) {
|
|
157
|
+
if (typeof state === "object" && state !== null && "strategies" in state) {
|
|
158
|
+
const registry = state.strategies;
|
|
159
|
+
for (const name of Object.keys(registry)) if (!STRATEGY_NAME_RE.test(name)) throw new Error(`Invalid strategy name '${name}': must match /^[A-Za-z0-9_-]+$/`);
|
|
160
|
+
const def = state.default;
|
|
161
|
+
return {
|
|
162
|
+
registry,
|
|
163
|
+
resolveDefaultName: typeof def === "function" ? def : (_wfid) => def
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
registry: { default: state },
|
|
168
|
+
resolveDefaultName: () => "default"
|
|
169
|
+
};
|
|
170
|
+
}
|
|
101
171
|
/**
|
|
102
172
|
* Handle an HTTP request that starts or resumes a workflow.
|
|
103
173
|
*
|
|
@@ -129,6 +199,7 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
129
199
|
const registry = new Map(config.outlets.map((o) => [o.name, o]));
|
|
130
200
|
ctx.set(outletsRegistryKey, registry);
|
|
131
201
|
ctx.set(wfFinishedKey, void 0);
|
|
202
|
+
const { registry: strategyRegistry, resolveDefaultName } = normalizeStateConfig(config.state);
|
|
132
203
|
const { parseBody } = (0, _wooksjs_http_body.useBody)();
|
|
133
204
|
const { params } = (0, _wooksjs_event_http.useUrlParams)();
|
|
134
205
|
const { getCookie } = (0, _wooksjs_event_http.useCookies)();
|
|
@@ -144,27 +215,31 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
144
215
|
}
|
|
145
216
|
const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
|
|
146
217
|
const input = body?.input;
|
|
147
|
-
const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
|
|
148
218
|
let output;
|
|
149
|
-
let
|
|
219
|
+
let incomingName;
|
|
220
|
+
let incomingRaw;
|
|
150
221
|
if (token) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
222
|
+
const unwrapped = unwrapToken(token);
|
|
223
|
+
if (!unwrapped) {
|
|
224
|
+
response.setStatus(410);
|
|
225
|
+
return { error: "Invalid workflow state token" };
|
|
226
|
+
}
|
|
227
|
+
const strategy = Object.prototype.hasOwnProperty.call(strategyRegistry, unwrapped.name) ? strategyRegistry[unwrapped.name] : void 0;
|
|
228
|
+
if (!strategy) {
|
|
229
|
+
response.setStatus(410);
|
|
230
|
+
return { error: "Invalid workflow state token" };
|
|
231
|
+
}
|
|
232
|
+
incomingName = unwrapped.name;
|
|
233
|
+
incomingRaw = unwrapped.raw;
|
|
234
|
+
const state = await strategy.consume(unwrapped.raw);
|
|
154
235
|
if (!state) {
|
|
155
236
|
response.setStatus(410);
|
|
156
237
|
return { error: "Invalid or expired workflow state" };
|
|
157
238
|
}
|
|
158
|
-
if (state.schemaId !== (wfid ?? "")) {
|
|
159
|
-
const realStrategy = resolveStrategy(state.schemaId);
|
|
160
|
-
if (realStrategy !== strategy) {
|
|
161
|
-
ctx.set(stateStrategyKey, realStrategy);
|
|
162
|
-
strategyReResolved = true;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
239
|
output = await deps.resume(state, {
|
|
166
240
|
input,
|
|
167
|
-
eventContext: ctx
|
|
241
|
+
eventContext: ctx,
|
|
242
|
+
strategy: { name: incomingName }
|
|
168
243
|
});
|
|
169
244
|
} else if (wfid) {
|
|
170
245
|
if (config.allow?.length && !config.allow.includes(wfid)) {
|
|
@@ -175,12 +250,13 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
175
250
|
response.setStatus(403);
|
|
176
251
|
return { error: `Workflow '${wfid}' is blocked` };
|
|
177
252
|
}
|
|
178
|
-
const
|
|
179
|
-
|
|
253
|
+
const defaultName = resolveDefaultName(wfid);
|
|
254
|
+
if (!(Object.prototype.hasOwnProperty.call(strategyRegistry, defaultName) ? strategyRegistry[defaultName] : void 0)) throw new Error(`Default strategy '${defaultName}' not found in registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
|
|
180
255
|
const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
|
|
181
256
|
output = await deps.start(wfid, initialContext, {
|
|
182
257
|
input,
|
|
183
|
-
eventContext: ctx
|
|
258
|
+
eventContext: ctx,
|
|
259
|
+
strategy: { name: defaultName }
|
|
184
260
|
});
|
|
185
261
|
} else {
|
|
186
262
|
response.setStatus(400);
|
|
@@ -211,13 +287,16 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
211
287
|
response.setStatus(500);
|
|
212
288
|
return { error: `Unknown outlet: '${outletReq.outlet}'` };
|
|
213
289
|
}
|
|
214
|
-
const
|
|
290
|
+
const finalName = outletReq.stateStrategy;
|
|
291
|
+
if (finalName === void 0) throw new Error("Workflow paused without `stateStrategy` on inputRequired — the WF adapter must augment the output with the active strategy name.");
|
|
292
|
+
const finalStrategy = Object.prototype.hasOwnProperty.call(strategyRegistry, finalName) ? strategyRegistry[finalName] : void 0;
|
|
293
|
+
if (!finalStrategy) throw new Error(`Workflow paused with unknown strategy '${finalName}' — step swapped to a name not in the trigger's registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
|
|
215
294
|
const stateWithMeta = {
|
|
216
295
|
...output.state,
|
|
217
296
|
meta: { outlet: outletReq.outlet }
|
|
218
297
|
};
|
|
219
|
-
const reuseHandle =
|
|
220
|
-
const newToken = await
|
|
298
|
+
const reuseHandle = incomingName !== void 0 && incomingName === finalName && incomingRaw !== void 0 ? { handle: incomingRaw } : void 0;
|
|
299
|
+
const newToken = wrapToken(finalName, await finalStrategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle));
|
|
221
300
|
const outOfBand = outletHandler.tokenDelivery === "out-of-band";
|
|
222
301
|
if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
|
|
223
302
|
httpOnly: true,
|
|
@@ -320,219 +399,6 @@ function createOutletHandler(wfApp) {
|
|
|
320
399
|
});
|
|
321
400
|
}
|
|
322
401
|
|
|
323
|
-
//#endregion
|
|
324
|
-
//#region node_modules/.pnpm/@prostojs+wf@0.2.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
|
|
325
|
-
/**
|
|
326
|
-
* Generic outlet request. Use for custom outlets.
|
|
327
|
-
*
|
|
328
|
-
* @example
|
|
329
|
-
* return outlet('pending-task', {
|
|
330
|
-
* payload: ApprovalForm,
|
|
331
|
-
* target: managerId,
|
|
332
|
-
* context: { orderId, amount },
|
|
333
|
-
* })
|
|
334
|
-
*/
|
|
335
|
-
function outlet(name, data) {
|
|
336
|
-
return { inputRequired: {
|
|
337
|
-
outlet: name,
|
|
338
|
-
...data
|
|
339
|
-
} };
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* Pause for HTTP form input. The outlet returns the payload (form definition)
|
|
343
|
-
* and state token in the HTTP response.
|
|
344
|
-
*
|
|
345
|
-
* @example
|
|
346
|
-
* return outletHttp(LoginForm)
|
|
347
|
-
* return outletHttp(LoginForm, { error: 'Invalid credentials' })
|
|
348
|
-
*/
|
|
349
|
-
function outletHttp(payload, context) {
|
|
350
|
-
return outlet("http", {
|
|
351
|
-
payload,
|
|
352
|
-
context
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Pause and send email with a magic link containing the state token.
|
|
357
|
-
*
|
|
358
|
-
* @example
|
|
359
|
-
* return outletEmail('user@test.com', 'invite', { name: 'Alice' })
|
|
360
|
-
*/
|
|
361
|
-
function outletEmail(target, template, context) {
|
|
362
|
-
return outlet("email", {
|
|
363
|
-
target,
|
|
364
|
-
template,
|
|
365
|
-
context
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Self-contained AES-256-GCM encrypted state strategy.
|
|
370
|
-
*
|
|
371
|
-
* Workflow state is encrypted into a base64url token that travels with the
|
|
372
|
-
* transport (cookie, URL param, hidden field). No server-side storage needed.
|
|
373
|
-
*
|
|
374
|
-
* Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
|
|
375
|
-
*
|
|
376
|
-
* ## Security warning — replay
|
|
377
|
-
*
|
|
378
|
-
* This strategy is STATELESS. It cannot enforce single-use semantics:
|
|
379
|
-
* `consume()` is a no-op alias for `retrieve()` because there is no
|
|
380
|
-
* server-side record to delete. Anyone who obtains a copy of the token
|
|
381
|
-
* (browser history, server logs, shoulder-surfing, intermediate proxy,
|
|
382
|
-
* shared device) can replay it until the TTL expires.
|
|
383
|
-
*
|
|
384
|
-
* Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
|
|
385
|
-
*
|
|
386
|
-
* 1. Every workflow step is idempotent — re-executing a step with the same
|
|
387
|
-
* input produces no harmful side effects (pure data collection,
|
|
388
|
-
* validation-only steps).
|
|
389
|
-
* 2. The flow is not security-sensitive — no credential changes, financial
|
|
390
|
-
* operations, account provisioning, permission grants, or any other
|
|
391
|
-
* privileged action.
|
|
392
|
-
*
|
|
393
|
-
* For auth flows (login, password reset, invite accept), financial
|
|
394
|
-
* operations, or anything with meaningful side effects, use
|
|
395
|
-
* `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
|
|
396
|
-
* supports true single-use tokens via atomic `getAndDelete` at the store
|
|
397
|
-
* layer.
|
|
398
|
-
*
|
|
399
|
-
* @example
|
|
400
|
-
* const strategy = new EncapsulatedStateStrategy({
|
|
401
|
-
* secret: crypto.randomBytes(32),
|
|
402
|
-
* defaultTtl: 3600_000, // 1 hour
|
|
403
|
-
* });
|
|
404
|
-
* const token = await strategy.persist(state);
|
|
405
|
-
* const recovered = await strategy.retrieve(token);
|
|
406
|
-
*/
|
|
407
|
-
var EncapsulatedStateStrategy = class {
|
|
408
|
-
/** @throws if secret is not exactly 32 bytes */
|
|
409
|
-
constructor(config) {
|
|
410
|
-
this.config = config;
|
|
411
|
-
this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret;
|
|
412
|
-
if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes");
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Encrypt workflow state into a self-contained token.
|
|
416
|
-
*
|
|
417
|
-
* NOTE: the `overrides.handle` hint from `WfStateStrategy` is silently
|
|
418
|
-
* ignored — the encapsulated token IS the ciphertext of the state, so a
|
|
419
|
-
* fixed handle cannot map to changing state. Callers that need handle
|
|
420
|
-
* stability across calls must use `HandleStateStrategy`.
|
|
421
|
-
*
|
|
422
|
-
* @param state — workflow state to persist
|
|
423
|
-
* @param options.ttl — time-to-live in ms (overrides defaultTtl)
|
|
424
|
-
* @returns base64url-encoded encrypted token
|
|
425
|
-
*/
|
|
426
|
-
async persist(state, options, _overrides) {
|
|
427
|
-
const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
|
|
428
|
-
const exp = ttl > 0 ? Date.now() + ttl : 0;
|
|
429
|
-
const payload = JSON.stringify({
|
|
430
|
-
s: state,
|
|
431
|
-
e: exp
|
|
432
|
-
});
|
|
433
|
-
const iv = (0, node_crypto.randomBytes)(12);
|
|
434
|
-
const cipher = (0, node_crypto.createCipheriv)("aes-256-gcm", this.key, iv);
|
|
435
|
-
const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
|
|
436
|
-
const tag = cipher.getAuthTag();
|
|
437
|
-
return Buffer.concat([
|
|
438
|
-
iv,
|
|
439
|
-
tag,
|
|
440
|
-
encrypted
|
|
441
|
-
]).toString("base64url");
|
|
442
|
-
}
|
|
443
|
-
/** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */
|
|
444
|
-
async retrieve(token) {
|
|
445
|
-
return this.decrypt(token);
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Stateless — CANNOT invalidate the token. Returns identical result to
|
|
449
|
-
* `retrieve()`. See the class-level security warning.
|
|
450
|
-
*
|
|
451
|
-
* This method exists only to satisfy the `WfStateStrategy` contract.
|
|
452
|
-
* Callers that need true single-use semantics must use
|
|
453
|
-
* `HandleStateStrategy`.
|
|
454
|
-
*/
|
|
455
|
-
async consume(token) {
|
|
456
|
-
return this.decrypt(token);
|
|
457
|
-
}
|
|
458
|
-
decrypt(token) {
|
|
459
|
-
try {
|
|
460
|
-
const buf = Buffer.from(token, "base64url");
|
|
461
|
-
if (buf.length < 28) return null;
|
|
462
|
-
const iv = buf.subarray(0, 12);
|
|
463
|
-
const tag = buf.subarray(12, 28);
|
|
464
|
-
const ciphertext = buf.subarray(28);
|
|
465
|
-
const decipher = (0, node_crypto.createDecipheriv)("aes-256-gcm", this.key, iv);
|
|
466
|
-
decipher.setAuthTag(tag);
|
|
467
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
468
|
-
const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8"));
|
|
469
|
-
if (exp > 0 && Date.now() > exp) return null;
|
|
470
|
-
return state;
|
|
471
|
-
} catch {
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
var HandleStateStrategy = class {
|
|
477
|
-
constructor(config) {
|
|
478
|
-
this.config = config;
|
|
479
|
-
}
|
|
480
|
-
async persist(state, options, overrides) {
|
|
481
|
-
const handle = overrides?.handle ?? (this.config.generateHandle ?? node_crypto.randomUUID)();
|
|
482
|
-
const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
|
|
483
|
-
const expiresAt = ttl > 0 ? Date.now() + ttl : void 0;
|
|
484
|
-
await this.config.store.set(handle, state, expiresAt);
|
|
485
|
-
return handle;
|
|
486
|
-
}
|
|
487
|
-
async retrieve(token) {
|
|
488
|
-
return (await this.config.store.get(token))?.state ?? null;
|
|
489
|
-
}
|
|
490
|
-
async consume(token) {
|
|
491
|
-
return (await this.config.store.getAndDelete(token))?.state ?? null;
|
|
492
|
-
}
|
|
493
|
-
};
|
|
494
|
-
/**
|
|
495
|
-
* In-memory state store for development and testing.
|
|
496
|
-
* State is lost on process restart.
|
|
497
|
-
*/
|
|
498
|
-
var WfStateStoreMemory = class {
|
|
499
|
-
constructor() {
|
|
500
|
-
this.store = /* @__PURE__ */ new Map();
|
|
501
|
-
}
|
|
502
|
-
async set(handle, state, expiresAt) {
|
|
503
|
-
this.store.set(handle, {
|
|
504
|
-
state,
|
|
505
|
-
expiresAt
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
async get(handle) {
|
|
509
|
-
const entry = this.store.get(handle);
|
|
510
|
-
if (!entry) return null;
|
|
511
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
512
|
-
this.store.delete(handle);
|
|
513
|
-
return null;
|
|
514
|
-
}
|
|
515
|
-
return entry;
|
|
516
|
-
}
|
|
517
|
-
async delete(handle) {
|
|
518
|
-
this.store.delete(handle);
|
|
519
|
-
}
|
|
520
|
-
async getAndDelete(handle) {
|
|
521
|
-
const entry = await this.get(handle);
|
|
522
|
-
if (entry) this.store.delete(handle);
|
|
523
|
-
return entry;
|
|
524
|
-
}
|
|
525
|
-
async cleanup() {
|
|
526
|
-
const now = Date.now();
|
|
527
|
-
let count = 0;
|
|
528
|
-
for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) {
|
|
529
|
-
this.store.delete(handle);
|
|
530
|
-
count++;
|
|
531
|
-
}
|
|
532
|
-
return count;
|
|
533
|
-
}
|
|
534
|
-
};
|
|
535
|
-
|
|
536
402
|
//#endregion
|
|
537
403
|
//#region packages/event-wf/src/workflow.ts
|
|
538
404
|
/** Workflow engine that resolves steps via Wooks router lookup. */
|
|
@@ -640,6 +506,7 @@ var WooksWf = class extends wooks.WooksAdapterBase {
|
|
|
640
506
|
indexes,
|
|
641
507
|
input
|
|
642
508
|
}, async () => {
|
|
509
|
+
if (opts?.strategy?.name !== void 0) (0, _wooksjs_event_core.current)().set(stateStrategyNameKey, opts.strategy.name);
|
|
643
510
|
const { handlers: foundHandlers } = this.wooks.lookup("WF_FLOW", `/${schemaId}`.replace(/^\/+/u, "/"));
|
|
644
511
|
const handlers = foundHandlers || this.opts?.onNotFound && [this.opts.onNotFound] || null;
|
|
645
512
|
if (handlers && handlers.length > 0) {
|
|
@@ -673,6 +540,10 @@ var WooksWf = class extends wooks.WooksAdapterBase {
|
|
|
673
540
|
throw error;
|
|
674
541
|
}
|
|
675
542
|
clean();
|
|
543
|
+
if (result.inputRequired) {
|
|
544
|
+
const finalName = (0, _wooksjs_event_core.current)().get(stateStrategyNameKey);
|
|
545
|
+
if (finalName !== void 0) result.inputRequired.stateStrategy = finalName;
|
|
546
|
+
}
|
|
676
547
|
if (result.resume) result.resume = (_input) => this.resume(result.state, {
|
|
677
548
|
input: _input,
|
|
678
549
|
spy,
|
|
@@ -717,15 +588,30 @@ function createWfApp(opts, wooks$2) {
|
|
|
717
588
|
}
|
|
718
589
|
|
|
719
590
|
//#endregion
|
|
720
|
-
exports
|
|
721
|
-
|
|
591
|
+
Object.defineProperty(exports, 'EncapsulatedStateStrategy', {
|
|
592
|
+
enumerable: true,
|
|
593
|
+
get: function () {
|
|
594
|
+
return _prostojs_wf_outlets.EncapsulatedStateStrategy;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
Object.defineProperty(exports, 'HandleStateStrategy', {
|
|
598
|
+
enumerable: true,
|
|
599
|
+
get: function () {
|
|
600
|
+
return _prostojs_wf_outlets.HandleStateStrategy;
|
|
601
|
+
}
|
|
602
|
+
});
|
|
722
603
|
Object.defineProperty(exports, 'StepRetriableError', {
|
|
723
604
|
enumerable: true,
|
|
724
605
|
get: function () {
|
|
725
606
|
return _prostojs_wf.StepRetriableError;
|
|
726
607
|
}
|
|
727
608
|
});
|
|
728
|
-
exports
|
|
609
|
+
Object.defineProperty(exports, 'WfStateStoreMemory', {
|
|
610
|
+
enumerable: true,
|
|
611
|
+
get: function () {
|
|
612
|
+
return _prostojs_wf_outlets.WfStateStoreMemory;
|
|
613
|
+
}
|
|
614
|
+
});
|
|
729
615
|
exports.WooksWf = WooksWf;
|
|
730
616
|
exports.createEmailOutlet = createEmailOutlet;
|
|
731
617
|
exports.createHttpOutlet = createHttpOutlet;
|
|
@@ -733,11 +619,27 @@ exports.createOutletHandler = createOutletHandler;
|
|
|
733
619
|
exports.createWfApp = createWfApp;
|
|
734
620
|
exports.createWfContext = createWfContext;
|
|
735
621
|
exports.handleWfOutletRequest = handleWfOutletRequest;
|
|
736
|
-
exports
|
|
737
|
-
|
|
738
|
-
|
|
622
|
+
Object.defineProperty(exports, 'outlet', {
|
|
623
|
+
enumerable: true,
|
|
624
|
+
get: function () {
|
|
625
|
+
return _prostojs_wf_outlets.outlet;
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
Object.defineProperty(exports, 'outletEmail', {
|
|
629
|
+
enumerable: true,
|
|
630
|
+
get: function () {
|
|
631
|
+
return _prostojs_wf_outlets.outletEmail;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
Object.defineProperty(exports, 'outletHttp', {
|
|
635
|
+
enumerable: true,
|
|
636
|
+
get: function () {
|
|
637
|
+
return _prostojs_wf_outlets.outletHttp;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
739
640
|
exports.resumeKey = resumeKey;
|
|
740
641
|
exports.resumeWfContext = resumeWfContext;
|
|
642
|
+
exports.swapStrategy = swapStrategy;
|
|
741
643
|
Object.defineProperty(exports, 'useLogger', {
|
|
742
644
|
enumerable: true,
|
|
743
645
|
get: function () {
|
|
@@ -753,5 +655,6 @@ Object.defineProperty(exports, 'useRouteParams', {
|
|
|
753
655
|
exports.useWfFinished = useWfFinished;
|
|
754
656
|
exports.useWfOutlet = useWfOutlet;
|
|
755
657
|
exports.useWfState = useWfState;
|
|
658
|
+
exports.useWfStrategy = useWfStrategy;
|
|
756
659
|
exports.wfKind = wfKind;
|
|
757
660
|
exports.wfShortcuts = wfShortcuts;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as _wooksjs_event_core from '@wooksjs/event-core';
|
|
|
2
2
|
import { EventContextOptions, EventKindSeeds, EventContext } from '@wooksjs/event-core';
|
|
3
3
|
export { EventContext, EventContextOptions, useLogger, useRouteParams } from '@wooksjs/event-core';
|
|
4
4
|
import * as _prostojs_wf_outlets from '@prostojs/wf/outlets';
|
|
5
|
-
import { WfStateStrategy, WfOutlet
|
|
5
|
+
import { WfOutletRequest, WfStateStrategy, WfOutlet } from '@prostojs/wf/outlets';
|
|
6
6
|
export { EncapsulatedStateStrategy, HandleStateStrategy, WfOutlet, WfOutletRequest, WfOutletResult, WfState, WfStateStore, WfStateStoreMemory, WfStateStrategy, outlet, outletEmail, outletHttp } from '@prostojs/wf/outlets';
|
|
7
7
|
import { TFlowOutput, Workflow, Step, TWorkflowSpy, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
|
|
8
8
|
export { StepRetriableError, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
|
|
@@ -50,7 +50,6 @@ declare function resumeWfContext<R>(options: EventContextOptions, seeds: EventKi
|
|
|
50
50
|
* where steps need to inspect or modify outlet state directly.
|
|
51
51
|
*/
|
|
52
52
|
declare const useWfOutlet: _wooksjs_event_core.WookComposable<{
|
|
53
|
-
getStateStrategy: () => _prostojs_wf_outlets.WfStateStrategy;
|
|
54
53
|
getOutlets: () => Map<string, _prostojs_wf_outlets.WfOutlet>;
|
|
55
54
|
getOutlet: (name: string) => _prostojs_wf_outlets.WfOutlet | null;
|
|
56
55
|
}>;
|
|
@@ -85,6 +84,58 @@ declare const useWfFinished: _wooksjs_event_core.WookComposable<{
|
|
|
85
84
|
get: () => WfFinishedResponse | undefined;
|
|
86
85
|
}>;
|
|
87
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Composable for inspecting / swapping the active state strategy name from
|
|
89
|
+
* within a workflow step. The new name applies to the NEXT pause — it travels
|
|
90
|
+
* back to the outlet trigger via `output.inputRequired.stateStrategy`, and
|
|
91
|
+
* persists in the issued token's prefix.
|
|
92
|
+
*
|
|
93
|
+
* The composable only validates the name FORMAT (regex). Existence in the
|
|
94
|
+
* trigger's strategy registry is validated at pause time by the trigger
|
|
95
|
+
* itself — `swapStrategy('typo')` here will not throw; the trigger will
|
|
96
|
+
* throw on pause when it can't find 'typo' in its registry.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* // Step that escalates from in-memory state to a durable KV store before
|
|
101
|
+
* // pausing for a long-running approval:
|
|
102
|
+
* app.step('await-approval', {
|
|
103
|
+
* handler: () => {
|
|
104
|
+
* swapStrategy('kv')
|
|
105
|
+
* return outletHttp({ fields: ['decision'] })
|
|
106
|
+
* },
|
|
107
|
+
* })
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
declare const useWfStrategy: _wooksjs_event_core.WookComposable<{
|
|
111
|
+
/** Name of the strategy currently set for the next persist. */
|
|
112
|
+
current: () => string;
|
|
113
|
+
/**
|
|
114
|
+
* Swap the active strategy by name. Validates name format only; unknown
|
|
115
|
+
* names surface as a loud error at pause time from the trigger.
|
|
116
|
+
*/
|
|
117
|
+
swap: (name: string) => void;
|
|
118
|
+
}>;
|
|
119
|
+
/**
|
|
120
|
+
* Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
|
|
121
|
+
* can write `return swapStrategy('kv')` when no outlet pause follows.
|
|
122
|
+
*/
|
|
123
|
+
declare function swapStrategy(name: string): undefined;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `WfOutletRequest` extended with the strategy name that was active when the
|
|
127
|
+
* workflow paused. The WF adapter augments `output.inputRequired` with this
|
|
128
|
+
* field so callers can persist the next token under the post-swap strategy
|
|
129
|
+
* without depending on EventContext write-through.
|
|
130
|
+
*
|
|
131
|
+
* The field is named generically (`stateStrategy`) — outlet handlers that
|
|
132
|
+
* don't care about strategies ignore it; outlet triggers and offline
|
|
133
|
+
* resume drivers read it to look up the right strategy in their registry.
|
|
134
|
+
*/
|
|
135
|
+
type WfPauseRequest<P = unknown> = WfOutletRequest<P> & {
|
|
136
|
+
stateStrategy?: string;
|
|
137
|
+
};
|
|
138
|
+
|
|
88
139
|
interface WfOutletTokenConfig {
|
|
89
140
|
/** Where to read state token from incoming request (default: `['body', 'query', 'cookie']`) */
|
|
90
141
|
read?: Array<'body' | 'query' | 'cookie'>;
|
|
@@ -99,26 +150,28 @@ interface WfOutletTriggerConfig {
|
|
|
99
150
|
/** Blacklist of workflow IDs. Checked after allow. */
|
|
100
151
|
block?: string[];
|
|
101
152
|
/**
|
|
102
|
-
* State persistence strategy.
|
|
103
|
-
* workflows, or a function that returns a strategy per workflow ID.
|
|
153
|
+
* State persistence strategy. Two forms:
|
|
104
154
|
*
|
|
105
|
-
* **
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
155
|
+
* - **Single-strategy shortcut**: pass a `WfStateStrategy` directly. The
|
|
156
|
+
* trigger registers it internally under the name `'default'`.
|
|
157
|
+
* - **Named map**: `{ strategies, default }` where `strategies` is a
|
|
158
|
+
* `Record<name, WfStateStrategy>` and `default` is either the name to use
|
|
159
|
+
* on workflow start or a function `(wfid) => name` that picks per
|
|
160
|
+
* workflow id. Steps may then call `swapStrategy(name)` to escalate the
|
|
161
|
+
* *next* outlet pause to a different strategy.
|
|
109
162
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* - OR every resume request must carry `wfid` so the correct strategy is
|
|
115
|
-
* always resolved.
|
|
163
|
+
* The active strategy name is embedded in the issued token as `<name>.<raw>`,
|
|
164
|
+
* so resume always picks the strategy that persisted the state. Each
|
|
165
|
+
* strategy can therefore have its own independent storage (no need for
|
|
166
|
+
* shared keyspaces between strategies).
|
|
116
167
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* token remains live in the real strategy.
|
|
168
|
+
* Strategy names must match `/^[A-Za-z0-9_-]+$/` (validated at trigger
|
|
169
|
+
* invocation).
|
|
120
170
|
*/
|
|
121
|
-
state: WfStateStrategy |
|
|
171
|
+
state: WfStateStrategy | {
|
|
172
|
+
strategies: Record<string, WfStateStrategy>;
|
|
173
|
+
default: string | ((wfid: string) => string);
|
|
174
|
+
};
|
|
122
175
|
/** Registered outlets */
|
|
123
176
|
outlets: WfOutlet[];
|
|
124
177
|
/** Token configuration (reading, writing, naming) */
|
|
@@ -148,7 +201,10 @@ interface WfOutletTriggerDeps {
|
|
|
148
201
|
start: (schemaId: string, context: unknown, opts?: {
|
|
149
202
|
input?: unknown;
|
|
150
203
|
eventContext?: unknown;
|
|
151
|
-
|
|
204
|
+
strategy?: {
|
|
205
|
+
name: string;
|
|
206
|
+
};
|
|
207
|
+
}) => Promise<TFlowOutput<unknown, unknown, WfPauseRequest>>;
|
|
152
208
|
/** Resume a workflow. Provided by WooksWf or MoostWf. */
|
|
153
209
|
resume: (state: {
|
|
154
210
|
schemaId: string;
|
|
@@ -157,7 +213,10 @@ interface WfOutletTriggerDeps {
|
|
|
157
213
|
}, opts?: {
|
|
158
214
|
input?: unknown;
|
|
159
215
|
eventContext?: unknown;
|
|
160
|
-
|
|
216
|
+
strategy?: {
|
|
217
|
+
name: string;
|
|
218
|
+
};
|
|
219
|
+
}) => Promise<TFlowOutput<unknown, unknown, WfPauseRequest>>;
|
|
161
220
|
}
|
|
162
221
|
|
|
163
222
|
/**
|
|
@@ -281,6 +340,20 @@ interface TWfRunOptions<I = unknown, T = unknown, IR = unknown> {
|
|
|
281
340
|
* Pass `current()` from within an active event scope (HTTP handler, etc.).
|
|
282
341
|
*/
|
|
283
342
|
eventContext?: EventContext;
|
|
343
|
+
/**
|
|
344
|
+
* Initial state strategy for the workflow run. Sets the strategy name on
|
|
345
|
+
* the WF event context so steps can inspect it via `useWfStrategy().current()`
|
|
346
|
+
* and swap it via `useWfStrategy().swap(name)`. The final post-swap name
|
|
347
|
+
* is reflected on `output.inputRequired.stateStrategy` (when paused) so
|
|
348
|
+
* callers can persist under the right keyspace without depending on
|
|
349
|
+
* EventContext write-through.
|
|
350
|
+
*
|
|
351
|
+
* The adapter only carries the name — strategy instances live in the
|
|
352
|
+
* caller (HTTP trigger, offline driver, etc.).
|
|
353
|
+
*/
|
|
354
|
+
strategy?: {
|
|
355
|
+
name: string;
|
|
356
|
+
};
|
|
284
357
|
}
|
|
285
358
|
/** Wooks adapter for defining and executing workflow schemas with step-based routing. */
|
|
286
359
|
declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
|
|
@@ -349,5 +422,5 @@ declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
|
|
|
349
422
|
*/
|
|
350
423
|
declare function createWfApp<T>(opts?: TWooksWfOptions, wooks?: Wooks | WooksAdapterBase): WooksWf<T, any>;
|
|
351
424
|
|
|
352
|
-
export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
|
|
353
|
-
export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps };
|
|
425
|
+
export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, swapStrategy, useWfFinished, useWfOutlet, useWfState, useWfStrategy, wfKind, wfShortcuts };
|
|
426
|
+
export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps, WfPauseRequest };
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEventContext, current, defineEventKind, defineWook, key, slot, useLogger, useRouteParams } from "@wooksjs/event-core";
|
|
2
2
|
import { useCookies, useResponse, useUrlParams } from "@wooksjs/event-http";
|
|
3
3
|
import { useBody } from "@wooksjs/http-body";
|
|
4
|
-
import {
|
|
4
|
+
import { EncapsulatedStateStrategy, HandleStateStrategy, WfStateStoreMemory, outlet, outletEmail, outletHttp } from "@prostojs/wf/outlets";
|
|
5
5
|
import { StepRetriableError, Workflow, createStep } from "@prostojs/wf";
|
|
6
6
|
import { WooksAdapterBase } from "wooks";
|
|
7
7
|
|
|
@@ -56,8 +56,6 @@ function resumeWfContext(options, seeds, fn) {
|
|
|
56
56
|
//#region packages/event-wf/src/outlets/outlet-context.ts
|
|
57
57
|
/** Registered outlet handlers, keyed by name */
|
|
58
58
|
const outletsRegistryKey = key("wf.outlets.registry");
|
|
59
|
-
/** Active state strategy for current request */
|
|
60
|
-
const stateStrategyKey = key("wf.outlets.stateStrategy");
|
|
61
59
|
/** Finished response set by workflow steps */
|
|
62
60
|
const wfFinishedKey = key("wf.outlets.finished");
|
|
63
61
|
|
|
@@ -71,7 +69,6 @@ const wfFinishedKey = key("wf.outlets.finished");
|
|
|
71
69
|
* where steps need to inspect or modify outlet state directly.
|
|
72
70
|
*/
|
|
73
71
|
const useWfOutlet = defineWook((ctx) => ({
|
|
74
|
-
getStateStrategy: () => ctx.get(stateStrategyKey),
|
|
75
72
|
getOutlets: () => ctx.get(outletsRegistryKey),
|
|
76
73
|
getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
|
|
77
74
|
}));
|
|
@@ -95,8 +92,81 @@ const useWfFinished = defineWook((ctx) => ({
|
|
|
95
92
|
get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
|
|
96
93
|
}));
|
|
97
94
|
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region packages/event-wf/src/strategy-context.ts
|
|
97
|
+
/** Name of the strategy currently active for the running workflow. */
|
|
98
|
+
const stateStrategyNameKey = key("wf.strategyName");
|
|
99
|
+
/** Strategy names must match this regex (validated at swap call + config). */
|
|
100
|
+
const STRATEGY_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region packages/event-wf/src/outlets/use-wf-strategy.ts
|
|
104
|
+
/**
|
|
105
|
+
* Composable for inspecting / swapping the active state strategy name from
|
|
106
|
+
* within a workflow step. The new name applies to the NEXT pause — it travels
|
|
107
|
+
* back to the outlet trigger via `output.inputRequired.stateStrategy`, and
|
|
108
|
+
* persists in the issued token's prefix.
|
|
109
|
+
*
|
|
110
|
+
* The composable only validates the name FORMAT (regex). Existence in the
|
|
111
|
+
* trigger's strategy registry is validated at pause time by the trigger
|
|
112
|
+
* itself — `swapStrategy('typo')` here will not throw; the trigger will
|
|
113
|
+
* throw on pause when it can't find 'typo' in its registry.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* // Step that escalates from in-memory state to a durable KV store before
|
|
118
|
+
* // pausing for a long-running approval:
|
|
119
|
+
* app.step('await-approval', {
|
|
120
|
+
* handler: () => {
|
|
121
|
+
* swapStrategy('kv')
|
|
122
|
+
* return outletHttp({ fields: ['decision'] })
|
|
123
|
+
* },
|
|
124
|
+
* })
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
const useWfStrategy = defineWook((ctx) => ({
|
|
128
|
+
current: () => ctx.get(stateStrategyNameKey),
|
|
129
|
+
swap: (name) => {
|
|
130
|
+
if (!STRATEGY_NAME_RE.test(name)) throw new Error(`swapStrategy: invalid name '${name}' — must match ${STRATEGY_NAME_RE}`);
|
|
131
|
+
ctx.set(stateStrategyNameKey, name);
|
|
132
|
+
}
|
|
133
|
+
}));
|
|
134
|
+
/**
|
|
135
|
+
* Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
|
|
136
|
+
* can write `return swapStrategy('kv')` when no outlet pause follows.
|
|
137
|
+
*/
|
|
138
|
+
function swapStrategy(name) {
|
|
139
|
+
useWfStrategy().swap(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
//#endregion
|
|
99
143
|
//#region packages/event-wf/src/outlets/trigger.ts
|
|
144
|
+
function wrapToken(name, raw) {
|
|
145
|
+
return `${name}.${raw}`;
|
|
146
|
+
}
|
|
147
|
+
function unwrapToken(token) {
|
|
148
|
+
const i = token.indexOf(".");
|
|
149
|
+
if (i <= 0 || i === token.length - 1) return null;
|
|
150
|
+
return {
|
|
151
|
+
name: token.slice(0, i),
|
|
152
|
+
raw: token.slice(i + 1)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function normalizeStateConfig(state) {
|
|
156
|
+
if (typeof state === "object" && state !== null && "strategies" in state) {
|
|
157
|
+
const registry = state.strategies;
|
|
158
|
+
for (const name of Object.keys(registry)) if (!STRATEGY_NAME_RE.test(name)) throw new Error(`Invalid strategy name '${name}': must match /^[A-Za-z0-9_-]+$/`);
|
|
159
|
+
const def = state.default;
|
|
160
|
+
return {
|
|
161
|
+
registry,
|
|
162
|
+
resolveDefaultName: typeof def === "function" ? def : (_wfid) => def
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
registry: { default: state },
|
|
167
|
+
resolveDefaultName: () => "default"
|
|
168
|
+
};
|
|
169
|
+
}
|
|
100
170
|
/**
|
|
101
171
|
* Handle an HTTP request that starts or resumes a workflow.
|
|
102
172
|
*
|
|
@@ -128,6 +198,7 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
128
198
|
const registry = new Map(config.outlets.map((o) => [o.name, o]));
|
|
129
199
|
ctx.set(outletsRegistryKey, registry);
|
|
130
200
|
ctx.set(wfFinishedKey, void 0);
|
|
201
|
+
const { registry: strategyRegistry, resolveDefaultName } = normalizeStateConfig(config.state);
|
|
131
202
|
const { parseBody } = useBody();
|
|
132
203
|
const { params } = useUrlParams();
|
|
133
204
|
const { getCookie } = useCookies();
|
|
@@ -143,27 +214,31 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
143
214
|
}
|
|
144
215
|
const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
|
|
145
216
|
const input = body?.input;
|
|
146
|
-
const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
|
|
147
217
|
let output;
|
|
148
|
-
let
|
|
218
|
+
let incomingName;
|
|
219
|
+
let incomingRaw;
|
|
149
220
|
if (token) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
221
|
+
const unwrapped = unwrapToken(token);
|
|
222
|
+
if (!unwrapped) {
|
|
223
|
+
response.setStatus(410);
|
|
224
|
+
return { error: "Invalid workflow state token" };
|
|
225
|
+
}
|
|
226
|
+
const strategy = Object.prototype.hasOwnProperty.call(strategyRegistry, unwrapped.name) ? strategyRegistry[unwrapped.name] : void 0;
|
|
227
|
+
if (!strategy) {
|
|
228
|
+
response.setStatus(410);
|
|
229
|
+
return { error: "Invalid workflow state token" };
|
|
230
|
+
}
|
|
231
|
+
incomingName = unwrapped.name;
|
|
232
|
+
incomingRaw = unwrapped.raw;
|
|
233
|
+
const state = await strategy.consume(unwrapped.raw);
|
|
153
234
|
if (!state) {
|
|
154
235
|
response.setStatus(410);
|
|
155
236
|
return { error: "Invalid or expired workflow state" };
|
|
156
237
|
}
|
|
157
|
-
if (state.schemaId !== (wfid ?? "")) {
|
|
158
|
-
const realStrategy = resolveStrategy(state.schemaId);
|
|
159
|
-
if (realStrategy !== strategy) {
|
|
160
|
-
ctx.set(stateStrategyKey, realStrategy);
|
|
161
|
-
strategyReResolved = true;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
238
|
output = await deps.resume(state, {
|
|
165
239
|
input,
|
|
166
|
-
eventContext: ctx
|
|
240
|
+
eventContext: ctx,
|
|
241
|
+
strategy: { name: incomingName }
|
|
167
242
|
});
|
|
168
243
|
} else if (wfid) {
|
|
169
244
|
if (config.allow?.length && !config.allow.includes(wfid)) {
|
|
@@ -174,12 +249,13 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
174
249
|
response.setStatus(403);
|
|
175
250
|
return { error: `Workflow '${wfid}' is blocked` };
|
|
176
251
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
252
|
+
const defaultName = resolveDefaultName(wfid);
|
|
253
|
+
if (!(Object.prototype.hasOwnProperty.call(strategyRegistry, defaultName) ? strategyRegistry[defaultName] : void 0)) throw new Error(`Default strategy '${defaultName}' not found in registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
|
|
179
254
|
const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
|
|
180
255
|
output = await deps.start(wfid, initialContext, {
|
|
181
256
|
input,
|
|
182
|
-
eventContext: ctx
|
|
257
|
+
eventContext: ctx,
|
|
258
|
+
strategy: { name: defaultName }
|
|
183
259
|
});
|
|
184
260
|
} else {
|
|
185
261
|
response.setStatus(400);
|
|
@@ -210,13 +286,16 @@ async function handleWfOutletRequest(config, deps) {
|
|
|
210
286
|
response.setStatus(500);
|
|
211
287
|
return { error: `Unknown outlet: '${outletReq.outlet}'` };
|
|
212
288
|
}
|
|
213
|
-
const
|
|
289
|
+
const finalName = outletReq.stateStrategy;
|
|
290
|
+
if (finalName === void 0) throw new Error("Workflow paused without `stateStrategy` on inputRequired — the WF adapter must augment the output with the active strategy name.");
|
|
291
|
+
const finalStrategy = Object.prototype.hasOwnProperty.call(strategyRegistry, finalName) ? strategyRegistry[finalName] : void 0;
|
|
292
|
+
if (!finalStrategy) throw new Error(`Workflow paused with unknown strategy '${finalName}' — step swapped to a name not in the trigger's registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
|
|
214
293
|
const stateWithMeta = {
|
|
215
294
|
...output.state,
|
|
216
295
|
meta: { outlet: outletReq.outlet }
|
|
217
296
|
};
|
|
218
|
-
const reuseHandle =
|
|
219
|
-
const newToken = await
|
|
297
|
+
const reuseHandle = incomingName !== void 0 && incomingName === finalName && incomingRaw !== void 0 ? { handle: incomingRaw } : void 0;
|
|
298
|
+
const newToken = wrapToken(finalName, await finalStrategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle));
|
|
220
299
|
const outOfBand = outletHandler.tokenDelivery === "out-of-band";
|
|
221
300
|
if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
|
|
222
301
|
httpOnly: true,
|
|
@@ -319,219 +398,6 @@ function createOutletHandler(wfApp) {
|
|
|
319
398
|
});
|
|
320
399
|
}
|
|
321
400
|
|
|
322
|
-
//#endregion
|
|
323
|
-
//#region node_modules/.pnpm/@prostojs+wf@0.2.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
|
|
324
|
-
/**
|
|
325
|
-
* Generic outlet request. Use for custom outlets.
|
|
326
|
-
*
|
|
327
|
-
* @example
|
|
328
|
-
* return outlet('pending-task', {
|
|
329
|
-
* payload: ApprovalForm,
|
|
330
|
-
* target: managerId,
|
|
331
|
-
* context: { orderId, amount },
|
|
332
|
-
* })
|
|
333
|
-
*/
|
|
334
|
-
function outlet(name, data) {
|
|
335
|
-
return { inputRequired: {
|
|
336
|
-
outlet: name,
|
|
337
|
-
...data
|
|
338
|
-
} };
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Pause for HTTP form input. The outlet returns the payload (form definition)
|
|
342
|
-
* and state token in the HTTP response.
|
|
343
|
-
*
|
|
344
|
-
* @example
|
|
345
|
-
* return outletHttp(LoginForm)
|
|
346
|
-
* return outletHttp(LoginForm, { error: 'Invalid credentials' })
|
|
347
|
-
*/
|
|
348
|
-
function outletHttp(payload, context) {
|
|
349
|
-
return outlet("http", {
|
|
350
|
-
payload,
|
|
351
|
-
context
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Pause and send email with a magic link containing the state token.
|
|
356
|
-
*
|
|
357
|
-
* @example
|
|
358
|
-
* return outletEmail('user@test.com', 'invite', { name: 'Alice' })
|
|
359
|
-
*/
|
|
360
|
-
function outletEmail(target, template, context) {
|
|
361
|
-
return outlet("email", {
|
|
362
|
-
target,
|
|
363
|
-
template,
|
|
364
|
-
context
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Self-contained AES-256-GCM encrypted state strategy.
|
|
369
|
-
*
|
|
370
|
-
* Workflow state is encrypted into a base64url token that travels with the
|
|
371
|
-
* transport (cookie, URL param, hidden field). No server-side storage needed.
|
|
372
|
-
*
|
|
373
|
-
* Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
|
|
374
|
-
*
|
|
375
|
-
* ## Security warning — replay
|
|
376
|
-
*
|
|
377
|
-
* This strategy is STATELESS. It cannot enforce single-use semantics:
|
|
378
|
-
* `consume()` is a no-op alias for `retrieve()` because there is no
|
|
379
|
-
* server-side record to delete. Anyone who obtains a copy of the token
|
|
380
|
-
* (browser history, server logs, shoulder-surfing, intermediate proxy,
|
|
381
|
-
* shared device) can replay it until the TTL expires.
|
|
382
|
-
*
|
|
383
|
-
* Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
|
|
384
|
-
*
|
|
385
|
-
* 1. Every workflow step is idempotent — re-executing a step with the same
|
|
386
|
-
* input produces no harmful side effects (pure data collection,
|
|
387
|
-
* validation-only steps).
|
|
388
|
-
* 2. The flow is not security-sensitive — no credential changes, financial
|
|
389
|
-
* operations, account provisioning, permission grants, or any other
|
|
390
|
-
* privileged action.
|
|
391
|
-
*
|
|
392
|
-
* For auth flows (login, password reset, invite accept), financial
|
|
393
|
-
* operations, or anything with meaningful side effects, use
|
|
394
|
-
* `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
|
|
395
|
-
* supports true single-use tokens via atomic `getAndDelete` at the store
|
|
396
|
-
* layer.
|
|
397
|
-
*
|
|
398
|
-
* @example
|
|
399
|
-
* const strategy = new EncapsulatedStateStrategy({
|
|
400
|
-
* secret: crypto.randomBytes(32),
|
|
401
|
-
* defaultTtl: 3600_000, // 1 hour
|
|
402
|
-
* });
|
|
403
|
-
* const token = await strategy.persist(state);
|
|
404
|
-
* const recovered = await strategy.retrieve(token);
|
|
405
|
-
*/
|
|
406
|
-
var EncapsulatedStateStrategy = class {
|
|
407
|
-
/** @throws if secret is not exactly 32 bytes */
|
|
408
|
-
constructor(config) {
|
|
409
|
-
this.config = config;
|
|
410
|
-
this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret;
|
|
411
|
-
if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes");
|
|
412
|
-
}
|
|
413
|
-
/**
|
|
414
|
-
* Encrypt workflow state into a self-contained token.
|
|
415
|
-
*
|
|
416
|
-
* NOTE: the `overrides.handle` hint from `WfStateStrategy` is silently
|
|
417
|
-
* ignored — the encapsulated token IS the ciphertext of the state, so a
|
|
418
|
-
* fixed handle cannot map to changing state. Callers that need handle
|
|
419
|
-
* stability across calls must use `HandleStateStrategy`.
|
|
420
|
-
*
|
|
421
|
-
* @param state — workflow state to persist
|
|
422
|
-
* @param options.ttl — time-to-live in ms (overrides defaultTtl)
|
|
423
|
-
* @returns base64url-encoded encrypted token
|
|
424
|
-
*/
|
|
425
|
-
async persist(state, options, _overrides) {
|
|
426
|
-
const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
|
|
427
|
-
const exp = ttl > 0 ? Date.now() + ttl : 0;
|
|
428
|
-
const payload = JSON.stringify({
|
|
429
|
-
s: state,
|
|
430
|
-
e: exp
|
|
431
|
-
});
|
|
432
|
-
const iv = randomBytes(12);
|
|
433
|
-
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
|
434
|
-
const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
|
|
435
|
-
const tag = cipher.getAuthTag();
|
|
436
|
-
return Buffer.concat([
|
|
437
|
-
iv,
|
|
438
|
-
tag,
|
|
439
|
-
encrypted
|
|
440
|
-
]).toString("base64url");
|
|
441
|
-
}
|
|
442
|
-
/** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */
|
|
443
|
-
async retrieve(token) {
|
|
444
|
-
return this.decrypt(token);
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* Stateless — CANNOT invalidate the token. Returns identical result to
|
|
448
|
-
* `retrieve()`. See the class-level security warning.
|
|
449
|
-
*
|
|
450
|
-
* This method exists only to satisfy the `WfStateStrategy` contract.
|
|
451
|
-
* Callers that need true single-use semantics must use
|
|
452
|
-
* `HandleStateStrategy`.
|
|
453
|
-
*/
|
|
454
|
-
async consume(token) {
|
|
455
|
-
return this.decrypt(token);
|
|
456
|
-
}
|
|
457
|
-
decrypt(token) {
|
|
458
|
-
try {
|
|
459
|
-
const buf = Buffer.from(token, "base64url");
|
|
460
|
-
if (buf.length < 28) return null;
|
|
461
|
-
const iv = buf.subarray(0, 12);
|
|
462
|
-
const tag = buf.subarray(12, 28);
|
|
463
|
-
const ciphertext = buf.subarray(28);
|
|
464
|
-
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
|
465
|
-
decipher.setAuthTag(tag);
|
|
466
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
467
|
-
const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8"));
|
|
468
|
-
if (exp > 0 && Date.now() > exp) return null;
|
|
469
|
-
return state;
|
|
470
|
-
} catch {
|
|
471
|
-
return null;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
var HandleStateStrategy = class {
|
|
476
|
-
constructor(config) {
|
|
477
|
-
this.config = config;
|
|
478
|
-
}
|
|
479
|
-
async persist(state, options, overrides) {
|
|
480
|
-
const handle = overrides?.handle ?? (this.config.generateHandle ?? randomUUID)();
|
|
481
|
-
const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
|
|
482
|
-
const expiresAt = ttl > 0 ? Date.now() + ttl : void 0;
|
|
483
|
-
await this.config.store.set(handle, state, expiresAt);
|
|
484
|
-
return handle;
|
|
485
|
-
}
|
|
486
|
-
async retrieve(token) {
|
|
487
|
-
return (await this.config.store.get(token))?.state ?? null;
|
|
488
|
-
}
|
|
489
|
-
async consume(token) {
|
|
490
|
-
return (await this.config.store.getAndDelete(token))?.state ?? null;
|
|
491
|
-
}
|
|
492
|
-
};
|
|
493
|
-
/**
|
|
494
|
-
* In-memory state store for development and testing.
|
|
495
|
-
* State is lost on process restart.
|
|
496
|
-
*/
|
|
497
|
-
var WfStateStoreMemory = class {
|
|
498
|
-
constructor() {
|
|
499
|
-
this.store = /* @__PURE__ */ new Map();
|
|
500
|
-
}
|
|
501
|
-
async set(handle, state, expiresAt) {
|
|
502
|
-
this.store.set(handle, {
|
|
503
|
-
state,
|
|
504
|
-
expiresAt
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
async get(handle) {
|
|
508
|
-
const entry = this.store.get(handle);
|
|
509
|
-
if (!entry) return null;
|
|
510
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
511
|
-
this.store.delete(handle);
|
|
512
|
-
return null;
|
|
513
|
-
}
|
|
514
|
-
return entry;
|
|
515
|
-
}
|
|
516
|
-
async delete(handle) {
|
|
517
|
-
this.store.delete(handle);
|
|
518
|
-
}
|
|
519
|
-
async getAndDelete(handle) {
|
|
520
|
-
const entry = await this.get(handle);
|
|
521
|
-
if (entry) this.store.delete(handle);
|
|
522
|
-
return entry;
|
|
523
|
-
}
|
|
524
|
-
async cleanup() {
|
|
525
|
-
const now = Date.now();
|
|
526
|
-
let count = 0;
|
|
527
|
-
for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) {
|
|
528
|
-
this.store.delete(handle);
|
|
529
|
-
count++;
|
|
530
|
-
}
|
|
531
|
-
return count;
|
|
532
|
-
}
|
|
533
|
-
};
|
|
534
|
-
|
|
535
401
|
//#endregion
|
|
536
402
|
//#region packages/event-wf/src/workflow.ts
|
|
537
403
|
/** Workflow engine that resolves steps via Wooks router lookup. */
|
|
@@ -639,6 +505,7 @@ var WooksWf = class extends WooksAdapterBase {
|
|
|
639
505
|
indexes,
|
|
640
506
|
input
|
|
641
507
|
}, async () => {
|
|
508
|
+
if (opts?.strategy?.name !== void 0) current().set(stateStrategyNameKey, opts.strategy.name);
|
|
642
509
|
const { handlers: foundHandlers } = this.wooks.lookup("WF_FLOW", `/${schemaId}`.replace(/^\/+/u, "/"));
|
|
643
510
|
const handlers = foundHandlers || this.opts?.onNotFound && [this.opts.onNotFound] || null;
|
|
644
511
|
if (handlers && handlers.length > 0) {
|
|
@@ -672,6 +539,10 @@ var WooksWf = class extends WooksAdapterBase {
|
|
|
672
539
|
throw error;
|
|
673
540
|
}
|
|
674
541
|
clean();
|
|
542
|
+
if (result.inputRequired) {
|
|
543
|
+
const finalName = current().get(stateStrategyNameKey);
|
|
544
|
+
if (finalName !== void 0) result.inputRequired.stateStrategy = finalName;
|
|
545
|
+
}
|
|
675
546
|
if (result.resume) result.resume = (_input) => this.resume(result.state, {
|
|
676
547
|
input: _input,
|
|
677
548
|
spy,
|
|
@@ -716,4 +587,4 @@ function createWfApp(opts, wooks) {
|
|
|
716
587
|
}
|
|
717
588
|
|
|
718
589
|
//#endregion
|
|
719
|
-
export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
|
|
590
|
+
export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, swapStrategy, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, useWfStrategy, wfKind, wfShortcuts };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wooksjs/event-wf",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "@wooksjs/event-wf",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app",
|
|
@@ -42,17 +42,17 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"typescript": "^5.9.3",
|
|
44
44
|
"vitest": "^3.2.4",
|
|
45
|
-
"@wooksjs/event-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"@wooksjs/
|
|
45
|
+
"@wooksjs/event-core": "^0.7.16",
|
|
46
|
+
"@wooksjs/event-http": "^0.7.16",
|
|
47
|
+
"wooks": "^0.7.16",
|
|
48
|
+
"@wooksjs/http-body": "^0.7.16"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@prostojs/logger": "^0.4.3",
|
|
52
|
-
"@wooksjs/event-core": "^0.7.
|
|
53
|
-
"@wooksjs/
|
|
54
|
-
"wooks": "^0.7.
|
|
55
|
-
"@wooksjs/http
|
|
52
|
+
"@wooksjs/event-core": "^0.7.16",
|
|
53
|
+
"@wooksjs/http-body": "^0.7.16",
|
|
54
|
+
"wooks": "^0.7.16",
|
|
55
|
+
"@wooksjs/event-http": "^0.7.16"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"@wooksjs/event-http": {
|