Skip to content

Server-driven flows

A flow is data, and that decision has a consequence this page is about: a definition can be written somewhere other than your bundle. It can live in a column, be assembled by a service, be edited by someone who does not deploy, and arrive over the wire.

This page has two halves, and they are not the same kind of thing. The first is the contract the engine accepts today and is covered by tests. The second is what is planned on top of it, and is not here yet.

A FlowDefinition is JSON. Everything it cannot carry - a validator, a predicate, a loader - is named by a $ref and answered by a registry the client already holds.

That split is the security property, not a convenience. A definition from a server can ask for a check to run; it cannot supply the code that runs it. A name it invents is reported by validateFlow before anything runs.

One qualification, because it decides how you build the registry. validateFlow asks name in registry, and in walks the prototype chain - so against an ordinary object literal, { "$ref": "toString" } passes as a known resolver and the engine then calls Object.prototype.toString, which returns a truthy string and quietly changes which steps are reachable. Build the registry with a null prototype, as the example does, and the only names that resolve are the ones you put there.

Loaded from the server.

The definition as text, and the half that never travels.

contract.ts
import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/**
* A flow as it arrives from a backend: text, not a module.
*
* This is the whole server-driven contract in 1.0.0. The definition is JSON,
* so it can be stored in a column, sent over a wire and diffed in a review;
* everything it cannot carry - a validator, a predicate, a loader - is named
* here and answered by a registry the client already has.
*/
export const FROM_SERVER = `{
"id": "signup",
"version": 1,
"order": ["account", "billing", "done"],
"steps": {
"account": { "label": "Account", "validate": { "$ref": "account" } },
"billing": {
"label": "Billing",
"when": { "$eq": [{ "$get": "data.account.plan" }, "team"] }
},
"done": { "label": "Done" }
}
}`;
/**
* The half that never travels. A `$ref` names a function the client holds, so
* a definition from a server can ask for a check without being able to supply
* the code that runs it - which is the property that makes accepting one
* safe at all.
*/
export const registry: AsyncRegistry = Object.assign(Object.create(null) as AsyncRegistry, {
account: (_args: unknown, scope: Scope): Record<string, string> | null => {
const { email } = (scope.data['account'] as { email?: string } | undefined) ?? {};
return typeof email === 'string' && email.includes('@')
? null
: { email: 'Enter your email address.' };
},
});
/**
* A later message from the same backend: one step replaced, one added. Steps
* are merged by id, so a patch names only what changes.
*/
export const PATCH_FROM_SERVER = `{
"order": ["account", "billing", "invoice", "done"],
"steps": {
"invoice": { "label": "Invoice details" }
}
}`;

Cheapest check first: size, JSON, shape, then the flow rules.

load.ts
import type { AsyncRegistry, FlowDefinition } from '@wizzard-packages/core';
import { validateFlow, type FlowProblem } from '@wizzard-packages/core/validate-flow';
/**
* Reading a flow that arrived from somewhere else.
*
* The order of the checks is the order of what can be wrong, cheapest first:
* size, then JSON, then the shape `validateFlow` assumes, then the flow rules
* themselves.
*
* The shape check is not politeness. `validateFlow` is typed for a
* `FlowDefinition` and reads fields off it without guarding them: `order: 1`
* reaches `for (const id of flow.order)`, and a step of `null` reaches a
* property read. Both are ordinary things for a broken service to send, and
* `flow-unreadable` - which is what the validator returns for them - names the
* read that failed rather than the field the service got wrong.
*
* The `try` around the validator is the belt to that pair of braces: it is what
* catches a value the validator cannot even print.
*/
export type LoadResult =
| { ok: true; flow: FlowDefinition }
| { ok: false; problems: readonly FlowProblem[] };
/** Characters, not bytes: the cheap gate that stops a generated file being parsed at all. */
const MAX_CHARS = 100_000;
const DOCS = 'https://zizzx.github.io/wizzard-packages/docs/server-driven/';
/**
* Problem, cause, fix, link - the shape every message in this library takes. The
* code is this loader's own, so a caller can tell its refusals from the
* validator's; the link is the guide, since the library has no page for it.
*/
const fail = (path: string, what: string, why: string, fix: string): LoadResult => ({
ok: false,
problems: [
{
path,
code: 'flow-not-loaded',
fix,
url: DOCS,
message: `[wizzard] ${what}. ${why}. ${fix}. ${DOCS}`,
},
],
});
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** The fields the validator dereferences without checking them first. */
function shapeProblem(value: Record<string, unknown>): LoadResult | null {
const { order, steps } = value;
if (
order !== undefined &&
(!Array.isArray(order) || order.some((id) => typeof id !== 'string'))
) {
return fail(
'order',
'order is not a list of step ids',
'It names the sequence the flow walks, and everything that reads it walks it as a list of strings',
'Send an array of step ids, or leave it out and the steps run in the order they are written'
);
}
if (steps !== undefined) {
if (!isObject(steps)) {
return fail(
'steps',
'steps is not an object',
'Every flow is a map of step ids to the steps themselves',
'Send an object keyed by step id'
);
}
for (const [id, step] of Object.entries(steps)) {
if (!isObject(step)) {
return fail(
`steps.${id}`,
`step "${id}" is ${step === null ? 'null' : typeof step}, not an object`,
'Each entry describes one step, and the validator reads fields off it',
`Send an object, empty if the step has nothing to say: "${id}": {}`
);
}
}
}
return null;
}
/** Runs the validator without letting an unguarded read escape as an exception. */
function problemsOf(flow: FlowDefinition, registry: AsyncRegistry): LoadResult {
try {
const problems = validateFlow(flow, registry);
return problems.length > 0 ? { ok: false, problems } : { ok: true, flow };
} catch (error) {
return fail(
'',
'the definition could not be checked',
`the validator failed on it (${(error as Error).message})`,
'This is a malformed payload rather than a flow with a mistake in it; check what the service sent'
);
}
}
export function loadFlow(text: string, registry: AsyncRegistry): LoadResult {
if (text.length > MAX_CHARS) {
return fail(
'',
`the payload is ${text.length} characters`,
`Anything past ${MAX_CHARS} is not a flow someone wrote`,
'Check that the service is sending a definition and not a page of something else'
);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
return fail(
'',
'the payload is not JSON',
`parsing it failed (${(error as Error).message})`,
'Check the response is the definition itself rather than an envelope around it'
);
}
if (!isObject(parsed) || typeof parsed['id'] !== 'string' || !isObject(parsed['steps'])) {
return fail(
'',
'the payload is not a flow',
'A flow is an object with a string `id` and an object of `steps`',
'Check the service is sending a flow definition'
);
}
const shape = shapeProblem(parsed);
if (shape !== null) return shape;
return problemsOf(parsed as unknown as FlowDefinition, registry);
}
/**
* A patch is a definition too, and `patchFlow` does not check it: it merges by
* id and installs the result. So the merge happens here first, against the same
* checks, and the patch is only handed over once the flow it would produce is
* one the engine can run.
*/
export function checkPatch(
current: FlowDefinition,
text: string,
registry: AsyncRegistry
): { ok: true; patch: Partial<FlowDefinition> } | { ok: false; problems: readonly FlowProblem[] } {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
return fail(
'',
'the patch is not JSON',
`parsing it failed (${(error as Error).message})`,
'Check the response is the patch itself rather than an envelope around it'
) as { ok: false; problems: readonly FlowProblem[] };
}
if (!isObject(parsed)) {
return fail(
'',
'the patch is not an object',
'A patch is a partial flow: the fields that changed, and nothing else',
'Send an object with the fields to replace'
) as { ok: false; problems: readonly FlowProblem[] };
}
const shape = shapeProblem(parsed);
if (shape !== null) return shape as { ok: false; problems: readonly FlowProblem[] };
const merged = {
...current,
...parsed,
steps: { ...current.steps, ...(parsed['steps'] as Record<string, unknown> | undefined) },
} as unknown as FlowDefinition;
const checked = problemsOf(merged, registry);
return checked.ok
? { ok: true, patch: parsed as unknown as Partial<FlowDefinition> }
: { ok: false, problems: checked.problems };
}
App.tsx
import { createWizard, type FlowDefinition, type Wizard as Engine } from '@wizzard-packages/core';
import {
WizardProvider,
useField,
useNavigation,
useStep,
useWizard,
} from '@wizzard-packages/react';
import { useId, useMemo, useState, type ReactNode } from 'react';
import { FROM_SERVER, PATCH_FROM_SERVER, registry } from './contract';
import { checkPatch, loadFlow } from './load';
export function App(): ReactNode {
// The definition is read once, and the engine is built from what came back.
// A payload that does not load has no wizard to render, which is the honest
// rendering of a backend that sent something wrong.
const loaded = useMemo(() => loadFlow(FROM_SERVER, registry), []);
const wizard = useMemo(
() => (loaded.ok ? createWizard({ flow: loaded.flow, registry }) : null),
[loaded]
);
if (!loaded.ok || wizard === null) {
return (
<ul>
{loaded.ok
? null
: loaded.problems.map((problem) => <li key={problem.path}>{problem.message}</li>)}
</ul>
);
}
return (
<WizardProvider wizard={wizard}>
<Wizard flow={loaded.flow} />
</WizardProvider>
);
}
export function Wizard(props: { flow: FlowDefinition }): ReactNode {
const wizard = useWizard() as Engine;
const { current } = useStep();
const { next, isBusy } = useNavigation();
const [email, setEmail] = useField<string>('account.email');
const [note, setNote] = useState('Loaded from the server.');
const emailId = useId();
const step = current ?? 'account';
const starting = current === null;
/**
* A patch is checked before it is applied. `patchFlow` merges and installs;
* it does not validate, so an `order` of the wrong type would be accepted
* here and fail later, in a selector, with the payload long out of sight.
*/
const apply = (text: string, label: string) => {
const checked = checkPatch(props.flow, text, registry);
if (!checked.ok) {
setNote(`${label}: refused before applying - ${checked.problems[0]?.message ?? ''}`);
return;
}
setNote(`${label}: ${wizard.patchFlow(checked.patch) ? 'applied' : 'refused'}`);
};
return (
<form onSubmit={(e) => e.preventDefault()}>
{step === 'account' && (
<p>
<label htmlFor={emailId}>Your email</label>
<input
id={emailId}
value={email ?? ''}
onChange={(e) => setEmail(e.target.value)}
disabled={starting}
/>
</p>
)}
{step !== 'account' && <p>Step: {step}</p>}
<button type="button" onClick={() => void next()} disabled={starting || isBusy}>
Next
</button>
<button type="button" onClick={() => apply(PATCH_FROM_SERVER, 'Patch')} disabled={starting}>
Apply the patch
</button>
{/* The patch a backend should not send: it deletes the step the person is
standing on, and the engine answers false rather than relocating them.
Built here rather than parsed, because JSON has no `undefined` and so
cannot express a deletion at all. */}
<button
type="button"
onClick={() => {
const removal = { steps: { account: undefined } } as unknown as Parameters<
typeof wizard.patchFlow
>[0];
setNote(`Removal: ${wizard.patchFlow(removal) ? 'applied' : 'refused'}`);
}}
disabled={starting}
>
Apply a patch that deletes this step
</button>
<p role="status">{note}</p>
</form>
);
}

A definition that arrived from elsewhere is untrusted input, and the checks are worth doing cheapest first: the size of the text, then whether it is JSON at all, then whether it has the shape a flow has, and only then the flow’s own rules.

The shape check is not politeness. validateFlow is typed for a FlowDefinition and reads fields off it without guarding them, so an ordinary malformed payload - a string where steps should be, order holding a number - makes one of those reads throw. The validator returns that as flow-unreadable rather than throwing, but the problem it returns names a read, not the field a service got wrong. Checking id and steps yourself first is what turns the common cases into problems that say what to fix.

Every failure is a value here, which is what lets a page render the problems instead of disappearing behind a stack trace. Validation is what validateFlow reports and how assertFlow differs.

patchFlow takes a partial definition and merges it: steps are replaced by id, so a patch names only what changed, and fields at the top level are replaced whole.

// `wizard` is the running engine: what `createWizard` returned, or `useWizard()` in a component.
wizard.patchFlow({ order: ['account', 'billing', 'invoice', 'done'], steps: { invoice: {} } });

It returns whether it applied, and there is one patch it refuses: the one that deletes the step the person is standing on. Relocating them silently loses a half-filled form, and a backend sending that has a bug worth surfacing rather than smoothing over. The refusal only applies at the root of the flow - a step inside a group is never in the root’s steps, and reading its absence as a deletion would refuse every patch made while someone stands inside a group.

A patch that introduces a group step without a traversal installed throws groups-not-installed rather than returning false. That is a wiring mistake in your application, not an outcome of the payload.

Check a patch before applying it. patchFlow merges and installs; it does not validate. A patch carrying order: 1 is accepted and the failure surfaces later, inside a selector, with the payload long out of sight. Merge it against the current definition yourself, run the same checks you ran on the first payload, and hand over the patch only if the flow it produces is one the engine can run - which is what the example does, and why the refusal it shows names a problem rather than a stack trace.

Deleting a step is the one change a payload cannot express. A step is removed by setting it to undefined, and JSON has no undefined - null is a different value and is not treated as a deletion. Removal has to be built in code, which is why the example builds that patch rather than parsing it.

Sending flows over the wire as a feature - fetching, caching, revalidating, patching from a stream - is roadmap work, and http-flow is the plugin that would carry it. It is not part of 1.0.0, and this page is deliberately the only server-driven surface the release ships.

The reason for the order is that the contract has to be right before the transport is interesting. Everything a transport would need already exists and is tested: a definition is JSON, it can be validated before it runs, it can be swapped while the wizard is running, and the part that cannot be serialized is named rather than sent. What http-flow adds is the plumbing, not the model.

Until it lands, fetch and patchFlow are the plumbing, and they are the same two calls the plugin would make.