Private packages

@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.

On this page