@wooksjs/event-wf 0.7.7 → 0.7.8

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 CHANGED
@@ -6,11 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __copyProps = (to, from, except, desc) => {
9
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key$1; i < n; i++) {
10
- key$1 = keys[i];
11
- if (!__hasOwnProp.call(to, key$1) && key$1 !== except) __defProp(to, key$1, {
12
- get: ((k) => from[k]).bind(null, key$1),
13
- enumerable: !(desc = __getOwnPropDesc(from, key$1)) || desc.enumerable
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key$2; i < n; i++) {
10
+ key$2 = keys[i];
11
+ if (!__hasOwnProp.call(to, key$2) && key$2 !== except) __defProp(to, key$2, {
12
+ get: ((k) => from[k]).bind(null, key$2),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key$2)) || desc.enumerable
14
14
  });
15
15
  }
16
16
  return to;
@@ -22,6 +22,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
 
23
23
  //#endregion
24
24
  const __wooksjs_event_core = __toESM(require("@wooksjs/event-core"));
25
+ const __wooksjs_event_http = __toESM(require("@wooksjs/event-http"));
26
+ const __wooksjs_http_body = __toESM(require("@wooksjs/http-body"));
27
+ const node_crypto = __toESM(require("node:crypto"));
25
28
  const __prostojs_wf = __toESM(require("@prostojs/wf"));
26
29
  const wooks = __toESM(require("wooks"));
27
30
 
@@ -46,17 +49,14 @@ const resumeKey = (0, __wooksjs_event_core.key)("wf.resume");
46
49
  * const stepInput = input<MyInput>()
47
50
  * ```
48
51
  */
49
- function useWfState() {
50
- const c = (0, __wooksjs_event_core.current)();
51
- return {
52
- ctx: () => c.get(wfKind.keys.inputContext),
53
- input: () => c.get(wfKind.keys.input),
54
- schemaId: c.get(wfKind.keys.schemaId),
55
- stepId: () => c.get(wfKind.keys.stepId),
56
- indexes: () => c.get(wfKind.keys.indexes),
57
- resume: c.get(resumeKey)
58
- };
59
- }
52
+ const useWfState = (0, __wooksjs_event_core.defineWook)((c) => ({
53
+ ctx: () => c.get(wfKind.keys.inputContext),
54
+ input: () => c.get(wfKind.keys.input),
55
+ schemaId: c.get(wfKind.keys.schemaId),
56
+ stepId: () => c.get(wfKind.keys.stepId),
57
+ indexes: () => c.get(wfKind.keys.indexes),
58
+ resume: c.get(resumeKey)
59
+ }));
60
60
 
61
61
  //#endregion
62
62
  //#region packages/event-wf/src/event-wf.ts
@@ -75,6 +75,446 @@ function resumeWfContext(options, seeds, fn) {
75
75
  });
76
76
  }
77
77
 
78
+ //#endregion
79
+ //#region packages/event-wf/src/outlets/outlet-context.ts
80
+ /** Registered outlet handlers, keyed by name */
81
+ const outletsRegistryKey = (0, __wooksjs_event_core.key)("wf.outlets.registry");
82
+ /** Active state strategy for current request */
83
+ const stateStrategyKey = (0, __wooksjs_event_core.key)("wf.outlets.stateStrategy");
84
+ /** Finished response set by workflow steps */
85
+ const wfFinishedKey = (0, __wooksjs_event_core.key)("wf.outlets.finished");
86
+
87
+ //#endregion
88
+ //#region packages/event-wf/src/outlets/use-wf-outlet.ts
89
+ /**
90
+ * Composable for accessing outlet infrastructure from within workflow steps.
91
+ *
92
+ * Most steps don't need this — they just return `outletHttp(form)` or
93
+ * `outletEmail(to, template)`. This composable is for advanced cases
94
+ * where steps need to inspect or modify outlet state directly.
95
+ */
96
+ const useWfOutlet = (0, __wooksjs_event_core.defineWook)((ctx) => ({
97
+ getStateStrategy: () => ctx.get(stateStrategyKey),
98
+ getOutlets: () => ctx.get(outletsRegistryKey),
99
+ getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
100
+ }));
101
+
102
+ //#endregion
103
+ //#region packages/event-wf/src/outlets/use-wf-finished.ts
104
+ /**
105
+ * Composable to set the completion response for a finished workflow.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * // Redirect after login
110
+ * useWfFinished().set({ type: 'redirect', value: '/dashboard' })
111
+ *
112
+ * // Return data
113
+ * useWfFinished().set({ type: 'data', value: { success: true } })
114
+ * ```
115
+ */
116
+ const useWfFinished = (0, __wooksjs_event_core.defineWook)((ctx) => ({
117
+ set: (response) => ctx.set(wfFinishedKey, response),
118
+ get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
119
+ }));
120
+
121
+ //#endregion
122
+ //#region packages/event-wf/src/outlets/trigger.ts
123
+ const DEFAULT_CONSUME_TOKEN = { email: true };
124
+ /**
125
+ * Handle an HTTP request that starts or resumes a workflow.
126
+ *
127
+ * Reads wfs (state token) and wfid (workflow ID) from request body, query params,
128
+ * or cookies — configurable via `config.token`. On workflow pause, persists state
129
+ * and dispatches to the named outlet. On finish, returns the finished response.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * // In a wooks HTTP handler:
134
+ * app.post('/workflow', () => handleWfOutletRequest(config, deps))
135
+ *
136
+ * // Better — use createOutletHandler():
137
+ * const handle = createOutletHandler(wfApp)
138
+ * app.post('/workflow', () => handle(config))
139
+ * ```
140
+ */
141
+ async function handleWfOutletRequest(config, deps) {
142
+ const tok = config.token ?? {};
143
+ const tokenName = tok.name ?? "wfs";
144
+ const tokenRead = tok.read ?? [
145
+ "body",
146
+ "query",
147
+ "cookie"
148
+ ];
149
+ const tokenWrite = tok.write ?? "body";
150
+ const wfidName = config.wfidName ?? "wfid";
151
+ const ctx = (0, __wooksjs_event_core.current)();
152
+ const registry = new Map(config.outlets.map((o) => [o.name, o]));
153
+ ctx.set(outletsRegistryKey, registry);
154
+ ctx.set(wfFinishedKey, void 0);
155
+ const { parseBody } = (0, __wooksjs_http_body.useBody)();
156
+ const { params } = (0, __wooksjs_event_http.useUrlParams)();
157
+ const { getCookie } = (0, __wooksjs_event_http.useCookies)();
158
+ const response = (0, __wooksjs_event_http.useResponse)();
159
+ const body = await parseBody().catch(() => void 0);
160
+ const queryParams = params();
161
+ let token;
162
+ for (const source of tokenRead) {
163
+ if (source === "body") token = body?.[tokenName];
164
+ else if (source === "query") token = queryParams.get(tokenName) ?? void 0;
165
+ else if (source === "cookie") token = getCookie(tokenName) ?? void 0;
166
+ if (token) break;
167
+ }
168
+ const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
169
+ const input = body?.input;
170
+ const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
171
+ const shouldConsume = (outletName) => {
172
+ if (typeof tok.consume === "boolean") return tok.consume;
173
+ return (tok.consume ?? DEFAULT_CONSUME_TOKEN)[outletName] ?? false;
174
+ };
175
+ let output;
176
+ if (token) {
177
+ const strategy = resolveStrategy(wfid ?? "");
178
+ ctx.set(stateStrategyKey, strategy);
179
+ const state = await strategy.retrieve(token);
180
+ if (!state) return {
181
+ error: "Invalid or expired workflow state",
182
+ status: 400
183
+ };
184
+ if (state.schemaId !== (wfid ?? "")) {
185
+ const realStrategy = resolveStrategy(state.schemaId);
186
+ ctx.set(stateStrategyKey, realStrategy);
187
+ }
188
+ const outletName = state.meta?.outlet;
189
+ if (outletName && shouldConsume(outletName)) await strategy.consume(token);
190
+ output = await deps.resume(state, {
191
+ input,
192
+ eventContext: ctx
193
+ });
194
+ } else if (wfid) {
195
+ if (config.allow?.length && !config.allow.includes(wfid)) return {
196
+ error: `Workflow '${wfid}' is not allowed`,
197
+ status: 403
198
+ };
199
+ if (config.block?.includes(wfid)) return {
200
+ error: `Workflow '${wfid}' is blocked`,
201
+ status: 403
202
+ };
203
+ const strategy = resolveStrategy(wfid);
204
+ ctx.set(stateStrategyKey, strategy);
205
+ const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
206
+ output = await deps.start(wfid, initialContext, {
207
+ input,
208
+ eventContext: ctx
209
+ });
210
+ } else return {
211
+ error: "Missing wfs (state token) or wfid (workflow ID)",
212
+ status: 400
213
+ };
214
+ if (output.finished) {
215
+ if (config.onFinished) return config.onFinished({
216
+ context: output.state.context,
217
+ schemaId: output.state.schemaId
218
+ });
219
+ const finished = ctx.get(wfFinishedKey);
220
+ if (finished?.cookies) for (const [name, cookie] of Object.entries(finished.cookies)) response.setCookie(name, cookie.value, cookie.options);
221
+ if (finished?.type === "redirect") {
222
+ response.setHeader("location", finished.value);
223
+ return { status: finished.status ?? 302 };
224
+ }
225
+ if (finished) return finished.value;
226
+ return { finished: true };
227
+ }
228
+ if (output.inputRequired) {
229
+ const outletReq = output.inputRequired;
230
+ const outletHandler = registry.get(outletReq.outlet);
231
+ if (!outletHandler) return {
232
+ error: `Unknown outlet: '${outletReq.outlet}'`,
233
+ status: 500
234
+ };
235
+ const strategy = ctx.get(stateStrategyKey);
236
+ const stateWithMeta = {
237
+ ...output.state,
238
+ meta: { outlet: outletReq.outlet }
239
+ };
240
+ const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0);
241
+ if (tokenWrite === "cookie") response.setCookie(tokenName, newToken, {
242
+ httpOnly: true,
243
+ sameSite: "Strict",
244
+ path: "/"
245
+ });
246
+ const result = await outletHandler.deliver(outletReq, newToken);
247
+ if (tokenWrite === "body" && result?.response && typeof result.response === "object") return {
248
+ ...result.response,
249
+ [tokenName]: newToken
250
+ };
251
+ return result?.response ?? { waiting: true };
252
+ }
253
+ if (output.error) return {
254
+ error: output.error.message,
255
+ errorList: output.errorList
256
+ };
257
+ return { error: "Unexpected workflow state" };
258
+ }
259
+
260
+ //#endregion
261
+ //#region packages/event-wf/src/outlets/create-outlet.ts
262
+ /**
263
+ * Creates an HTTP outlet that passes through the outlet payload as
264
+ * the HTTP response body. This is the most common outlet — it returns
265
+ * forms, prompts, or data to the client.
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * const httpOutlet = createHttpOutlet()
270
+ * // Step does: return outletHttp({ fields: ['email', 'password'] })
271
+ * // Client receives: { fields: ['email', 'password'] }
272
+ * ```
273
+ */
274
+ function createHttpOutlet(opts) {
275
+ return {
276
+ name: "http",
277
+ async deliver(request, _token) {
278
+ const body = opts?.transform ? opts.transform(request.payload, request.context) : typeof request.payload === "object" && request.payload !== null ? {
279
+ ...request.payload,
280
+ ...request.context
281
+ } : request.payload;
282
+ return { response: body };
283
+ }
284
+ };
285
+ }
286
+ /**
287
+ * Creates an email outlet that delegates to a user-provided send function.
288
+ * The send function receives the target, template, context, and the state
289
+ * token (for embedding in magic links / verification URLs).
290
+ *
291
+ * @example
292
+ * ```ts
293
+ * const emailOutlet = createEmailOutlet(async (opts) => {
294
+ * await mailer.send({
295
+ * to: opts.target,
296
+ * template: opts.template,
297
+ * data: { ...opts.context, verifyUrl: `/verify?wfs=${opts.token}` },
298
+ * })
299
+ * })
300
+ * ```
301
+ */
302
+ function createEmailOutlet(send) {
303
+ return {
304
+ name: "email",
305
+ async deliver(request, token) {
306
+ await send({
307
+ target: request.target ?? "",
308
+ template: request.template ?? "",
309
+ context: request.context ?? {},
310
+ token
311
+ });
312
+ return { response: {
313
+ sent: true,
314
+ outlet: "email"
315
+ } };
316
+ }
317
+ };
318
+ }
319
+
320
+ //#endregion
321
+ //#region packages/event-wf/src/outlets/create-handler.ts
322
+ /**
323
+ * Creates a pre-wired outlet handler from a workflow app instance.
324
+ * Eliminates the need to manually construct `WfOutletTriggerDeps`.
325
+ *
326
+ * Accepts any object with `start` and `resume` methods (WooksWf, MoostWf, etc.).
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * const handle = createOutletHandler(wfApp)
331
+ * httpApp.post('/workflow', () => handle(config))
332
+ * ```
333
+ */
334
+ function createOutletHandler(wfApp) {
335
+ return (config) => handleWfOutletRequest(config, {
336
+ start: (schemaId, context, opts) => wfApp.start(schemaId, context, opts),
337
+ resume: (state, opts) => wfApp.resume(state, opts)
338
+ });
339
+ }
340
+
341
+ //#endregion
342
+ //#region node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
343
+ /**
344
+ * Generic outlet request. Use for custom outlets.
345
+ *
346
+ * @example
347
+ * return outlet('pending-task', {
348
+ * payload: ApprovalForm,
349
+ * target: managerId,
350
+ * context: { orderId, amount },
351
+ * })
352
+ */
353
+ function outlet(name, data) {
354
+ return { inputRequired: {
355
+ outlet: name,
356
+ ...data
357
+ } };
358
+ }
359
+ /**
360
+ * Pause for HTTP form input. The outlet returns the payload (form definition)
361
+ * and state token in the HTTP response.
362
+ *
363
+ * @example
364
+ * return outletHttp(LoginForm)
365
+ * return outletHttp(LoginForm, { error: 'Invalid credentials' })
366
+ */
367
+ function outletHttp(payload, context) {
368
+ return outlet("http", {
369
+ payload,
370
+ context
371
+ });
372
+ }
373
+ /**
374
+ * Pause and send email with a magic link containing the state token.
375
+ *
376
+ * @example
377
+ * return outletEmail('user@test.com', 'invite', { name: 'Alice' })
378
+ */
379
+ function outletEmail(target, template, context) {
380
+ return outlet("email", {
381
+ target,
382
+ template,
383
+ context
384
+ });
385
+ }
386
+ /**
387
+ * Self-contained AES-256-GCM encrypted state strategy.
388
+ *
389
+ * Workflow state is encrypted into a base64url token that travels with the
390
+ * transport (cookie, URL param, hidden field). No server-side storage needed.
391
+ *
392
+ * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
393
+ *
394
+ * @example
395
+ * const strategy = new EncapsulatedStateStrategy({
396
+ * secret: crypto.randomBytes(32),
397
+ * defaultTtl: 3600_000, // 1 hour
398
+ * });
399
+ * const token = await strategy.persist(state);
400
+ * const recovered = await strategy.retrieve(token);
401
+ */
402
+ var EncapsulatedStateStrategy = class {
403
+ /** @throws if secret is not exactly 32 bytes */
404
+ constructor(config) {
405
+ this.config = config;
406
+ this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret;
407
+ if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes");
408
+ }
409
+ /**
410
+ * Encrypt workflow state into a self-contained token.
411
+ * @param state — workflow state to persist
412
+ * @param options.ttl — time-to-live in ms (overrides defaultTtl)
413
+ * @returns base64url-encoded encrypted token
414
+ */
415
+ async persist(state, options) {
416
+ const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
417
+ const exp = ttl > 0 ? Date.now() + ttl : 0;
418
+ const payload = JSON.stringify({
419
+ s: state,
420
+ e: exp
421
+ });
422
+ const iv = (0, node_crypto.randomBytes)(12);
423
+ const cipher = (0, node_crypto.createCipheriv)("aes-256-gcm", this.key, iv);
424
+ const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
425
+ const tag = cipher.getAuthTag();
426
+ return Buffer.concat([
427
+ iv,
428
+ tag,
429
+ encrypted
430
+ ]).toString("base64url");
431
+ }
432
+ /** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */
433
+ async retrieve(token) {
434
+ return this.decrypt(token);
435
+ }
436
+ /** Same as retrieve (stateless — cannot truly invalidate a token). */
437
+ async consume(token) {
438
+ return this.decrypt(token);
439
+ }
440
+ decrypt(token) {
441
+ try {
442
+ const buf = Buffer.from(token, "base64url");
443
+ if (buf.length < 28) return null;
444
+ const iv = buf.subarray(0, 12);
445
+ const tag = buf.subarray(12, 28);
446
+ const ciphertext = buf.subarray(28);
447
+ const decipher = (0, node_crypto.createDecipheriv)("aes-256-gcm", this.key, iv);
448
+ decipher.setAuthTag(tag);
449
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
450
+ const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8"));
451
+ if (exp > 0 && Date.now() > exp) return null;
452
+ return state;
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+ };
458
+ var HandleStateStrategy = class {
459
+ constructor(config) {
460
+ this.config = config;
461
+ }
462
+ async persist(state, options) {
463
+ const handle = (this.config.generateHandle ?? node_crypto.randomUUID)();
464
+ const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
465
+ const expiresAt = ttl > 0 ? Date.now() + ttl : void 0;
466
+ await this.config.store.set(handle, state, expiresAt);
467
+ return handle;
468
+ }
469
+ async retrieve(token) {
470
+ return (await this.config.store.get(token))?.state ?? null;
471
+ }
472
+ async consume(token) {
473
+ return (await this.config.store.getAndDelete(token))?.state ?? null;
474
+ }
475
+ };
476
+ /**
477
+ * In-memory state store for development and testing.
478
+ * State is lost on process restart.
479
+ */
480
+ var WfStateStoreMemory = class {
481
+ constructor() {
482
+ this.store = /* @__PURE__ */ new Map();
483
+ }
484
+ async set(handle, state, expiresAt) {
485
+ this.store.set(handle, {
486
+ state,
487
+ expiresAt
488
+ });
489
+ }
490
+ async get(handle) {
491
+ const entry = this.store.get(handle);
492
+ if (!entry) return null;
493
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
494
+ this.store.delete(handle);
495
+ return null;
496
+ }
497
+ return entry;
498
+ }
499
+ async delete(handle) {
500
+ this.store.delete(handle);
501
+ }
502
+ async getAndDelete(handle) {
503
+ const entry = await this.get(handle);
504
+ if (entry) this.store.delete(handle);
505
+ return entry;
506
+ }
507
+ async cleanup() {
508
+ const now = Date.now();
509
+ let count = 0;
510
+ for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) {
511
+ this.store.delete(handle);
512
+ count++;
513
+ }
514
+ return count;
515
+ }
516
+ };
517
+
78
518
  //#endregion
79
519
  //#region packages/event-wf/src/workflow.ts
80
520
  /** Workflow engine that resolves steps via Wooks router lookup. */
@@ -267,15 +707,25 @@ function createWfApp(opts, wooks$1) {
267
707
  }
268
708
 
269
709
  //#endregion
710
+ exports.EncapsulatedStateStrategy = EncapsulatedStateStrategy;
711
+ exports.HandleStateStrategy = HandleStateStrategy;
270
712
  Object.defineProperty(exports, 'StepRetriableError', {
271
713
  enumerable: true,
272
714
  get: function () {
273
715
  return __prostojs_wf.StepRetriableError;
274
716
  }
275
717
  });
718
+ exports.WfStateStoreMemory = WfStateStoreMemory;
276
719
  exports.WooksWf = WooksWf;
720
+ exports.createEmailOutlet = createEmailOutlet;
721
+ exports.createHttpOutlet = createHttpOutlet;
722
+ exports.createOutletHandler = createOutletHandler;
277
723
  exports.createWfApp = createWfApp;
278
724
  exports.createWfContext = createWfContext;
725
+ exports.handleWfOutletRequest = handleWfOutletRequest;
726
+ exports.outlet = outlet;
727
+ exports.outletEmail = outletEmail;
728
+ exports.outletHttp = outletHttp;
279
729
  exports.resumeKey = resumeKey;
280
730
  exports.resumeWfContext = resumeWfContext;
281
731
  Object.defineProperty(exports, 'useLogger', {
@@ -290,6 +740,8 @@ Object.defineProperty(exports, 'useRouteParams', {
290
740
  return __wooksjs_event_core.useRouteParams;
291
741
  }
292
742
  });
743
+ exports.useWfFinished = useWfFinished;
744
+ exports.useWfOutlet = useWfOutlet;
293
745
  exports.useWfState = useWfState;
294
746
  exports.wfKind = wfKind;
295
747
  exports.wfShortcuts = wfShortcuts;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  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
+ import * as _prostojs_wf_outlets from '@prostojs/wf/outlets';
5
+ import { WfStateStrategy, WfOutlet, WfOutletRequest } from '@prostojs/wf/outlets';
6
+ export { EncapsulatedStateStrategy, HandleStateStrategy, WfOutlet, WfOutletRequest, WfOutletResult, WfState, WfStateStore, WfStateStoreMemory, WfStateStrategy, outlet, outletEmail, outletHttp } from '@prostojs/wf/outlets';
7
+ import { TFlowOutput, Workflow, Step, TWorkflowSpy, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
8
+ export { StepRetriableError, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
4
9
  import * as wooks from 'wooks';
5
10
  import { Wooks, TWooksHandler, TWooksOptions, WooksAdapterBase } from 'wooks';
6
11
  import { TConsoleBase } from '@prostojs/logger';
7
- import { Workflow, Step, TWorkflowSpy, TStepHandler, TWorkflowSchema, TFlowOutput } from '@prostojs/wf';
8
- export { StepRetriableError, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
9
12
 
10
13
  /**
11
14
  * Composable that provides access to the current workflow execution state.
@@ -16,14 +19,14 @@ export { StepRetriableError, TStepHandler, TWorkflowSchema } from '@prostojs/wf'
16
19
  * const stepInput = input<MyInput>()
17
20
  * ```
18
21
  */
19
- declare function useWfState(): {
22
+ declare const useWfState: _wooksjs_event_core.WookComposable<{
20
23
  ctx: <T>() => T;
21
24
  input: <I>() => I | undefined;
22
25
  schemaId: string;
23
26
  stepId: () => string | null;
24
27
  indexes: () => number[] | undefined;
25
28
  resume: boolean;
26
- };
29
+ }>;
27
30
 
28
31
  declare const wfKind: _wooksjs_event_core.EventKind<{
29
32
  schemaId: _wooksjs_event_core.SlotMarker<string>;
@@ -39,6 +42,189 @@ declare function createWfContext<R>(options: EventContextOptions, seeds: EventKi
39
42
  /** Creates a WF event context for resuming a paused workflow and runs `fn` inside it. */
40
43
  declare function resumeWfContext<R>(options: EventContextOptions, seeds: EventKindSeeds<typeof wfKind>, fn: () => R): R;
41
44
 
45
+ /**
46
+ * Composable for accessing outlet infrastructure from within workflow steps.
47
+ *
48
+ * Most steps don't need this — they just return `outletHttp(form)` or
49
+ * `outletEmail(to, template)`. This composable is for advanced cases
50
+ * where steps need to inspect or modify outlet state directly.
51
+ */
52
+ declare const useWfOutlet: _wooksjs_event_core.WookComposable<{
53
+ getStateStrategy: () => _prostojs_wf_outlets.WfStateStrategy;
54
+ getOutlets: () => Map<string, _prostojs_wf_outlets.WfOutlet>;
55
+ getOutlet: (name: string) => _prostojs_wf_outlets.WfOutlet | null;
56
+ }>;
57
+
58
+ interface WfFinishedResponse {
59
+ type: 'redirect' | 'data';
60
+ /** Redirect URL or response body */
61
+ value: unknown;
62
+ /** HTTP status code (default 200 for data, 302 for redirect) */
63
+ status?: number;
64
+ /** Cookies to set */
65
+ cookies?: Record<string, {
66
+ value: string;
67
+ options?: Record<string, unknown>;
68
+ }>;
69
+ }
70
+
71
+ /**
72
+ * Composable to set the completion response for a finished workflow.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * // Redirect after login
77
+ * useWfFinished().set({ type: 'redirect', value: '/dashboard' })
78
+ *
79
+ * // Return data
80
+ * useWfFinished().set({ type: 'data', value: { success: true } })
81
+ * ```
82
+ */
83
+ declare const useWfFinished: _wooksjs_event_core.WookComposable<{
84
+ set: (response: WfFinishedResponse) => void;
85
+ get: () => WfFinishedResponse | undefined;
86
+ }>;
87
+
88
+ interface WfOutletTokenConfig {
89
+ /** Where to read state token from incoming request (default: `['body', 'query', 'cookie']`) */
90
+ read?: Array<'body' | 'query' | 'cookie'>;
91
+ /** Where to write state token in response (default: `'body'`) */
92
+ write?: 'body' | 'cookie';
93
+ /** Parameter name for state token (default: `'wfs'`) */
94
+ name?: string;
95
+ /**
96
+ * Token consumption mode per outlet. When `true`, the trigger calls
97
+ * `strategy.consume()` (single-use token) instead of `strategy.retrieve()`
98
+ * on resume. Defaults to `{ email: true }` — email magic links are consumed
99
+ * on first use, HTTP tokens are reusable.
100
+ *
101
+ * Can be a boolean (applies to all outlets) or a per-outlet-name map.
102
+ */
103
+ consume?: boolean | Record<string, boolean>;
104
+ }
105
+ interface WfOutletTriggerConfig {
106
+ /** Whitelist of allowed workflow IDs. If empty, all are allowed. */
107
+ allow?: string[];
108
+ /** Blacklist of workflow IDs. Checked after allow. */
109
+ block?: string[];
110
+ /** State persistence strategy */
111
+ state: WfStateStrategy | ((wfid: string) => WfStateStrategy);
112
+ /** Registered outlets */
113
+ outlets: WfOutlet[];
114
+ /** Token configuration (reading, writing, naming, consumption) */
115
+ token?: WfOutletTokenConfig;
116
+ /** Parameter name for workflow ID (default: `'wfid'`) */
117
+ wfidName?: string;
118
+ /**
119
+ * Initial workflow context factory. Called when starting a new workflow.
120
+ * Receives the parsed request body so you can seed context from the request.
121
+ * Default: `() => ({})` (empty context).
122
+ */
123
+ initialContext?: (body: Record<string, unknown> | undefined, wfid: string) => unknown;
124
+ /**
125
+ * Called when a workflow finishes. If provided, its return value becomes the
126
+ * HTTP response — overriding `useWfFinished()`. This keeps steps transport-agnostic
127
+ * when the completion response is always the same shape.
128
+ *
129
+ * If not provided, falls back to `useWfFinished()` or `{ finished: true }`.
130
+ */
131
+ onFinished?: (ctx: {
132
+ context: unknown;
133
+ schemaId: string;
134
+ }) => unknown;
135
+ }
136
+ interface WfOutletTriggerDeps {
137
+ /** Start a workflow. Provided by WooksWf or MoostWf. */
138
+ start: (schemaId: string, context: unknown, opts?: {
139
+ input?: unknown;
140
+ eventContext?: unknown;
141
+ }) => Promise<TFlowOutput<unknown, unknown, WfOutletRequest>>;
142
+ /** Resume a workflow. Provided by WooksWf or MoostWf. */
143
+ resume: (state: {
144
+ schemaId: string;
145
+ indexes: number[];
146
+ context: unknown;
147
+ }, opts?: {
148
+ input?: unknown;
149
+ eventContext?: unknown;
150
+ }) => Promise<TFlowOutput<unknown, unknown, WfOutletRequest>>;
151
+ }
152
+
153
+ /**
154
+ * Handle an HTTP request that starts or resumes a workflow.
155
+ *
156
+ * Reads wfs (state token) and wfid (workflow ID) from request body, query params,
157
+ * or cookies — configurable via `config.token`. On workflow pause, persists state
158
+ * and dispatches to the named outlet. On finish, returns the finished response.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * // In a wooks HTTP handler:
163
+ * app.post('/workflow', () => handleWfOutletRequest(config, deps))
164
+ *
165
+ * // Better — use createOutletHandler():
166
+ * const handle = createOutletHandler(wfApp)
167
+ * app.post('/workflow', () => handle(config))
168
+ * ```
169
+ */
170
+ declare function handleWfOutletRequest(config: WfOutletTriggerConfig, deps: WfOutletTriggerDeps): Promise<unknown>;
171
+
172
+ /**
173
+ * Creates an HTTP outlet that passes through the outlet payload as
174
+ * the HTTP response body. This is the most common outlet — it returns
175
+ * forms, prompts, or data to the client.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const httpOutlet = createHttpOutlet()
180
+ * // Step does: return outletHttp({ fields: ['email', 'password'] })
181
+ * // Client receives: { fields: ['email', 'password'] }
182
+ * ```
183
+ */
184
+ declare function createHttpOutlet(opts?: {
185
+ /** Transform the payload before returning to client */
186
+ transform?: (payload: unknown, context?: Record<string, unknown>) => unknown;
187
+ }): WfOutlet;
188
+ /**
189
+ * Creates an email outlet that delegates to a user-provided send function.
190
+ * The send function receives the target, template, context, and the state
191
+ * token (for embedding in magic links / verification URLs).
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const emailOutlet = createEmailOutlet(async (opts) => {
196
+ * await mailer.send({
197
+ * to: opts.target,
198
+ * template: opts.template,
199
+ * data: { ...opts.context, verifyUrl: `/verify?wfs=${opts.token}` },
200
+ * })
201
+ * })
202
+ * ```
203
+ */
204
+ declare function createEmailOutlet(send: (opts: {
205
+ target: string;
206
+ template: string;
207
+ context: Record<string, unknown>;
208
+ token: string;
209
+ }) => Promise<void>): WfOutlet;
210
+
211
+ /**
212
+ * Creates a pre-wired outlet handler from a workflow app instance.
213
+ * Eliminates the need to manually construct `WfOutletTriggerDeps`.
214
+ *
215
+ * Accepts any object with `start` and `resume` methods (WooksWf, MoostWf, etc.).
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * const handle = createOutletHandler(wfApp)
220
+ * httpApp.post('/workflow', () => handle(config))
221
+ * ```
222
+ */
223
+ declare function createOutletHandler(wfApp: {
224
+ start: WfOutletTriggerDeps['start'];
225
+ resume: WfOutletTriggerDeps['resume'];
226
+ }): (config: WfOutletTriggerConfig) => Promise<unknown>;
227
+
42
228
  /** Input data for creating a workflow event context. */
43
229
  interface TWFEventInput {
44
230
  schemaId: string;
@@ -153,5 +339,5 @@ declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
153
339
  */
154
340
  declare function createWfApp<T>(opts?: TWooksWfOptions, wooks?: Wooks | WooksAdapterBase): WooksWf<T, any>;
155
341
 
156
- export { WooksWf, createWfApp, createWfContext, resumeKey, resumeWfContext, useWfState, wfKind, wfShortcuts };
157
- export type { TWFEventInput, TWfRunOptions, TWooksWfOptions };
342
+ export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
343
+ export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps };
package/dist/index.mjs CHANGED
@@ -1,4 +1,7 @@
1
- import { createEventContext, current, defineEventKind, key, slot, useLogger, useRouteParams } from "@wooksjs/event-core";
1
+ import { createEventContext, current, defineEventKind, defineWook, key, slot, useLogger, useRouteParams } from "@wooksjs/event-core";
2
+ import { useCookies, useResponse, useUrlParams } from "@wooksjs/event-http";
3
+ import { useBody } from "@wooksjs/http-body";
4
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
2
5
  import { StepRetriableError, Workflow, createStep } from "@prostojs/wf";
3
6
  import { WooksAdapterBase } from "wooks";
4
7
 
@@ -23,17 +26,14 @@ const resumeKey = key("wf.resume");
23
26
  * const stepInput = input<MyInput>()
24
27
  * ```
25
28
  */
26
- function useWfState() {
27
- const c = current();
28
- return {
29
- ctx: () => c.get(wfKind.keys.inputContext),
30
- input: () => c.get(wfKind.keys.input),
31
- schemaId: c.get(wfKind.keys.schemaId),
32
- stepId: () => c.get(wfKind.keys.stepId),
33
- indexes: () => c.get(wfKind.keys.indexes),
34
- resume: c.get(resumeKey)
35
- };
36
- }
29
+ const useWfState = defineWook((c) => ({
30
+ ctx: () => c.get(wfKind.keys.inputContext),
31
+ input: () => c.get(wfKind.keys.input),
32
+ schemaId: c.get(wfKind.keys.schemaId),
33
+ stepId: () => c.get(wfKind.keys.stepId),
34
+ indexes: () => c.get(wfKind.keys.indexes),
35
+ resume: c.get(resumeKey)
36
+ }));
37
37
 
38
38
  //#endregion
39
39
  //#region packages/event-wf/src/event-wf.ts
@@ -52,6 +52,446 @@ function resumeWfContext(options, seeds, fn) {
52
52
  });
53
53
  }
54
54
 
55
+ //#endregion
56
+ //#region packages/event-wf/src/outlets/outlet-context.ts
57
+ /** Registered outlet handlers, keyed by name */
58
+ const outletsRegistryKey = key("wf.outlets.registry");
59
+ /** Active state strategy for current request */
60
+ const stateStrategyKey = key("wf.outlets.stateStrategy");
61
+ /** Finished response set by workflow steps */
62
+ const wfFinishedKey = key("wf.outlets.finished");
63
+
64
+ //#endregion
65
+ //#region packages/event-wf/src/outlets/use-wf-outlet.ts
66
+ /**
67
+ * Composable for accessing outlet infrastructure from within workflow steps.
68
+ *
69
+ * Most steps don't need this — they just return `outletHttp(form)` or
70
+ * `outletEmail(to, template)`. This composable is for advanced cases
71
+ * where steps need to inspect or modify outlet state directly.
72
+ */
73
+ const useWfOutlet = defineWook((ctx) => ({
74
+ getStateStrategy: () => ctx.get(stateStrategyKey),
75
+ getOutlets: () => ctx.get(outletsRegistryKey),
76
+ getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
77
+ }));
78
+
79
+ //#endregion
80
+ //#region packages/event-wf/src/outlets/use-wf-finished.ts
81
+ /**
82
+ * Composable to set the completion response for a finished workflow.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * // Redirect after login
87
+ * useWfFinished().set({ type: 'redirect', value: '/dashboard' })
88
+ *
89
+ * // Return data
90
+ * useWfFinished().set({ type: 'data', value: { success: true } })
91
+ * ```
92
+ */
93
+ const useWfFinished = defineWook((ctx) => ({
94
+ set: (response) => ctx.set(wfFinishedKey, response),
95
+ get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
96
+ }));
97
+
98
+ //#endregion
99
+ //#region packages/event-wf/src/outlets/trigger.ts
100
+ const DEFAULT_CONSUME_TOKEN = { email: true };
101
+ /**
102
+ * Handle an HTTP request that starts or resumes a workflow.
103
+ *
104
+ * Reads wfs (state token) and wfid (workflow ID) from request body, query params,
105
+ * or cookies — configurable via `config.token`. On workflow pause, persists state
106
+ * and dispatches to the named outlet. On finish, returns the finished response.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * // In a wooks HTTP handler:
111
+ * app.post('/workflow', () => handleWfOutletRequest(config, deps))
112
+ *
113
+ * // Better — use createOutletHandler():
114
+ * const handle = createOutletHandler(wfApp)
115
+ * app.post('/workflow', () => handle(config))
116
+ * ```
117
+ */
118
+ async function handleWfOutletRequest(config, deps) {
119
+ const tok = config.token ?? {};
120
+ const tokenName = tok.name ?? "wfs";
121
+ const tokenRead = tok.read ?? [
122
+ "body",
123
+ "query",
124
+ "cookie"
125
+ ];
126
+ const tokenWrite = tok.write ?? "body";
127
+ const wfidName = config.wfidName ?? "wfid";
128
+ const ctx = current();
129
+ const registry = new Map(config.outlets.map((o) => [o.name, o]));
130
+ ctx.set(outletsRegistryKey, registry);
131
+ ctx.set(wfFinishedKey, void 0);
132
+ const { parseBody } = useBody();
133
+ const { params } = useUrlParams();
134
+ const { getCookie } = useCookies();
135
+ const response = useResponse();
136
+ const body = await parseBody().catch(() => void 0);
137
+ const queryParams = params();
138
+ let token;
139
+ for (const source of tokenRead) {
140
+ if (source === "body") token = body?.[tokenName];
141
+ else if (source === "query") token = queryParams.get(tokenName) ?? void 0;
142
+ else if (source === "cookie") token = getCookie(tokenName) ?? void 0;
143
+ if (token) break;
144
+ }
145
+ const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
146
+ const input = body?.input;
147
+ const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
148
+ const shouldConsume = (outletName) => {
149
+ if (typeof tok.consume === "boolean") return tok.consume;
150
+ return (tok.consume ?? DEFAULT_CONSUME_TOKEN)[outletName] ?? false;
151
+ };
152
+ let output;
153
+ if (token) {
154
+ const strategy = resolveStrategy(wfid ?? "");
155
+ ctx.set(stateStrategyKey, strategy);
156
+ const state = await strategy.retrieve(token);
157
+ if (!state) return {
158
+ error: "Invalid or expired workflow state",
159
+ status: 400
160
+ };
161
+ if (state.schemaId !== (wfid ?? "")) {
162
+ const realStrategy = resolveStrategy(state.schemaId);
163
+ ctx.set(stateStrategyKey, realStrategy);
164
+ }
165
+ const outletName = state.meta?.outlet;
166
+ if (outletName && shouldConsume(outletName)) await strategy.consume(token);
167
+ output = await deps.resume(state, {
168
+ input,
169
+ eventContext: ctx
170
+ });
171
+ } else if (wfid) {
172
+ if (config.allow?.length && !config.allow.includes(wfid)) return {
173
+ error: `Workflow '${wfid}' is not allowed`,
174
+ status: 403
175
+ };
176
+ if (config.block?.includes(wfid)) return {
177
+ error: `Workflow '${wfid}' is blocked`,
178
+ status: 403
179
+ };
180
+ const strategy = resolveStrategy(wfid);
181
+ ctx.set(stateStrategyKey, strategy);
182
+ const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
183
+ output = await deps.start(wfid, initialContext, {
184
+ input,
185
+ eventContext: ctx
186
+ });
187
+ } else return {
188
+ error: "Missing wfs (state token) or wfid (workflow ID)",
189
+ status: 400
190
+ };
191
+ if (output.finished) {
192
+ if (config.onFinished) return config.onFinished({
193
+ context: output.state.context,
194
+ schemaId: output.state.schemaId
195
+ });
196
+ const finished = ctx.get(wfFinishedKey);
197
+ if (finished?.cookies) for (const [name, cookie] of Object.entries(finished.cookies)) response.setCookie(name, cookie.value, cookie.options);
198
+ if (finished?.type === "redirect") {
199
+ response.setHeader("location", finished.value);
200
+ return { status: finished.status ?? 302 };
201
+ }
202
+ if (finished) return finished.value;
203
+ return { finished: true };
204
+ }
205
+ if (output.inputRequired) {
206
+ const outletReq = output.inputRequired;
207
+ const outletHandler = registry.get(outletReq.outlet);
208
+ if (!outletHandler) return {
209
+ error: `Unknown outlet: '${outletReq.outlet}'`,
210
+ status: 500
211
+ };
212
+ const strategy = ctx.get(stateStrategyKey);
213
+ const stateWithMeta = {
214
+ ...output.state,
215
+ meta: { outlet: outletReq.outlet }
216
+ };
217
+ const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0);
218
+ if (tokenWrite === "cookie") response.setCookie(tokenName, newToken, {
219
+ httpOnly: true,
220
+ sameSite: "Strict",
221
+ path: "/"
222
+ });
223
+ const result = await outletHandler.deliver(outletReq, newToken);
224
+ if (tokenWrite === "body" && result?.response && typeof result.response === "object") return {
225
+ ...result.response,
226
+ [tokenName]: newToken
227
+ };
228
+ return result?.response ?? { waiting: true };
229
+ }
230
+ if (output.error) return {
231
+ error: output.error.message,
232
+ errorList: output.errorList
233
+ };
234
+ return { error: "Unexpected workflow state" };
235
+ }
236
+
237
+ //#endregion
238
+ //#region packages/event-wf/src/outlets/create-outlet.ts
239
+ /**
240
+ * Creates an HTTP outlet that passes through the outlet payload as
241
+ * the HTTP response body. This is the most common outlet — it returns
242
+ * forms, prompts, or data to the client.
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * const httpOutlet = createHttpOutlet()
247
+ * // Step does: return outletHttp({ fields: ['email', 'password'] })
248
+ * // Client receives: { fields: ['email', 'password'] }
249
+ * ```
250
+ */
251
+ function createHttpOutlet(opts) {
252
+ return {
253
+ name: "http",
254
+ async deliver(request, _token) {
255
+ const body = opts?.transform ? opts.transform(request.payload, request.context) : typeof request.payload === "object" && request.payload !== null ? {
256
+ ...request.payload,
257
+ ...request.context
258
+ } : request.payload;
259
+ return { response: body };
260
+ }
261
+ };
262
+ }
263
+ /**
264
+ * Creates an email outlet that delegates to a user-provided send function.
265
+ * The send function receives the target, template, context, and the state
266
+ * token (for embedding in magic links / verification URLs).
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const emailOutlet = createEmailOutlet(async (opts) => {
271
+ * await mailer.send({
272
+ * to: opts.target,
273
+ * template: opts.template,
274
+ * data: { ...opts.context, verifyUrl: `/verify?wfs=${opts.token}` },
275
+ * })
276
+ * })
277
+ * ```
278
+ */
279
+ function createEmailOutlet(send) {
280
+ return {
281
+ name: "email",
282
+ async deliver(request, token) {
283
+ await send({
284
+ target: request.target ?? "",
285
+ template: request.template ?? "",
286
+ context: request.context ?? {},
287
+ token
288
+ });
289
+ return { response: {
290
+ sent: true,
291
+ outlet: "email"
292
+ } };
293
+ }
294
+ };
295
+ }
296
+
297
+ //#endregion
298
+ //#region packages/event-wf/src/outlets/create-handler.ts
299
+ /**
300
+ * Creates a pre-wired outlet handler from a workflow app instance.
301
+ * Eliminates the need to manually construct `WfOutletTriggerDeps`.
302
+ *
303
+ * Accepts any object with `start` and `resume` methods (WooksWf, MoostWf, etc.).
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * const handle = createOutletHandler(wfApp)
308
+ * httpApp.post('/workflow', () => handle(config))
309
+ * ```
310
+ */
311
+ function createOutletHandler(wfApp) {
312
+ return (config) => handleWfOutletRequest(config, {
313
+ start: (schemaId, context, opts) => wfApp.start(schemaId, context, opts),
314
+ resume: (state, opts) => wfApp.resume(state, opts)
315
+ });
316
+ }
317
+
318
+ //#endregion
319
+ //#region node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
320
+ /**
321
+ * Generic outlet request. Use for custom outlets.
322
+ *
323
+ * @example
324
+ * return outlet('pending-task', {
325
+ * payload: ApprovalForm,
326
+ * target: managerId,
327
+ * context: { orderId, amount },
328
+ * })
329
+ */
330
+ function outlet(name, data) {
331
+ return { inputRequired: {
332
+ outlet: name,
333
+ ...data
334
+ } };
335
+ }
336
+ /**
337
+ * Pause for HTTP form input. The outlet returns the payload (form definition)
338
+ * and state token in the HTTP response.
339
+ *
340
+ * @example
341
+ * return outletHttp(LoginForm)
342
+ * return outletHttp(LoginForm, { error: 'Invalid credentials' })
343
+ */
344
+ function outletHttp(payload, context) {
345
+ return outlet("http", {
346
+ payload,
347
+ context
348
+ });
349
+ }
350
+ /**
351
+ * Pause and send email with a magic link containing the state token.
352
+ *
353
+ * @example
354
+ * return outletEmail('user@test.com', 'invite', { name: 'Alice' })
355
+ */
356
+ function outletEmail(target, template, context) {
357
+ return outlet("email", {
358
+ target,
359
+ template,
360
+ context
361
+ });
362
+ }
363
+ /**
364
+ * Self-contained AES-256-GCM encrypted state strategy.
365
+ *
366
+ * Workflow state is encrypted into a base64url token that travels with the
367
+ * transport (cookie, URL param, hidden field). No server-side storage needed.
368
+ *
369
+ * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
370
+ *
371
+ * @example
372
+ * const strategy = new EncapsulatedStateStrategy({
373
+ * secret: crypto.randomBytes(32),
374
+ * defaultTtl: 3600_000, // 1 hour
375
+ * });
376
+ * const token = await strategy.persist(state);
377
+ * const recovered = await strategy.retrieve(token);
378
+ */
379
+ var EncapsulatedStateStrategy = class {
380
+ /** @throws if secret is not exactly 32 bytes */
381
+ constructor(config) {
382
+ this.config = config;
383
+ this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret;
384
+ if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes");
385
+ }
386
+ /**
387
+ * Encrypt workflow state into a self-contained token.
388
+ * @param state — workflow state to persist
389
+ * @param options.ttl — time-to-live in ms (overrides defaultTtl)
390
+ * @returns base64url-encoded encrypted token
391
+ */
392
+ async persist(state, options) {
393
+ const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
394
+ const exp = ttl > 0 ? Date.now() + ttl : 0;
395
+ const payload = JSON.stringify({
396
+ s: state,
397
+ e: exp
398
+ });
399
+ const iv = randomBytes(12);
400
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
401
+ const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
402
+ const tag = cipher.getAuthTag();
403
+ return Buffer.concat([
404
+ iv,
405
+ tag,
406
+ encrypted
407
+ ]).toString("base64url");
408
+ }
409
+ /** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */
410
+ async retrieve(token) {
411
+ return this.decrypt(token);
412
+ }
413
+ /** Same as retrieve (stateless — cannot truly invalidate a token). */
414
+ async consume(token) {
415
+ return this.decrypt(token);
416
+ }
417
+ decrypt(token) {
418
+ try {
419
+ const buf = Buffer.from(token, "base64url");
420
+ if (buf.length < 28) return null;
421
+ const iv = buf.subarray(0, 12);
422
+ const tag = buf.subarray(12, 28);
423
+ const ciphertext = buf.subarray(28);
424
+ const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
425
+ decipher.setAuthTag(tag);
426
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
427
+ const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8"));
428
+ if (exp > 0 && Date.now() > exp) return null;
429
+ return state;
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+ };
435
+ var HandleStateStrategy = class {
436
+ constructor(config) {
437
+ this.config = config;
438
+ }
439
+ async persist(state, options) {
440
+ const handle = (this.config.generateHandle ?? randomUUID)();
441
+ const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
442
+ const expiresAt = ttl > 0 ? Date.now() + ttl : void 0;
443
+ await this.config.store.set(handle, state, expiresAt);
444
+ return handle;
445
+ }
446
+ async retrieve(token) {
447
+ return (await this.config.store.get(token))?.state ?? null;
448
+ }
449
+ async consume(token) {
450
+ return (await this.config.store.getAndDelete(token))?.state ?? null;
451
+ }
452
+ };
453
+ /**
454
+ * In-memory state store for development and testing.
455
+ * State is lost on process restart.
456
+ */
457
+ var WfStateStoreMemory = class {
458
+ constructor() {
459
+ this.store = /* @__PURE__ */ new Map();
460
+ }
461
+ async set(handle, state, expiresAt) {
462
+ this.store.set(handle, {
463
+ state,
464
+ expiresAt
465
+ });
466
+ }
467
+ async get(handle) {
468
+ const entry = this.store.get(handle);
469
+ if (!entry) return null;
470
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
471
+ this.store.delete(handle);
472
+ return null;
473
+ }
474
+ return entry;
475
+ }
476
+ async delete(handle) {
477
+ this.store.delete(handle);
478
+ }
479
+ async getAndDelete(handle) {
480
+ const entry = await this.get(handle);
481
+ if (entry) this.store.delete(handle);
482
+ return entry;
483
+ }
484
+ async cleanup() {
485
+ const now = Date.now();
486
+ let count = 0;
487
+ for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) {
488
+ this.store.delete(handle);
489
+ count++;
490
+ }
491
+ return count;
492
+ }
493
+ };
494
+
55
495
  //#endregion
56
496
  //#region packages/event-wf/src/workflow.ts
57
497
  /** Workflow engine that resolves steps via Wooks router lookup. */
@@ -244,4 +684,4 @@ function createWfApp(opts, wooks) {
244
684
  }
245
685
 
246
686
  //#endregion
247
- export { StepRetriableError, WooksWf, createWfApp, createWfContext, resumeKey, resumeWfContext, useLogger, useRouteParams, useWfState, wfKind, wfShortcuts };
687
+ export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-wf",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "@wooksjs/event-wf",
5
5
  "keywords": [
6
6
  "app",
@@ -42,18 +42,30 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
- "@prostojs/wf": "^0.0.18"
45
+ "@prostojs/wf": "^0.1.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "typescript": "^5.9.3",
49
49
  "vitest": "^3.2.4",
50
- "@wooksjs/event-core": "^0.7.7",
51
- "wooks": "^0.7.7"
50
+ "@wooksjs/event-core": "^0.7.8",
51
+ "@wooksjs/event-http": "^0.7.8",
52
+ "wooks": "^0.7.8",
53
+ "@wooksjs/http-body": "^0.7.8"
52
54
  },
53
55
  "peerDependencies": {
54
56
  "@prostojs/logger": "^0.4.3",
55
- "@wooksjs/event-core": "^0.7.7",
56
- "wooks": "^0.7.7"
57
+ "@wooksjs/event-core": "^0.7.8",
58
+ "@wooksjs/event-http": "^0.7.8",
59
+ "wooks": "^0.7.8",
60
+ "@wooksjs/http-body": "^0.7.8"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@wooksjs/event-http": {
64
+ "optional": true
65
+ },
66
+ "@wooksjs/http-body": {
67
+ "optional": true
68
+ }
57
69
  },
58
70
  "scripts": {
59
71
  "build": "rolldown -c ../../rolldown.config.mjs",