One-click buy with a durable cancellation window in TypeScript on Resonate

A one-click purchase flow needs to start a checkout, hold open a short window during which the user can still cancel, then either confirm or cancel — and recover its workflow state if the process dies mid-window. The example-nextjs-ecommerce-ts repo expresses this as a single generator workflow: ctx.run wraps a Promise that races a 5-second timer against an EventEmitter cancel:<key> event, the result is checkpointed, and a second POST /buy/:key with the same key reconnects to the existing execution. The example is built on Express (the README also shows the framework-independent adaptation to Next.js), pinned to @resonatehq/sdk ^0.10.0, and is roughly 200 lines total.

The shape of the solution

// requires: import type { Context } from "@resonatehq/sdk";
// requires: import { bus } from "./bus";
export function* oneClickBuy(
ctx: Context,
itemId: string,
key: string,
): Generator<any, PurchaseResult, any> {
// Wait up to CANCEL_WINDOW_MS for a cancellation.
// ctx.run() checkpoints the result — if the process crashes during the
// window, it either returns the cached cancellation or restarts the timer.
const decision = yield* ctx.run(waitForCancelOrTimeout, key, CANCEL_WINDOW_MS);

if (decision === "cancelled") {
yield* ctx.run(cancelledPurchase, itemId);
return { state: "PURCHASE_CANCELLED", itemId };
}

const orderId = yield* ctx.run(checkoutItem, itemId);
return { state: "PURCHASE_CONFIRMED", itemId, orderId };
}
// from example-nextjs-ecommerce-ts/src/workflow.ts:33

The cancellation window itself is a plain Promise that resolves on whichever happens first — the timer or the cancel signal:

// requires: import { bus } from "./bus"; // bus = new EventEmitter() (see src/bus.ts)
async function waitForCancelOrTimeout(
_ctx: Context,
key: string,
windowMs: number,
): Promise<"cancelled" | "confirmed"> {
return new Promise<"cancelled" | "confirmed">((resolve) => {
const timer = setTimeout(() => {
bus.removeAllListeners(`cancel:${key}`);
resolve("confirmed");
}, windowMs);

bus.once(`cancel:${key}`, () => {
clearTimeout(timer);
resolve("cancelled");
});
});
}
// from example-nextjs-ecommerce-ts/src/workflow.ts:60

The HTTP layer is three Express handlers. POST /buy/:key starts (or reconnects to) the workflow:

app.post("/buy/:key", async (req, res) => {
const key = req.params.key;
const itemId = (req.body as { itemId?: string }).itemId ?? "widget-001";
// ... console.log of the incoming request
// Fire-and-forget: workflow runs in background
resonate.run(`checkout/${key}`, oneClickBuy, itemId, key).catch(console.error);
res.json({ status: "pending", key });
});
// from example-nextjs-ecommerce-ts/src/main.ts:28

POST /cancel/:key emits the cancel event onto the in-process bus:

app.post("/cancel/:key", (req, res) => {
const key = req.params.key;
// ... console.log of the cancellation request
bus.emit(`cancel:${key}`);
res.json({ status: "ok" });
});
// from example-nextjs-ecommerce-ts/src/main.ts:45

GET /status/:key reads the workflow handle and returns 404 if the id was never started:

app.get("/status/:key", async (req, res) => {
const key = req.params.key;

try {
const handle = await resonate.get(`checkout/${key}`);
const done = await handle.done();

if (!done) {
res.json({ status: "pending" });
return;
}

const result = (await handle.result()) as PurchaseResult;
res.json({ status: "done", result });
} catch {
res.status(404).json({ status: "not_found" });
}
});
// from example-nextjs-ecommerce-ts/src/main.ts:57

The durable primitives in play

new Resonate() — instantiates an embedded Resonate runtime in-process; no external server required for this example. src/main.ts:11.

resonate.register(oneClickBuy) — registers the generator workflow so it can be invoked and recovered by name. src/main.ts:12.

resonate.run(id, fn, ...args) — starts (or reconnects to) a workflow keyed on id. Here the id is `checkout/${key}`, so a duplicate POST /buy/:key with the same key deduplicates to the same execution. src/main.ts:35.

resonate.get(id) — returns a handle to an existing workflow by the same id; the example uses it to poll handle.done() and read handle.result() from the status endpoint. src/main.ts:61.

ctx.run(waitForCancelOrTimeout, key, CANCEL_WINDOW_MS) — wraps the timer-vs-signal Promise as a durable checkpoint inside the workflow. The resolved value ("cancelled" or "confirmed") is persisted; on replay after a crash the workflow does not race the timer again, it returns the cached decision. src/workflow.ts:41.

ctx.run(cancelledPurchase, itemId) / ctx.run(checkoutItem, itemId) — branch-specific durable steps; each step is a checkpoint, so neither side-effecting branch is retried on replay once it has completed. src/workflow.ts:44, src/workflow.ts:48.

What the SDK handles vs. what you write

You write: a generator with three ctx.run calls — one for the cancellation window, one for the cancel tail, one for the confirm tail (src/workflow.ts:41, 44, 48); one async function (waitForCancelOrTimeout) that returns a Promise racing setTimeout against an EventEmitter event; two leaf activities (cancelledPurchase, checkoutItem) that stand in for real-world inventory and checkout calls; and three Express handlers that translate HTTP into resonate.run / bus.emit / resonate.get. There is no separate state variable for PURCHASE_PENDING / CONFIRMED / CANCELLED — the generator's position in the function body is the state.

The SDK and embedded Resonate runtime handle: keying execution on `checkout/${key}` so duplicate POST /buy/:key calls reconnect to the same workflow, checkpointing the resolved value of ctx.run(waitForCancelOrTimeout, …) so the window does not restart on replay once resolved, persisting each subsequent ctx.run result so the cancel-tail or confirm-tail does not double-fire, exposing the workflow's terminal value through resonate.get(id).result() for the status endpoint, and serving all of this without external infrastructure (new Resonate() runs the durability layer in the same process as Express).

Failure modes covered

Worker crashes mid-window with no cancel received. ctx.run(waitForCancelOrTimeout, …) has not yet checkpointed a value when the process dies (src/workflow.ts:41). On restart the workflow replays from the start, calls waitForCancelOrTimeout again, and races a fresh timer against the cancel:${key} event. The user's effective window resets on the restart; if no cancel arrives in either window, the workflow confirms exactly once.

Worker crashes after the window resolved but before the confirm/cancel tail ran. The decision is now a persisted checkpoint. On restart, ctx.run(waitForCancelOrTimeout, …) returns the cached "cancelled" or "confirmed" value instead of re-racing the timer (src/workflow.ts:38–41 comment), and the workflow proceeds straight to the matching tail.

Worker crashes after checkoutItem ran but before the workflow returned. ctx.run(checkoutItem, itemId) checkpoints its returned orderId (src/workflow.ts:48). On replay, checkoutItem is not re-invoked, so the user is not double-charged; the workflow replays with the cached orderId and returns the same PURCHASE_CONFIRMED result.

Duplicate POST /buy/:key from a double-click or retry. The workflow id is `checkout/${key}`, derived from the path param (src/main.ts:35). The second resonate.run with the same id reconnects to the in-flight execution rather than starting a parallel checkout.

POST /cancel/:key arrives after the window has expired. bus.emit(`cancel:${key}`) fires into the EventEmitter (src/main.ts:48), but the setTimeout branch of waitForCancelOrTimeout has already called bus.removeAllListeners(`cancel:${key}`) (src/workflow.ts:67), so there is no listener to receive it; the workflow is already on the confirmation path. The cancel endpoint still returns { status: "ok" }.

GET /status/:key for a key that was never started. resonate.get throws and the handler's try/catch returns 404 { status: "not_found" } (src/main.ts:71–73).

When to reach for this pattern

If you have a short, post-action "undo window" — one-click buy, send-email-with-undo, scheduled-delete-with-grace-period — and the window is short enough to wait inline but long enough that crash recovery matters.

If you would otherwise model the same flow as a PENDING / CONFIRMED / CANCELLED row in a database, polled by a cron job, and you'd rather express it as straight-line code where the generator's position is the state.

If the cancel signal arrives over HTTP (or any other in-process channel) and you don't want to introduce Redis pub/sub or a message broker just to deliver it to the workflow — an in-process EventEmitter is enough for a single-process deployment.

If callers should be able to safely retry the start request without spawning duplicate checkouts.

If the same workflow needs to run later behind Next.js, Hono, or any other HTTP layer with no changes to the workflow code itself (the README's "Adapting to Next.js" section is the example).

Sources

Example repo: github.com/resonatehq-examples/example-nextjs-ecommerce-ts

TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts

SDK primitives cited:

src/resonate.ts — Resonate constructor, register, run, get

src/context.ts — Context.run

Docs:

Resonate documentation home

TypeScript SDK guide