Skip to content

Writing a plugin

A plugin is an object with a name and any of six optional hooks. It is the engine’s extension point: persistence and devtools are both plugins, and nothing about them is privileged.

The example on this page is a checkout with two plugins of its own. Every block below is a file from examples/tasks/src/plugins in the repository, and a test runs the script and compares what it prints, so the output shown is what the engine does. The files sit side by side in one folder; run.ts awaits at the top level, so it runs as an ES module - with "type": "module" in the folder’s package.json - under a TypeScript runner such as npx tsx run.ts.

plugins/flow.ts
import { defineFlow, step } from '@wizzard-packages/core';
/** A checkout: what is in the cart, who is buying, and how they pay. */
export const checkout = defineFlow({
id: 'checkout',
order: ['cart', 'account', 'pay'],
steps: {
cart: step<{ coupon: string }>({ label: 'Cart' }),
account: step({ label: 'Sign in' }),
pay: step({ label: 'Payment' }),
},
});

audit holds state of its own, so it uses the hooks that start, observe and stop:

plugins/audit.ts
import type { Hooks } from '@wizzard-packages/core';
/**
* A plugin that uses the three hooks a plugin holding state of its own needs:
* `init` to start, `onCommit` and `onAttempt` to observe, and the function
* `init` returns to stop.
*/
export const audit = (log: (line: string) => void): Hooks => {
let commits = 0;
return {
name: 'audit',
// Once, inside `createWizard`, before anything else reads the wizard. The
// host reads state and flow and commits through the engine's one write
// path - here, a coupon every new checkout starts with.
init: (host) => {
host.commit({ data: { ...host.getState().data, cart: { coupon: 'WELCOME' } } });
log(`audit: ${host.getFlow().id} opened`);
// Called by `destroy()`.
return () => {
log(`audit: closed after ${commits} commits`);
};
},
// Every write, navigation or not. The commit `init` made is not reported:
// the plugin already knows about it.
onCommit: () => {
commits += 1;
},
// Every attempt to move, twice: when it starts, and when it ends with its
// result or errors with what was thrown. A refused move writes nothing,
// so only this hook sees it.
onAttempt: (attempt) => {
if (attempt.phase === 'end') {
const { result } = attempt;
log(
`attempt ${attempt.id} ${attempt.intent.type} (${attempt.source}): ` +
(result.ok ? `reached ${String(result.to)}` : `${result.reason} by ${result.by}`)
);
}
},
};
};

requireSignIn is the other kind, a plugin that decides whether a move may happen:

plugins/auth.ts
import type { Hooks } from '@wizzard-packages/core';
/**
* Refuses to leave the sign-in step until someone is signed in. `to` is `null`
* for `next()` - its target is not resolved yet when plugins run - so the rule
* reads `from`.
*/
export const requireSignIn = (signedIn: () => boolean): Hooks => ({
name: 'auth',
beforeNavigate: ({ from }) => (from === 'account' && !signedIn() ? { block: 'auth' } : undefined),
});

A plugin is passed to createWizard in its plugins option, and the wizard calls it from then on. The script builds the wizard, drives it into the refusal and past it, and destroys it:

plugins/run.ts
import { createWizard } from '@wizzard-packages/core';
import { audit } from './audit';
import { requireSignIn } from './auth';
import { checkout } from './flow';
let user: string | null = null;
// Plugins run in the order given; the first `beforeNavigate` to refuse ends a move.
const wizard = createWizard({
flow: checkout,
plugins: [audit(console.log), requireSignIn(() => user !== null)],
});
console.log(`coupon: ${String(wizard.get('cart.coupon'))}`);
await wizard.start();
await wizard.next();
// Refused by the `auth` plugin: a value, not an exception.
const refused = await wizard.next();
console.log(`refused: ${JSON.stringify(refused)}`);
user = 'ada';
await wizard.next();
// The application created the wizard, so it destroys it, and that runs every
// teardown `init` returned. A binding that created the wizard from options
// does this on unmount.
wizard.destroy();
Output
audit: checkout opened
coupon: WELCOME
attempt 1 next (start): reached cart
attempt 2 next (call): reached account
attempt 3 next (call): blocked by auth
refused: {"ok":false,"reason":"blocked","by":"auth","code":"nav-blocked","url":"https://zizzx.github.io/wizzard-packages/errors/nav-blocked"}
attempt 4 next (call): reached pay
audit: closed after 8 commits

The output reads in the order things happened. init ran inside createWizard, before the first line of the script read the coupon it committed. Every move is one onAttempt line, the refused one included, while onCommit counted eight writes for four attempts - the sections below say why. destroy() ran the teardown last.

Hook Runs May
init(host) once, before the engine is handed out read state and flow, commit, return a teardown
beforeNavigate(e) at the start of every move, before validation block or redirect the move
loadStep(id, signal) while entering a step marked deferred await work; see Loading a step
afterNavigate(e) after the move has committed observe only
onCommit(state, previous) after every commit, navigation or not observe only
onAttempt(attempt) around a whole attempt: start, end, error observe only

init receives a host with getState, getFlow and commit, and may return a teardown function, which destroy() calls. destroy() is called by whoever created the wizard: the application, when it called createWizard itself, as run.ts does; WizardProvider or provideWizard on unmount, when they were given options and created it; and the application again when it handed them a wizard it built. A teardown that never runs leaves its timers and listeners behind. A commit made during init is not reported to any plugin’s onCommit, so a plugin restoring state does not see its own restoration as a change.

onCommit fires on every commit, not only on navigation: set, setCtx, patch, reset and patchFlow each produce one.

onAttempt receives one object per event, twice per attempt. Every one carries id, which counts attempts on this wizard; intent, the move as asked ({ type: 'next' }, { type: 'back' } or { type: 'go', to, force }); source, which is 'start' for the move start() makes and 'call' for every other; and rev, the state’s revision at that moment. phase says which event it is: 'start', then either 'end' with the result the caller receives, or 'error' with the error that was thrown.

beforeNavigate is the only hook that can change what happens. It is called on every plugin in registration order, and the first one that refuses ends the move.

requireSignIn in plugins/auth.ts above is one, and the refusal it returns is the refused: line of the output.

to is the destination only when the move is an explicit go(id). A next() or a back() has not resolved its target yet when plugins run, so to is null there: a rule about a particular step reads from, or the state, rather than to.

Returned Effect
nothing the move continues
false refused, { ok: false, reason: 'blocked', by: <plugin name> }
{ block: name } refused, with by set to that name
{ redirect: to } the move becomes a go to that step and continues

A refusal also carries code and url, the kebab-case code and the page that explains it, as the refused: line of the output shows. Any other truthy answer - true, a string, an object with neither a string block nor a string redirect - rejects the move with before-navigate-invalid.

A redirect still needs the target’s when to hold. The flow’s policy is checked against it only when the move it replaces was a go() without force: the policy says where a jump may land, and a redirect from next() or back() is how a plugin sends someone to a step they have not reached yet, such as a sign-in.

It may be async. Everything after it - validation, guards, loading - runs against the intent it left behind, so a redirect is honoured by the rest of the pipeline rather than corrected afterwards.

A move is one pipeline, and lands in exactly one commit - the one that changes where the wizard is. It is not the only write: taking the navigation lock is a commit, and so is marking a step busy before it loads, and so is releasing the lock when a move is refused, and onCommit sees each of them. A plugin counting moves, or recording one entry per navigation, belongs in onAttempt or afterNavigate rather than in onCommit.

  1. beforeNavigate on every plugin
  2. the leaving step’s validate, going forward
  3. the leaving step’s exit guard
  4. the target is resolved, then checked for reachability and policy
  5. loadStep for a deferred step, then the step - read again, so the body a loadStep installed - is checked for reachability and its load runs
  6. the target’s enter guard
  7. the commit
  8. afterNavigate on every plugin

afterNavigate runs after that landing commit, so it cannot undo a move; a plugin that must prevent one does it in beforeNavigate.

start() entering a step that a restored session stands on is not a move: the step is not left, so steps 1 to 3 do not run, the target is not checked for reachability or policy, and steps 5 to 7 run as on any entry. beforeNavigate and afterNavigate do not see it; onAttempt does, with source: 'start'.

The engine keeps running, and says what it dropped.

  • afterNavigate throwing is logged as after-navigate-threw. The move stands: it had already committed.
  • init, onCommit or onAttempt throwing disables that plugin entirely and logs plugin-disabled. None of its hooks are called again, because a plugin that failed once has no state the engine can trust.
  • A teardown throwing is logged as plugin-teardown-failed, and the other teardowns still run.
  • beforeNavigate and loadStep throwing is not caught: the move is abandoned, the wizard returns to idle, and the exception reaches whoever called next() - unless the move was overtaken or cancelled by then, when it answers superseded or aborted and onAttempt reports an end, not an error. The plugin stays enabled - a network failure in a loader is the caller’s to handle, not a reason to switch persistence off.

A plugin that means to refuse should return a refusal rather than throw. API behaviour has the rule this follows: refusals are values, failures are exceptions, and a failure with nobody to throw to is logged.

@wizzard-packages/plugins/persist is the plugin to read; its source is packages/plugins/src/persist.ts. It uses two hooks and no more: init reads storage, decodes the snapshot and commits it through the host; onCommit serialises the state and writes it behind a short debounce, so typing does not write on every keystroke. Every failure inside it - storage unavailable, payload unreadable, version mismatch - is reported through its own onRestore callback and a warning, never by throwing, which is what keeps it from disabling itself. Persistence documents it as a user.