Serving runners

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

A runner exposes your workflows to Duraton over one of two transports. connect dials Duraton over a WebSocket and needs no inbound URL - the simplest path, and the one to reach for first. serve runs an HTTP handler Duraton 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 Duraton 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 "@duraton/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 Duraton's HMAC-SHA256 invoke signature; it falls back to DURATON_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@duraton/sdk/bunBun.serve({ routes: { "/invoke": serve(opts) } })
Hono@duraton/sdk/honoapp.post("/invoke", serve(opts))
Elysia@duraton/sdk/elysianew Elysia().post("/invoke", serve(opts))
Express@duraton/sdk/expressapp.post("/invoke", serve(opts))
Fastify@duraton/sdk/fastifyapp.register(serve(opts))
Next.js@duraton/sdk/nextexport const { POST } = serve(opts)
Node (http)@duraton/sdk/nodecreateServer(toNodeHandler(opts))

Mount adapters that read the raw request - Express, Fastify, Node - before any body parser, so the bytes Duraton 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 DURATON_API_KEY.

Defaults

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

OptionDefaultEnv var
engineUrlhttp://localhost:6770DURATON_ENGINE_URL
appdefaultDURATON_APP
url (runner address)http://localhost:6773/invokeDURATON_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 Duraton should call. The DEFAULT_* values are exported from @duraton/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