workflow

Durable workflows for Lunora: defineWorkflow over Cloudflare Workflows, generated WorkflowEntrypoint classes, and the ctx.workflows surface


[![typescript-image][typescript-badge]][typescript-url] [![FSL-1.1-Apache-2.0 licence][license-badge]][license] [![npm version][npm-version-badge]][npm-version] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome]
---

Daniel Bannert's open source work is supported by the community on GitHub Sponsors

--- Durable workflows for Lunora, built on [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) (GA durable execution). `defineWorkflow` lets you author a multi-step, durable program whose steps are **memoized and retried** automatically and that **survives Worker restarts and redeploys**. Codegen emits the `WorkflowEntrypoint` class and wires the typed `ctx.workflows` handle; `@lunora/config` reconciles the `[[workflows]]` binding. Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX. ## Install ```sh npm install @lunora/workflow ``` ```sh yarn add @lunora/workflow ``` ```sh pnpm add @lunora/workflow ``` ## Usage ### Authoring ```ts // lunora/workflows.ts import { defineWorkflow } from "@lunora/workflow"; import { api } from "./_generated/api"; export const orderPipeline = defineWorkflow<{ orderId: string }>({ handler: async (ctx) => { // ctx.step.do(...) is the durability boundary — memoized + retried. const order = await ctx.step.do("load", () => ctx.run(api.orders.get, { id: ctx.params.orderId })); await ctx.step.sleep("cool-off", "1 minute"); await ctx.step.do("charge", () => ctx.run(api.payments.charge, { orderId: ctx.params.orderId })); // Hibernate until an external event arrives (webhook, approval, …). const shipped = await ctx.step.waitForEvent<{ trackingId: string }>("await-shipment", { type: "shipment.created" }); return { order, trackingId: shipped.payload.trackingId }; }, }); ``` The handler context bundles: - `ctx.step` — the native Cloudflare durable-step API (`do` / `sleep` / `sleepUntil` / `waitForEvent`). - `ctx.run(ref, args, opts?)` — call a Lunora query / mutation / action; wrap in `ctx.step.do(...)` for durability. - `ctx.runStep(step, args, opts?)` — run a reusable, schema-validated `defineStep` as a durable step (see below). - `ctx.event` / `ctx.params` — the triggering event and its payload. - `ctx.env` — the Worker bindings. - `ctx.log` — a workflow-prefixed logger surfaced in `wrangler tail` / Studio. ### Reusable steps (`defineStep`) A step authored inline with `ctx.step.do("name", () => …)` is fine for one-offs, but `defineStep` lets you define a step **once** — schema-validated and reusable across workflows. Args are validated (with `@lunora/values`) **before** the body runs, and the return value is validated **after** (when you declare `returns`), so a bad payload fails fast instead of corrupting later steps. Scaffold one with `vis generate lunora-step --name=chargeOrder` (appends to `lunora/steps.ts`), or write it by hand: ```ts // lunora/steps.ts import { defineStep } from "@lunora/workflow"; import { v } from "@lunora/values"; import { api } from "./_generated/api"; export const charge = defineStep("charge", { args: { orderId: v.string(), amount: v.number() }, returns: v.object({ receiptId: v.string() }), config: { retries: { limit: 3, backoff: "exponential", delay: "10 seconds" } }, handler: async (ctx, { orderId, amount }) => { if (ctx.attempt > 1) ctx.log.warn(`retrying charge for ${orderId}`); return ctx.run(api.payments.charge, { orderId, amount }); }, // Compensation — runs if a *later* step fails after this one committed. rollback: async (ctx) => { await ctx.run(api.payments.refund, { orderId: ctx.args.orderId }); }, }); ``` Run it from a workflow body — `ctx.runStep` wraps it in a durable `step.do(...)` for you: ```ts export const orderPipeline = defineWorkflow<{ orderId: string }>({ handler: async (ctx) => { const { receiptId } = await ctx.runStep(charge, { orderId: ctx.params.orderId, amount: 4200 }); return { receiptId }; }, }); ``` The step handler context gives you `ctx.attempt` (1-based retry counter), `ctx.config`, `ctx.env`, `ctx.run`, `ctx.log`, and `ctx.step` (`{ name, count }`). Pass `ctx.runStep(step, args, { config })` to override the step's durability config for a single call. ### Rollback (saga compensation) A step's optional `rollback` handler is forwarded to Cloudflare's **native** step rollback — it runs when a later step in the same instance fails, letting you undo a committed side effect (refund a charge, delete an uploaded object). The rollback context carries the original `args`, the `error` that triggered it, the step's `output` (if it completed), and `env` / `run` / `log`. Cloudflare owns rollback ordering and execution; `defineStep` just wires the handler and an optional `rollbackConfig`. ### Failing without retries (`NonRetryableError`) Throw `NonRetryableError` from a step or the handler to fail the instance immediately, skipping retries — the portable, Node-importable mirror of `cloudflare:workflows`' native error (so your workflow code stays unit-testable). The runtime converts it to the native error at the workflow boundary. ```ts import { defineStep, NonRetryableError } from "@lunora/workflow"; import { v } from "@lunora/values"; import { api } from "./_generated/api"; export const charge = defineStep("charge", { args: { orderId: v.string() }, handler: async (ctx, { orderId }) => { const order = await ctx.run(api.orders.get, { id: orderId }); if (order.status === "cancelled") { throw new NonRetryableError("order cancelled — retrying will never succeed"); } return ctx.run(api.payments.charge, { orderId }); }, }); ``` ### Starting instances Codegen wires a typed `ctx.workflows` handle onto mutations and actions. Resolve a handle by export name and start an instance: ```ts // lunora/orders.ts import { mutation, v } from "./_generated/server"; export const checkout = mutation.input({ orderId: v.string() }).mutation(async ({ ctx, args: { orderId } }) => { const instance = await ctx.workflows.get<{ orderId: string }>("orderPipeline").create({ params: { orderId } }); return { instanceId: instance.id }; }); ``` A handle exposes `create({ id?, params?, retention? })`, `createBatch([...])`, and `get(id)`. An instance handle exposes its lifecycle: `status()`, `pause()`, `resume()`, `terminate()`, and `sendEvent({ type, payload })` (to satisfy a `waitForEvent`). ### Runtime requirements `ctx.run` dispatches back into the Worker, so the workflow's `env` must carry: - `LUNORA_ORIGIN_URL` — where the Worker is mounted. - `LUNORA_ADMIN_TOKEN` — the admin bearer the dispatch endpoint accepts. ### Manual wiring (without codegen) 1. Author `lunora/workflows.ts` as above. 2. Re-export the generated class from your worker entry — wrangler requires every `workflows[].class_name` to be exported: ```ts import LunoraWorkflow from "@lunora/workflow/do"; import { orderPipeline } from "./lunora/workflows"; export class OrderPipelineWorkflow extends LunoraWorkflow { constructor(ctx: ExecutionContext, env: Record) { super(ctx, env, orderPipeline, "orderPipeline"); } } ``` 3. Add the binding to `wrangler.jsonc`: ```jsonc { "workflows": [{ "name": "order-pipeline", "binding": "WORKFLOW_ORDER_PIPELINE", "class_name": "OrderPipelineWorkflow" }], } ``` 4. Build `ctx.workflows` from the binding: `createWorkflows({ bindings: { orderPipeline: env.WORKFLOW_ORDER_PIPELINE } })`. The `workflowClassName` / `workflowBindingName` / `workflowDefaultName` helpers produce exactly these names so codegen and config never disagree. ### Observing instances (REST client) The Worker `Workflow` binding can only `create`/`get` an instance and read one instance's status — it has no instance list and no per-step detail. To list instances or read a step timeline, use `createWorkflowsRestClient`, which talks to Cloudflare's account-scoped Workflows REST API. The API token is a secret, so this runs server-side only (the Studio reaches it through the admin-gated runtime proxy). ```ts import { createWorkflowsRestClient } from "@lunora/workflow"; const client = createWorkflowsRestClient({ accountId: env.CLOUDFLARE_ACCOUNT_ID, apiToken: env.CLOUDFLARE_API_TOKEN, // scope: Workflows Read (Edit for setInstanceStatus) }); const page = await client.listInstances({ workflowName: "order-pipeline", status: "running" }); const detail = await client.getInstance({ workflowName: "order-pipeline", instanceId: page.instances[0].id }); await client.setInstanceStatus({ workflowName: "order-pipeline", instanceId: detail.id, action: "terminate" }); ``` > This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/addons/workflows)**. ## Related - [`@lunora/server`](https://www.npmjs.com/package/@lunora/server) — start workflows via `ctx.workflows` from a mutation or action. - [`@lunora/scheduler`](https://www.npmjs.com/package/@lunora/scheduler) — `runAfter` / `runAt` + Cron Triggers for non-durable scheduling. - [`@lunora/config`](https://www.npmjs.com/package/@lunora/config) — reconciles the `[[workflows]]` binding. ## Supported Node.js Versions Libraries in this ecosystem make the best effort to track [Node.js' release schedule](https://github.com/nodejs/release#release-schedule). Here's [a post on why we think this is important](https://medium.com/the-node-js-collection/maintainers-should-consider-following-node-js-release-schedule-ab08ed4de71a). ## Contributing If you would like to help take a look at the [list of issues](https://github.com/anolilab/lunora/issues) and check our [Contributing](https://github.com/anolilab/lunora/blob/alpha/.github/CONTRIBUTING.md) guidelines. > **Note:** please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. ## Credits - [Daniel Bannert](https://github.com/prisis) - [All Contributors](https://github.com/anolilab/lunora/graphs/contributors) ## Made with ❤️ at Anolilab This is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Anolilab](https://www.anolilab.com/open-source) is a Development and AI Studio. Contact us at [hello@anolilab.com](mailto:hello@anolilab.com) if you need any help with these technologies or just want to say hi! ## License The Lunora workflow package is open-sourced software licensed under the [FSL-1.1-Apache-2.0][license]. [license-badge]: https://img.shields.io/badge/license-FSL--1.1--Apache--2.0-blue.svg?style=for-the-badge [license]: https://github.com/anolilab/lunora/blob/alpha/LICENSE.md [npm-version-badge]: https://img.shields.io/npm/v/@lunora/workflow?style=for-the-badge [npm-version]: https://www.npmjs.com/package/@lunora/workflow [npm-downloads-badge]: https://img.shields.io/npm/dm/@lunora/workflow?style=for-the-badge [npm-downloads]: https://www.npmjs.com/package/@lunora/workflow [prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge [prs-welcome]: https://github.com/anolilab/lunora/blob/alpha/.github/CONTRIBUTING.md [typescript-badge]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript [typescript-url]: https://www.typescriptlang.org/