Serving runners

connect, serve, and the framework adapters - the two transports that expose your workflows to the engine, plus defaults and error types.

A runner exposes your workflows to the engine over one of two transports. connect dials the engine over a WebSocket and needs no inbound URL - the simplest path, and the one to reach for first. serve runs an HTTP handler the engine calls, with a thin adapter for whichever framework you already use. See runners for which to pick.

Every endpoint has a local-dev default, so most calls are one line. Override any of them per call or through the matching env var; see Defaults.

connect

The outbound-WebSocket transport: the runner dials the engine and receives invokes over a persistent socket, so it needs no inbound URL, no framework, and no separate registration. connect handles the handshake, registers the manifest over the socket, answers invokes, and reconnects automatically.

import { connect } from "@durablex/sdk";

const handle = connect({ app: "order-app", workflows: [orderCreated] });
// later: handle.close();

Prop

Type

Prop

Type

Connect mode uses the runtime's global WebSocket (Bun, Deno, and Cloudflare Workers have it built in; Node needs 22+ or a polyfill). Set runner for a runner that should receive pinned runs; omit it for a stateless replica in an anycast pool.

serve

For an HTTP server or a serverless deploy, serve builds the invoke handler from your workflows. It returns a (req: Request) => Promise<Response> - a standard Fetch handler that runs on any Fetch-native runtime. Pass app (or any of engineUrl/url) and it registers itself on startup, so you do not call register separately; omit them all for a bare handler.

const invoke = serve({ app: "order-app", workflows: [orderCreated] });

Bun.serve({ port: 6773, routes: { "/invoke": invoke } });

Prop

Type

signingKey verifies the engine's HMAC-SHA256 invoke signature; it falls back to DURABLEX_SIGNING_KEY, omit both to skip verification in local dev.

Framework adapters

Rather than wire the handler into your framework by hand, import the adapter for it. Each takes the same options as serve (so it auto-registers the same way) and returns that framework's native handler. The route defaults to /invoke; mount it elsewhere and set url to match.

FrameworkImportMount
Bun@durablex/sdk/bunBun.serve({ routes: { "/invoke": serve(opts) } })
Hono@durablex/sdk/honoapp.post("/invoke", serve(opts))
Elysia@durablex/sdk/elysianew Elysia().post("/invoke", serve(opts))
Express@durablex/sdk/expressapp.post("/invoke", serve(opts))
Fastify@durablex/sdk/fastifyapp.register(serve(opts))
Next.js@durablex/sdk/nextexport const { POST } = serve(opts)
Node (http)@durablex/sdk/nodecreateServer(toNodeHandler(opts))

Mount adapters that read the raw request - Express, Fastify, Node - before any body parser, so the bytes the engine signed reach the handler intact (the Fastify adapter scopes its own raw-body parser, so that one is automatic). Deno and Cloudflare Workers are Fetch-native: use the serve handler directly as the runtime's fetch.

register

serve's auto-register covers most cases. Call register directly only to register a serve-mode runner yourself - for example to control exactly when it happens, or from a process that builds the handler elsewhere. It is idempotent; safe on every startup.

await register({ app: "order-app", url: "http://localhost:6773/invoke", workflows: [orderCreated] });

Prop

Type

apiKey authenticates the runner (Bearer token) and falls back to DURABLEX_API_KEY.

Defaults

Every endpoint resolves from the explicit option, then the env var, then a built-in local default - so you set DURABLEX_ENGINE_URL and DURABLEX_API_KEY once against Durablex Cloud and the rest stay one-liners.

OptionDefaultEnv var
engineUrlhttp://localhost:6770DURABLEX_ENGINE_URL
appdefaultDURABLEX_APP
url (runner address)http://localhost:6773/invokeDURABLEX_RUNNER_URL
route path/invokemount path (Fastify: path option)

The route path and the registered url must agree: if you mount the runner somewhere other than /invoke, set url to the full address the engine should call. The DEFAULT_* values are exported from @durablex/sdk if you need to reference them.

Errors

Throw these from inside a step to control retry behavior. Any other thrown error is retried per the workflow's retry policy.

NonRetriableError

Fails the run immediately, skipping any remaining attempts the retry policy would allow. Use it for failures retrying cannot fix - validation errors, missing config.

new NonRetriableError(message: string, options?: { cause?: unknown })

RetryAfterError

Retries the step after a specific delay instead of the policy's computed backoff. It does not grant extra attempts: once maxAttempts is reached the run fails as usual.

new RetryAfterError(message: string, retryAfter: string | number | Date, options?: { cause?: unknown })

retryAfter is a duration string ("30s"), a number of milliseconds, or an absolute Date. The resolved delay is exposed as retryAfterMs.

On this page