@onyx/workflows
Durable multi-step processes on Cloudflare Workflows, with per-step retries and long sleeps.
@onyx/workflows hosts durable multi-step processes on Cloudflare Workflows. Each workflow splits into a definition (name, binding, params schema) and a class (the steps). The registry of definitions is the single source of truth: the wrangler workflows config is generated from it at build time, so adding a workflow never touches wrangler.jsonc, and local dev simulates workflows.
Starting a workflow
startWorkflow(name, params, options?) is typed from the registry, parses params against the definition's schema, and returns { id }. Passing a stable options.id deduplicates: creating an instance with an id that already exists throws, which guarantees one running chain per entity.
Real usage from @onyx/auth, which starts a reminder when an organization invitation is sent:
import { startWorkflow } from '@onyx/workflows/client';
// The stable instance id makes re-sent invitations reuse the running
// reminder chain instead of starting a second one.
await startWorkflow(
'invitation-reminder',
{ invitationId: data.id },
{ id: `invitation-reminder-${data.id}` },
);Defining a new workflow
Each workflow lives in src/workflows/<name>/ as two files: a Node-importable definition and the class that implements the steps.
definition.ts declares the name, binding, and params schema:
import { z } from 'zod';
import { defineWorkflow } from '../../define.ts';
export const invitationReminder = defineWorkflow({
name: 'invitation-reminder',
binding: 'INVITATION_REMINDER',
className: 'InvitationReminderWorkflow',
schema: z.object({ invitationId: z.string() }),
});workflow.ts implements the class as a WorkflowEntrypoint over WorkflowsEnv:
export class InvitationReminderWorkflow extends WorkflowEntrypoint<
WorkflowsEnv,
InvitationReminderParams
> {
async run(event, step: WorkflowStep): Promise<void> {
// Params are serialized at rest, so validate on entry.
const { invitationId } = invitationReminder.schema.parse(event.payload);
const invitation = await step.do('load invitation', async () => {
// Step results must be JSON-serializable.
});
await step.sleepUntil('wait until reminder is due', dueTimestamp);
await step.do('send reminder if still pending', async () => {
// Re-read state; no-op if the invite was handled meanwhile.
});
}
}Then list the definition in src/registry.ts and export the class from the package's ./server export, re-exporting it from the Worker entry in apps/web next to the existing classes. startWorkflow() and the generated config pick everything up from there.
WorkflowsEnv names what steps need from the environment (DB, RESEND_API_KEY, MAIL_FROM, BETTER_AUTH_URL).
Built-in workflow
invitation-reminder starts when an organization invite is sent, sleeps until a day before the invite expires, and nudges the invitee if the invitation is still pending. If it was accepted, rejected, or canceled in the meantime, the final step is a no-op.