Serving runners

Host workflows in an HTTP service you already deploy: serve, register, the framework adapters, invoke signature verification, and the SDK error types.

serve builds an HTTP invoke handler from your workflows and Duraton POSTs invokes to it, so the runner needs a publicly reachable URL. Use it when your runner is already an HTTP service you deploy and expose; otherwise reach for connect, which needs no inbound URL.

import { serve } from "@duraton/sdk/bun";

const invoke = serve({
  url: process.env.DURATON_URL,     // your Duraton base URL, shown in the console
  apiKey: process.env.DURATON_API_KEY,
  signingKey: process.env.DURATON_SIGNING_KEY,
  app: "support-app",
  runnerUrl: "https://support.example.com/invoke",      // where Duraton POSTs invokes
  workflows: [ticketCreated],
});

Bun.serve({ port: Number(process.env.PORT), routes: { "/invoke": invoke } });

serve

Returns a (req: Request) => Promise<Response> - a standard Fetch handler that runs on any Fetch-native runtime. Passing any of url / app / runnerUrl turns on auto-registration: the handler registers itself on startup and re-registers on a heartbeat, so you never call register yourself. Pass none of the three for a bare handler that only answers invokes.

Prop

Type

An invoke body over 1 MiB is rejected with 413, matching Duraton's wire-message cap; a body that is not a valid invoke request is rejected with 400.

Invoke signatures

Duraton signs every invoke it POSTs. The signature rides the X-Duraton-Signature header as t=<unix-seconds>&s=<hex>, where s is an HMAC-SHA256 over the exact string `${t}.${rawBody}` keyed by your project's signing key. serve verifies it before parsing the body: it recomputes the HMAC, compares in constant time, and rejects the request with 401 when the signature is missing, malformed, mismatched, or its timestamp is more than 300 seconds from now.

const invoke = serve({
  signingKey: process.env.DURATON_SIGNING_KEY,   // the signing key from your project's settings
  app: "support-app",
  runnerUrl: "https://support.example.com/invoke",
  workflows: [ticketCreated],
});

A signing key is required: serve throws on startup unless signingKey or DURATON_SIGNING_KEY is set, since the signature check is the only thing that stops a forged invoke. Get the key from your project's settings in the Duraton console and set it on every runner you expose. To serve without a signature check on purpose - a private network, a local test - pass allowUnsignedInvokes: true instead of a key.

The runner never sends the key back to Duraton. At registration it reports only a one-way fingerprint of it, which Duraton compares against its own so the console can flag a runner whose key has drifted.

Signing key and rotation

Each project has its own signing key. Get it from the Duraton console, on your project's settings, and set it on every serve runner as DURATON_SIGNING_KEY (or pass it as the signingKey option):

export DURATON_SIGNING_KEY="..."

A runner started with connect needs no signing key - it authenticates with your API key when the WebSocket opens, so signing keys apply only to serve (inbound HTTP) runners.

Rotating the key is zero-downtime. When you rotate it in the console, Duraton issues a new key and keeps signing invokes with the previous key too for a 24-hour window. Update DURATON_SIGNING_KEY on your runners to the new value any time inside that window - both keys verify, so no invoke is rejected while you roll the new value out and redeploy at your own pace. Once the window closes, only the new key is accepted, and a runner still on the old key starts failing verification. So the rotation flow is: rotate the key in the console, then update DURATON_SIGNING_KEY on every serve runner within 24 hours.

Framework adapters

Each adapter 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 runnerUrl to match.

import { serve } from "@duraton/sdk/hono";

app.post("/invoke", serve({
  app: "support-app",
  runnerUrl: "https://support.example.com/invoke",
  workflows: [ticketCreated],
}));
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 the adapters that read the raw request - Express, Fastify, Node - before any body parser, so the bytes Duraton signed reach the handler intact and the HMAC still verifies. 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-registration covers most cases. Call register directly to register a serve-mode runner yourself - to control exactly when it happens, or from a process that builds the handler elsewhere. It is idempotent, so it is safe on every startup.

import { register } from "@duraton/sdk";

await register({
  url: process.env.DURATON_URL,
  apiKey: process.env.DURATON_API_KEY,
  app: "support-app",
  runnerUrl: "https://support.example.com/invoke",
  workflows: [ticketCreated],
});

Prop

Type

A failed registration throws - register does not swallow a non-2xx response.

Configuration

The runner reads these from its own environment. Each is an override of the matching option, so a process that sets them needs no options at all beyond workflows and app.

export DURATON_URL="https://run.duraton.dev"
export DURATON_API_KEY="dtn_live_..."
export DURATON_SIGNING_KEY="..."
export DURATON_RUNNER_URL="https://support.example.com/invoke"
Env varBacksDescription
DURATON_URLurlYour Duraton base URL, shown in the console.
DURATON_API_KEYapiKeyThe project API key the runner authenticates with.
DURATON_SIGNING_KEYsigningKeyThe key invoke signatures are verified against.
DURATON_RUNNER_URLrunnerUrlThe public URL Duraton POSTs invokes to.
DURATON_APPappThe app this runner registers under; "default" when unset.
DURATON_REGIONRunnerMeta.regionA region label reported at registration, shown on the runner in the console.

The route path and the registered runnerUrl must agree: mount the handler somewhere other than /invoke and runnerUrl must be the full address Duraton should call.

Middleware

middleware is a thin lifecycle hook that wraps every run of your handler. It has two optional hooks, and either transport - serve or connect - accepts the same object:

  • onInvoke runs before your handler and returns log bindings that are attached to every log line the run emits - a per-run child logger, without threading context through your code.
  • onResult runs after your handler settles and can transform what leaves the process: a sanitized error, a redacted result.
import { serve } from "@duraton/sdk/bun";
import type { Middleware } from "@duraton/sdk";

const middleware: Middleware = {
  onInvoke: (info) => ({ runId: info.runId, attempt: info.attempt }),
  onResult: (info, outcome) => {
    if (!outcome.ok) return outcome;   // leave failures alone
    return { ok: true, result: redact(outcome.result) };
  },
};

const invoke = serve({ app: "support-app", workflows: [ticketCreated], middleware });

onResult fires only on a terminal outcome - when your handler returns or throws - never while the run is still discovering steps. A durable handler re-runs top to bottom on every step it discovers, so a hook that fired mid-run would run its "final" transform many times per run. onInvoke runs on each of those passes (it only enriches log context, touching no durable state), so keep it cheap and side-effect-free.

InvokeInfo

Both hooks receive the same read-only description of the run.

Prop

Type

onResult and Outcome

onResult(info, outcome) receives an Outcome discriminating a handler that returned from one that threw, and returns a replacement Outcome to transform it - or nothing to leave it unchanged.

type Outcome =
  | { ok: true; result: unknown }
  | { ok: false; error: unknown };

type OnResult = (info: InvokeInfo, outcome: Outcome) => Outcome | void | Promise<Outcome | void>;

Returning the other variant crosses the outcome over: return { ok: false, error } from a success to fail a run that returned, or { ok: true, result } from a failure to swallow a handler error into a success. The common case is neither - you sanitize the error or redact the result and keep the same ok.

Built-in middlewares

Two ready-made hooks cover the common cases. A single Middleware object can carry both an onInvoke and an onResult, so compose them by spreading:

import { bindLogContext, sanitizeErrors } from "@duraton/sdk";

const middleware = {
  ...sanitizeErrors(),
  ...bindLogContext((info) => ({ runId: info.runId, attempt: info.attempt })),
};

connect({ app: "support-app", workflows: [ticketCreated], middleware });
  • sanitizeErrors() - an onResult that replaces any thrown error with one that keeps the message but drops the stack, so internal frames never leave the process.
  • bindLogContext(fn) - an onInvoke that attaches fn(info) to every log line the run emits. A per-call field on ctx.log wins over a binding of the same key.

Middleware is available in the TypeScript SDK today. Python and Go parity is on the roadmap.

Errors

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

NonRetriableError

Fails the run immediately, skipping any attempts the retry policy would still allow. Use it for failures a retry cannot fix - a validation error, a missing config value.

await ctx.step.run("validate", () => {
  if (!ticket.email) throw new NonRetriableError("ticket has no requester email");
  return ticket;
});
new NonRetriableError(message: string, options?: { cause?: unknown })

RetryAfterError

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

await ctx.step.run("call-vendor", async () => {
  const res = await fetch(vendorUrl);
  if (res.status === 429) throw new RetryAfterError("vendor rate limit", "30s");
  return res.json();
});
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.

PollTimeoutError

Thrown by step.poll when a resource does not become ready before its timeout. Unhandled, it fails the run like any terminal error; catch it to treat a missed deadline as a non-fatal branch.

try {
  await ctx.step.poll("provision", () => fetchRecordOrNull(), { every: "5s", timeout: "10m" });
} catch (err) {
  if (err instanceof PollTimeoutError) return { status: "still-provisioning" };
  throw err;
}

On this page