@dicabrio/durable-sdk
v0.4.2
Published
TypeScript SDK for the Durable execution engine: define durable functions, sync them, send events, and serve the replay callback.
Maintainers
Readme
@dicabrio/durable-sdk
TypeScript SDK for the self-hosted Durable execution engine. The service keeps queue and replay state; your application hosts a signed callback that executes at most one new checkpoint per request.
Install
npm install @dicabrio/durable-sdkThe package contains ESM, CommonJS and self-contained declarations. Node.js 18+ is supported; the Durable CLI requires Node.js 20+.
Define a function
import {
createFunction,
NonRetriableError,
} from "@dicabrio/durable-sdk";
export const onboarding = createFunction({
id: "onboarding",
trigger: { event: "user.created" },
retries: 3, // retries after the initial attempt
timeout: "20s", // one callback/checkpoint invocation
handler: async ({ event, step }) => {
const data = event.data as { id: string };
const user = await step.run("load-user", async () => {
if (!data.id) throw new NonRetriableError("user id required", {
code: "USER_ID_REQUIRED",
details: { source: event.name },
});
return { id: data.id, email: `${data.id}@example.com` };
});
await step.sleep("cooldown", "5s");
await step.run("send-email", () => sendWelcomeEmail(user));
return { ok: true };
},
onFailure: async ({ event, error, step, runId }) => {
// Separate durable run. event is the durable/function.failed system event.
await step.run("mark-failed", () =>
markRecipeFailed(event.data.event.data, error.message),
);
},
});NonRetriableError skips remaining retries. Other errors are retried according
to the function’s retries setting; if omitted, the service-wide default is
used. Failure handling follows Inngest: a terminal failure emits
durable/function.failed; onFailure is shorthand for a separate durable
handler of that system event and receives { event, error, step, runId }.
The original event is event.data.event, and event.data.run_id identifies the
failed run. Cancellations do not invoke onFailure.
Host the callback
Express / Node HTTP
Mount without a JSON parser on this route; HMAC verification requires the exact raw bytes.
import express from "express";
import { serve } from "@dicabrio/durable-sdk";
const app = express();
app.all("/api/durable", serve([onboarding], {
signingKey: process.env.DURABLE_APP_KEY!,
baseUrl: process.env.DURABLE_BASE_URL,
appId: process.env.DURABLE_APP_ID,
appName: "billing",
}));baseUrl and appId enable the best-effort live running-step marker. Durable
execution itself does not depend on that marker. serve/serveNext accept
maxBodyBytes (1 MiB by default); DurableClient similarly supports
requestTimeoutMs, maxRequestBytes and maxResponseBytes.
Next.js App Router
// app/api/durable/route.ts
import { serveNext } from "@dicabrio/durable-sdk/next";
import { onboarding } from "@/durable/functions";
export const runtime = "nodejs";
export const maxDuration = 30;
export const { GET, POST } = serveNext([onboarding], {
signingKey: process.env.DURABLE_APP_KEY!,
baseUrl: process.env.DURABLE_BASE_URL,
appId: process.env.DURABLE_APP_ID,
appName: "billing",
});In development, durable dev discovers this route at
http://localhost:3000/api/durable; appName controls the workspace name.
Discovery is enabled only when NODE_ENV=development (or dev: true is set explicitly).
The adapter uses standard Request/Response, has no Next.js dependency and is
Node-runtime only because HMAC currently uses node:crypto.
Provision and connect
Create production workspaces in the authenticated dashboard. For controlled CI/local provisioning before app credentials exist:
import { DurableClient, provisionApp } from "@dicabrio/durable-sdk";
const credentials = await provisionApp({
baseUrl: "https://app.durable.dicabrio.com",
appUrl: "https://my-app.example.com/api/durable", // stored once on the app record
name: "billing",
environment: "prod",
adminToken: process.env.DURABLE_ADMIN_TOKEN,
});
const client = new DurableClient({
baseUrl: "https://app.durable.dicabrio.com",
appId: credentials.id,
signingKey: credentials.key,
});The callback URL is supplied only when the workspace is created and is then
stored by Durable. It is not part of DurableClient or /fn/sync; only an
authenticated admin operation can change it.
The first pull check may fail until the generated key is configured in the app;
a subsequent sync() or dashboard Refresh completes registration.
Registration
await client.sync([onboarding]);sync() pushes the complete manifest. The same endpoint also answers a signed
GET, allowing the service to pull the manifest on startup, nightly and through
Refresh. Missing functions are disabled rather than deleted, preserving run
history and allowing explicitly retained old versions to finish.
Deploy the service and all workers before publishing/running an SDK that emits new protocol features.
Reliable events
const receipt = await client.send({
id: `import:${importId}`, // stable idempotency key within this app
name: "import.requested",
data: { importId },
});If id is omitted the SDK generates one before its first attempt. Network,
408, 429 and 5xx failures are retried with the same signed body. If all attempts
fail, EventSendError.eventId exposes that generated ID for a safe later retry.
Reusing an ID with the same event returns the immutable original receipt; reusing
it with a different name/payload returns HTTP 409. Idempotency receipts survive
run-history cleanup and are removed only with the app.
For imports, client.sendMany(events) sends 1-100 events in one idempotent
transaction. IDs omitted by the caller are generated before the first network
attempt and exposed through BulkEventSendError.eventIds on failure.
Inside a workflow use step.sendEvent, never an uncheckpointed client.send:
const [sent] = await step.sendEvent("start-import", {
id: `recipe:${recipeId}:import`,
name: "import.requested",
data: { recipeId },
});The checkpoint and event fan-out commit in one service transaction.
Step primitives
| Primitive | Behavior |
|---|---|
| step.run(id, fn) | execute one checkpoint and memoize its JSON result |
| step.parallel(id, tasks) | execute 1-20 callbacks concurrently as one composite checkpoint |
| step.sleep(id, duration) | durable delay without holding a worker |
| step.waitForEvent(id, options) | resume with a matching event or null on timeout |
| step.sendEvent(id, event) | atomically checkpoint and dispatch idempotent events |
Step IDs must be stable and unique within a run. Inputs, outputs and event data
must be JSON-serializable. Multiple new step.run calls in Promise.all are
rejected before their callbacks start; use step.parallel. Its branches share
one checkpoint and can all repeat if the callback result is lost.
Multiple and shared concurrency rules
createFunction({
id: "domain-import",
trigger: { event: "import.requested" },
concurrency: [
{ limit: 2, key: "domain" }, // two per event.data.domain
{ limit: 20, scope: "all-imports" }, // shared across functions in this app
],
handler: async ({ event, step }) => { /* ... */ },
});All rules must have capacity before a callback starts. Shared scope names are
isolated per app. key is a direct top-level field in event.data, not an
expression; precompute derived values before sending the event. Every function
participating in one shared scope must use the same limit and either all keyed
or all unkeyed rules. Concurrency counts active
callbacks, not sleeping/waiting runs.
Programmatic run management
All operations are signed and scoped to the client app:
const page = await client.searchRuns({ functionId: "recipe-import@v1", status: "running" });
const run = await client.getRun(page.runs[0].id);
await client.cancelRun(page.runs[0].id);
await client.rerun(page.runs[0].id, { idempotencyKey: "incident:42" });
await client.rerunFromStep(page.runs[0].id, "fetch", {
idempotencyKey: "incident:42:fetch",
});
const failures = await client.listFailures();
await client.retryFailure(failures[0].id); // explicit DLQ replayAlways persist or derive rerun idempotency keys; retrying the same action key returns the original target run.
Workflow versions and deployments
Existing runs call the latest deployed code for their registered runtime ID. Use explicit versions for incompatible workflow changes:
const v1 = createFunction({
id: "recipe-import", version: "v1", enabled: false,
trigger: { event: "recipe.import" },
handler: oldHandler,
});
const v2 = createFunction({
id: "recipe-import", version: "v2",
trigger: { event: "recipe.import" },
handler: newHandler,
});
await client.sync([v1, v2]);Only one version of a base ID may be enabled. Runs are pinned to IDs such as
recipe-import@v1; keep the old handler deployed until its active, sleeping,
waiting and retrying runs have drained. Treat a published version as immutable.
For compatible edits, preserve step IDs and deterministic control flow. Use
client.getVersionStatus("recipe-import@v1"); remove old code only when the
version is disabled and safeToRemove is true.
Timeouts, payloads and long work
Defaults:
- service API/callback request: 1 MiB;
- callback/manifest response: 1 MiB;
- app callback: 30 seconds unless overridden per function;
- manifest pull: 10 seconds;
- error
details: 64 KiB; - total memoized invocation state: below the API request limit.
The request limit covers the triggering event, rich failure context and all
memoized steps together. Keep normal state below roughly 750 KiB; store large
objects externally and checkpoint references. The service records state_bytes
and terminally fails with STATE_LIMIT_EXCEEDED instead of sending an oversized
next callback.
A timeout cannot forcibly stop JavaScript already executing on Vercel or another platform. A step that times out before its result is persisted can execute again. External side effects must therefore use provider idempotency keys. Durable provides at-least-once checkpoint execution, not transactional exactly-once delivery to third parties.
Never put a complete crawl, newsletter send or unbounded batch loop in one
step.run. Split it into bounded checkpoints that comfortably fit below both
the Durable callback timeout and the hosting platform limit:
for (const item of chunk) {
await step.run(`process-${item.id}`, () => processItem(item));
}For large collections, fan out stable-ID events and process bounded chunks so the cumulative memoized invocation payload also stays below the request limit.
Security
Every app→service body uses x-durable-app plus an HMAC-SHA256 signature. Every
service→app invocation and app response is signed with the workspace key.
Manifest pulls sign a five-minute timestamp. Keep signing keys and admin tokens
server-side; never expose them to browser code.
License
MIT
