Skip to content

Steps

A step is a named, reusable unit of work. You define steps once and reference them by id inside flows.

Defining a Step

ts
app.step('step-id', {
  handler: (ctx) => {
    // your logic here
  },
})

The handler receives the workflow context as its first argument. You can mutate it directly:

ts
app.step('increment', {
  handler: (ctx) => {
    ctx.counter++
  },
})

Step IDs Must Be Unique

Steps are registered on a router, and the first registration of an id wins — registering the same id again is ignored and the later handler is never reached. A duplicate logs a warning so the mistake is visible instead of silent:

WF step "increment" registered more than once — the first registration wins.

By default the router is a process-global singleton, shared across every workflow app created without an explicit Wooks instance. So ids can collide across apps, not just within one — this most often bites tests that build a fresh app per case and reuse an id.

Fail loudly with strictStepIds

Pass strictStepIds: true to turn a duplicate id into a thrown error instead of a warning — handy in CI where a collision should fail the build:

ts
const app = createWfApp({ strictStepIds: true })

app.step('process', { handler: () => {} })
app.step('process', { handler: () => {} })
// throws: WF step "process" already registered. Step ids must be unique (strictStepIds enabled).

Resetting the router between tests

Because the router is shared, reset it before each test so ids need not be globally unique and each test stays hermetic:

ts
import { clearGlobalWooks } from 'wooks'

beforeEach(() => clearGlobalWooks())

After the reset every test gets a fresh router, so re-registering the same ids across cases is fine. The same clearGlobalWooks() call is what HMR / dev-restart flows use to re-register steps cleanly.

Accessing State with useWfState

Inside any step handler, call useWfState() to access the full workflow execution state:

ts
import { useWfState } from '@wooksjs/event-wf'

app.step('process', {
  handler: () => {
    const { ctx, input, schemaId, stepId, indexes, resume } = useWfState()

    const context = ctx<MyContext>()   // typed workflow context
    const stepInput = input<string>()  // input provided for this step (if any)
    const flowId = schemaId            // id of the running flow
    const currentStep = stepId()       // normalized step id, e.g. '/add/5' (null before the first step)
    const position = indexes()         // position in the flow schema
    const isResumed = resume           // true if this is a resumed execution
  },
})

useWfState() works from anywhere in the call stack — it uses AsyncLocalStorage under the hood, so you can call it from utility functions, not just directly in the handler.

Parametric Steps

Step ids support route-style parameters. This lets you create generic steps that receive values through their id.

ts
import { useRouteParams } from '@wooksjs/event-core'

app.step('add/:n', {
  handler: (ctx) => {
    const n = Number(useRouteParams().get('n'))
    ctx.result += n
  },
})

Now you can call this step with different values in your flow:

ts
app.flow('calculate', ['add/5', 'add/10', 'add/3'])

Supported Routing Patterns

Step ids use @prostojs/router syntax:

PatternExampleMatches
StaticvalidateExactly validate
Named parameteradd/:nadd/5, add/100
Multiple parametersmove/:from/:tomove/inbox/archive
Optional parameterlog/:level?log and log/debug
Wildcardnotify/*notify/email, notify/slack/general

Access parameters with useRouteParams().get('paramName') — use get('*') for wildcard captures.

String Handlers

For lightweight, serializable steps, you can use a JavaScript expression string instead of a function:

ts
app.step('add', {
  input: 'number',
  handler: 'ctx.result += input',
})

app.step('double', {
  handler: 'ctx.result *= 2',
})

String handlers run in a restricted sandbox with only ctx (the workflow context), input (the step input), and StepRetriableError available. They cannot access Node.js APIs, imports, or composables.

A string handler signals a retriable failure by returning (not throwing) a StepRetriableError:

ts
app.step('check-balance', {
  handler: 'ctx.balance < 0 ? new StepRetriableError(new Error("negative balance")) : undefined',
})

Use string handlers when you need steps to be serializable (e.g., stored in a database or sent over the wire). Use function handlers for everything else.

Step with Required Input

A step can declare that it requires input. If the input is not provided when the step runs, the workflow pauses and waits for it.

ts
app.step('get-approval', {
  input: 'boolean',  // declares that this step needs input
  handler: (ctx) => {
    const { input } = useWfState()
    ctx.approved = input<boolean>() ?? false
  },
})

See Input & Resume for the full pause/resume pattern.

Handling Retriable Errors

If a step fails but can be retried, throw a StepRetriableError:

ts
import { StepRetriableError } from '@wooksjs/event-wf'

app.step('call-api', {
  handler: async (ctx) => {
    const res = await fetch(ctx.apiUrl)
    if (!res.ok) {
      throw new StepRetriableError(new Error(`API returned ${res.status}`))
    }
    ctx.data = await res.json()
  },
})

The workflow pauses with the error available on the output. You can retry by resuming:

ts
const output = await app.start('my-flow', initialContext)

if (!output.finished && output.error) {
  console.log(output.error.message)  // "API returned 503"
  // retry the failed step:
  const retried = await output.retry()
  // equivalent: await app.resume(output.state)
}

Regular (non-retriable) errors propagate normally and are thrown from start() / resume().

Released under the MIT License.