@wooksjs/event-wf 0.7.7 → 0.7.9

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;