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