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.
| Framework | Import | Mount |
|---|---|---|
| Bun | @durablex/sdk/bun | Bun.serve({ routes: { "/invoke": serve(opts) } }) |
| Hono | @durablex/sdk/hono | app.post("/invoke", serve(opts)) |
| Elysia | @durablex/sdk/elysia | new Elysia().post("/invoke", serve(opts)) |
| Express | @durablex/sdk/express | app.post("/invoke", serve(opts)) |
| Fastify | @durablex/sdk/fastify | app.register(serve(opts)) |
| Next.js | @durablex/sdk/next | export const { POST } = serve(opts) |
| Node (http) | @durablex/sdk/node | createServer(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.
| Option | Default | Env var |
|---|---|---|
engineUrl | http://localhost:6770 | DURABLEX_ENGINE_URL |
app | default | DURABLEX_APP |
url (runner address) | http://localhost:6773/invoke | DURABLEX_RUNNER_URL |
| route path | /invoke | mount 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.