Outlets
Outlets let workflows pause and deliver a request to the outside world — render an HTTP form, send an email with a magic link, or dispatch to any custom delivery channel. When the user responds (submits the form, clicks the link), the workflow resumes automatically.
The outlet system handles state persistence, token generation, token consumption (single-use for email, reusable for HTTP), and HTTP response building — so your step handlers stay declarative.
Overview
The flow looks like this:
- A step returns
outletHttp(form)oroutletEmail(to, template)— the workflow pauses - The trigger function persists the state, generates a token, and dispatches to the outlet handler
- The outlet handler delivers the response (HTTP body, email with magic link, etc.)
- The client/user responds with the token + input
- The trigger reads the token, restores state, and resumes the workflow
All of this is handled by handleWfOutletRequest() — a single function you wire to an HTTP endpoint.
Quick Start
import { createHttpApp } from '@wooksjs/event-http'
import {
createWfApp,
useWfState,
createHttpOutlet,
createOutletHandler,
outletHttp,
HandleStateStrategy,
WfStateStoreMemory,
} from '@wooksjs/event-wf'
// 1. Create workflow app
const wf = createWfApp<{ email?: string; verified?: boolean }>()
// 2. Define steps
wf.step('ask-email', {
handler: () => {
const { input } = useWfState()
if (input()) return // already provided on resume
return outletHttp({ fields: ['email'] }) // pause and ask client
},
})
wf.step('save', {
handler: () => {
const { ctx, input } = useWfState()
const data = input<{ email: string }>()
if (data) ctx<{ email?: string }>().email = data.email
},
})
wf.flow('signup', ['ask-email', 'save'])
// 3. Wire outlet handler to HTTP
const http = createHttpApp()
const handle = createOutletHandler(wf)
http.post('/signup', () =>
handle({
state: new HandleStateStrategy({ store: new WfStateStoreMemory() }),
outlets: [createHttpOutlet()],
})
)
http.listen(3000)Client flow:
POST /signup { wfid: "signup" }
← { fields: ["email"], wfs: "abc123" }
POST /signup { wfs: "abc123", input: { email: "user@example.com" } }
← { finished: true }Step Helpers
These helpers are re-exported from @prostojs/wf/outlets for convenience:
outletHttp(payload, context?)
Pause the workflow and return a form/prompt to the HTTP client:
wf.step('login', {
handler: () => {
const { input } = useWfState()
if (input()) return
return outletHttp(
{ fields: ['username', 'password'] },
{ error: 'Invalid credentials' }, // optional context
)
},
})outletEmail(target, template, context?)
Pause and send an email (e.g. verification link, approval request):
wf.step('verify-email', {
handler: () => {
const { input } = useWfState()
if (input()) return
return outletEmail('user@example.com', 'verify-template', {
name: 'Alice',
})
},
})outlet(name, data?)
Generic outlet for custom delivery channels:
return outlet('sms', { payload: { phone: '+1234567890' } })Outlet Handlers
Outlet handlers implement the WfOutlet interface — they receive the pause request and a state token, and return the response.
createHttpOutlet(opts?)
Built-in factory for HTTP outlets. Passes the step's payload through as the response body:
import { createHttpOutlet } from '@wooksjs/event-wf'
const httpOutlet = createHttpOutlet()
// With custom transform:
const httpOutlet = createHttpOutlet({
transform: (payload, context) => ({
type: 'form',
...payload,
...context,
}),
})createEmailOutlet(sendFn)
Built-in factory for email outlets. Delegates to your email-sending function:
import { createEmailOutlet } from '@wooksjs/event-wf'
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({
to: target,
template,
data: {
...context,
verifyUrl: `https://example.com/signup?wfs=${token}`,
},
})
})Custom Outlets
Implement the WfOutlet interface directly:
import type { WfOutlet } from '@wooksjs/event-wf'
const smsOutlet: WfOutlet = {
name: 'sms',
async deliver(request, token) {
await smsService.send(request.target!, `Your code: ${token}`)
return { response: { sent: true } }
},
}Configuration
The handleWfOutletRequest function (or the handler returned by createOutletHandler) accepts a WfOutletTriggerConfig:
interface WfOutletTriggerConfig {
state: WfStateStrategy | ((wfid: string) => WfStateStrategy)
outlets: WfOutlet[]
token?: {
name?: string // default: 'wfs'
read?: Array<'body' | 'query' | 'cookie'> // default: ['body', 'query', 'cookie']
write?: 'body' | 'cookie' // default: 'body'
consume?: boolean | Record<string, boolean> // default: { email: true }
}
wfidName?: string // default: 'wfid'
allow?: string[]
block?: string[]
initialContext?: (body, wfid) => unknown
onFinished?: (ctx: { context, schemaId }) => unknown
}State Strategies
State strategies control how workflow state is persisted between pause and resume.
HandleStateStrategy — server-side storage with a short handle/UUID as token:
import { HandleStateStrategy, WfStateStoreMemory } from '@wooksjs/event-wf'
const strategy = new HandleStateStrategy({
store: new WfStateStoreMemory(), // in-memory (dev/test only)
defaultTtl: 60_000, // 1 minute expiry
})For production, implement the WfStateStore interface backed by Redis, a database, etc.
EncapsulatedStateStrategy — stateless, encrypted token (no server storage needed):
import { EncapsulatedStateStrategy } from '@wooksjs/event-wf'
const strategy = new EncapsulatedStateStrategy({
secret: crypto.randomBytes(32), // 32-byte AES-256 key
defaultTtl: 300_000, // 5 minutes
})The entire workflow state is encrypted into the token itself using AES-256-GCM.
Token Configuration
token.read — where to look for the state token in incoming requests. Checked in order:
{ token: { read: ['body', 'query', 'cookie'] } } // default
{ token: { read: ['cookie'] } } // cookie-onlytoken.write — how to return the token to the client:
'body'(default) — merges the token into the JSON response body'cookie'— sets an httpOnly cookie
token.consume — controls single-use vs reusable tokens per outlet:
// Default: email tokens are consumed, HTTP tokens are reusable
{ token: { consume: { email: true } } }
// All tokens are single-use:
{ token: { consume: true } }
// All tokens are reusable:
{ token: { consume: false } }When a token is consumed, it is invalidated after the first resume — preventing replay attacks on email magic links.
Access Control
{
allow: ['signup', 'reset-password'], // only these workflows can be started
block: ['admin-setup'], // these are always blocked
}Initial Context
Seed the workflow context from the request body when starting:
{
initialContext: (body, wfid) => ({
source: 'web',
locale: body?.locale ?? 'en',
}),
}Completion Handler
Control the HTTP response when a workflow finishes, without coupling steps to HTTP:
{
onFinished: ({ context, schemaId }) => ({
success: true,
result: context,
}),
}If not provided, the trigger checks useWfFinished() (set from within steps) and falls back to { finished: true }.
Composables
useWfFinished()
Set the HTTP response for when the workflow completes. Call from the last step:
import { useWfFinished } from '@wooksjs/event-wf'
wf.step('complete', {
handler: () => {
useWfFinished().set({ type: 'redirect', value: '/dashboard' })
// or
useWfFinished().set({ type: 'data', value: { success: true }, status: 200 })
},
})This is an alternative to onFinished in the config — use it when different steps need different completion responses.
useWfOutlet()
Advanced composable for inspecting outlet infrastructure from within steps:
import { useWfOutlet } from '@wooksjs/event-wf'
wf.step('custom-step', {
handler: () => {
const { getStateStrategy, getOutlet } = useWfOutlet()
const httpOutlet = getOutlet('http')
// ...
},
})Full Example: Signup with Email Verification
import { createHttpApp } from '@wooksjs/event-http'
import {
createWfApp,
useWfState,
useWfFinished,
createHttpOutlet,
createEmailOutlet,
createOutletHandler,
outletHttp,
outletEmail,
HandleStateStrategy,
WfStateStoreMemory,
} from '@wooksjs/event-wf'
interface SignupContext {
email?: string
verified?: boolean
}
const wf = createWfApp<SignupContext>()
wf.step('collect-email', {
handler: () => {
const { input } = useWfState()
if (input()) return
return outletHttp({ fields: ['email'], title: 'Enter your email' })
},
})
wf.step('send-verification', {
handler: () => {
const { ctx, input } = useWfState()
const data = input<{ email: string }>()
if (data) {
ctx<SignupContext>().email = data.email
return // resume after email link clicked
}
return outletEmail(ctx<SignupContext>().email!, 'verify-email')
},
})
wf.step('complete', {
handler: () => {
const { ctx } = useWfState()
ctx<SignupContext>().verified = true
useWfFinished().set({ type: 'redirect', value: '/welcome' })
},
})
wf.flow('signup', ['collect-email', 'send-verification', 'complete'])
const http = createHttpApp()
const store = new WfStateStoreMemory()
const handle = createOutletHandler(wf)
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
console.log(`Send ${template} to ${target} with link: /signup?wfs=${token}`)
})
http.post('/signup', () =>
handle({
state: new HandleStateStrategy({ store }),
outlets: [createHttpOutlet(), emailOutlet],
})
)
// Also handle GET for email link clicks
http.get('/signup', () =>
handle({
state: new HandleStateStrategy({ store }),
outlets: [createHttpOutlet(), emailOutlet],
token: { read: ['query'] },
})
)
http.listen(3000)Flow:
POST /signup { wfid: "signup" }→ returns form fieldsPOST /signup { wfs: "token1", input: { email: "user@test.com" } }→ sends verification email- User clicks
GET /signup?wfs=token2→ workflow completes, redirect to/welcome