Node.js SDK for the Orch8 workflow engine.
npm install @orch8.io/sdkRequires Node.js 18+.
SDK version 0.7.x targets the Orch8 engine 0.7.x API (ORCH8_API_VERSION
1.0.0) and sequence schema version 1, including sagas,
conditional steps, filtered retries, output schemas, local-time delays, and
bounded loop history. Portable continuity APIs are available under
client.continuity.
import { Orch8Client } from "@orch8.io/sdk";
const client = new Orch8Client({
baseUrl: "https://api.orch8.io",
tenantId: "my-tenant",
});
const seq = await client.createSequence({
name: "my-sequence",
namespace: "default",
blocks: [],
});
const inst = await client.createInstance({
sequence_id: seq.id,
context: { user_id: "123" },
});The exported workflow() builder covers every Orch8 block and validates the
result with the SDK's Zod contract before it is sent. Supply a handler map to
make handler parameters type-safe:
import { workflow } from "@orch8.io/sdk";
type Handlers = {
"send-email": { to: string; subject: string };
charge: { customerId: string; cents: number };
};
const checkout = workflow<Handlers>("checkout")
.step("charge", "charge", { customerId: "cus_123", cents: 2500 }, {
retry: { max_attempts: 3, initial_backoff: 500, max_backoff: 10_000 },
})
.step("receipt", "send-email", { to: "buyer@example.com", subject: "Receipt" })
.build();
await client.createSequence(checkout);Nested callbacks retain the same handler map, so steps inside parallel, router, loop, saga, and A/B blocks are checked too. Omit the generic when integrating a dynamic handler registry.
Framework runners can be exposed as durable workers without adding the
framework as an SDK dependency: durableAgentHandler(graph) supports the
structural ainvoke/invoke (LangGraph), kickoff (CrewAI), and run
(AutoGen/custom) contracts and pins thread identity to the Orch8 instance.
Request observers and cursor-preserving pagination use the same transport:
const client = new Orch8Client({
baseUrl: "https://api.orch8.io",
onResponse: ({ method, path, status, durationMs }) =>
metrics.timing("orch8.request", durationMs, { method, path, status }),
});
const page = await client.requestPage<TaskInstance>("/instances", { limit: "50" });Resource IDs are encoded as single path segments. Resumable SSE consumers can retain event IDs and provide them on a later connection:
for await (const event of client.streamInstanceEvents(instanceId, {
lastEventId: savedCursor,
})) {
savedCursor = event.id;
consume(event.data);
}ORCH8_ROUTES and ORCH8_API_VERSION are generated from the engine OpenAPI
contract. Worker defaults are aligned across Node, Python, and Go; use
worker.stats() for a portable capacity snapshot.
For newly introduced or experimental engine routes, use the authenticated low-level client without losing tenant headers:
const engineInfo = await client.request("GET", "/info");Safe requests retry transient 408, 425, 429, and 5xx responses up to
three times and each attempt times out after 30 seconds. Refresh short-lived
credentials per attempt with getHeaders; use retry: false to opt out.
const client = new Orch8Client({
baseUrl: "https://api.orch8.io",
getHeaders: async () => ({ Authorization: `Bearer ${await getToken()}` }),
retry: { maxAttempts: 3, baseDelayMs: 250 },
timeoutMs: 30_000,
});client.jobs wraps the engine's background-jobs API (/jobs). A job is a
single durable step dispatched to a worker handler, with retries, delays,
priorities, and idempotent enqueue:
const job = await client.jobs.enqueue(
"send-email",
{ to: "buyer@example.com", template: "welcome" },
{
queue: "emails",
delayMs: 60_000, // or runAt: new Date(...)
retry: { max_attempts: 5, initial_backoff_ms: 1_000, max_backoff_ms: 60_000 },
idempotencyKey: `welcome:${userId}`, // re-enqueue returns the same job
},
);
const done = await client.jobs.waitFor(job.id, { timeoutMs: 120_000 });
if (done.status !== "completed") console.error(done.error);
for await (const page of client.jobs.list({ status: "dead_lettered", limit: 100 })) {
for (const j of page.items) await client.jobs.cancel(j.id);
}list() yields pages and follows next_cursor; listAll() yields individual
jobs. waitFor() resolves on any terminal status (completed, failed,
cancelled, dead_lettered) and throws JobWaitTimeoutError on timeout.
Jobs need no special worker: the job's step is an ordinary worker task for
handler, so an existing Orch8Worker that registers "send-email" runs it,
receiving the job payload as task.params:
new Orch8Worker({
client,
workerId: "emails-1",
handlers: { "send-email": async (task) => sendEmail(task.params) },
});The
/jobsroutes require an engine release that ships the background-jobs API; older engines return 404.
Run a polling worker using x-api-key and x-tenant-id authentication:
import { Orch8Client, Orch8Worker } from "@orch8.io/sdk";
const client = new Orch8Client({
baseUrl: process.env.ORCH8_ENGINE_URL ?? "http://localhost:8080",
tenantId: process.env.ORCH8_TENANT_ID,
headers: { "x-api-key": process.env.ORCH8_API_KEY ?? "" },
});
const worker = new Orch8Worker({
client,
workerId: "worker-1",
handlers: {
"inspect-document": async (task) => {
// Replace with your bounded task implementation.
return { inspected: true, input: task.params };
},
},
maxConcurrent: 10,
});
await worker.start(); // Starts polling and returns immediately.
process.once("SIGTERM", () => { void worker.stop(); });
process.once("SIGINT", () => { void worker.stop(); });The worker echoes each task's claim_epoch on heartbeat, completion, and failure.
It respects the server's minimum poll delay and uses a heartbeat interval no
longer than the advertised interval or half the lease duration. Completion
callbacks run only after a successful acknowledgement; rejected or ambiguous
acknowledgements are left for lease recovery, without sending a contradictory
failure request.
client.pollTasks() and client.pollTasksFromQueue() still return task arrays.
Use client.pollTaskBatch() or client.pollTaskBatchFromQueue() to access
tasks, lease_secs, heartbeat_interval_secs, and poll_after_ms when writing
your own loop. Legacy array responses are accepted at the client boundary, with
no timing hints. Epoch fields remain optional in the types for legacy servers;
always echo the epoch supplied by a current server in custom worker loops.
A lease does not authorize offline execution. Handlers are not forcibly cancelled
on lease loss or timeout; use bounded work and provider idempotency keys for
external effects. stop() waits up to 30 seconds for executing handlers.
Handlers receive a second argument with the task's lease facts. effectId is
the server's deterministic idempotency key for the step's effect; pass it to
downstream APIs. All three are null against servers that predate them:
handlers: {
"charge-card": async (task, { effectId, leaseSecs, continuityEpoch }) =>
payments.charge(task.params, { idempotencyKey: effectId ?? task.id }),
},Steps can require a runtime with params.$runtime (kinds, a specific
runtime_id, hardware, regions, plugins, trust). Such tasks are only handed
to workers that advertise matching capabilities, so pass capabilities to
claim them. The worker sends a fresh advertisement with every poll, bound to
its workerId and valid for at most five minutes:
new Orch8Worker({
client,
workerId: "gpu-box-1",
handlers,
capabilities: { kind: "desktop", hardware: ["cuda"], regions: ["norway"] },
});kind defaults to server and trust to registered; handlers defaults to
the configured handler names. Tasks claimed while stop() is in progress, or
beyond free capacity, are released at once (POST /workers/tasks/{id}/release
with started: false) instead of waiting for lease expiry; servers without
that endpoint fall back to lease expiry.
Run step handlers inside a web page — steps that need a human, the DOM, or data
the page already has. The browser entry has no Node built-ins (CI bundles it
with esbuild for platform: "browser" and fails on any node: import).
The browser never holds an API key or receives secrets. Your backend mints
a short-lived token scoped to kind=browser, one runtime id and a handler
allowlist; it only works on the worker task endpoints. The engine never
dispatches credential-bearing steps to browser runtimes and filters the
instance context it sends them. Handlers may return page data (DOM, form
values, user input, the page's own fetched resources) as step output; output
is capped at 1 MiB of JSON and treated as untrusted input downstream.
Backend (Node, operator/admin key):
import { Orch8Client } from "@orch8.io/sdk";
const orch8 = new Orch8Client({ baseUrl, headers: { "x-api-key": process.env.ORCH8_API_KEY! } });
app.post("/api/orch8/browser-session", requireLogin, async (req, res) => {
res.json(await orch8.createBrowserSession({
runtimeId: `browser-${req.user.id}`, // stable id = per-user mailbox for targeted steps
handlers: ["confirm_shipping"],
ttlSecs: 900, // server default 900, max 3600
}));
});Page:
import { BrowserWorker, readForm } from "@orch8.io/sdk/browser";
const worker = new BrowserWorker({
baseUrl: "https://orch8.example.com", // add your origin to ORCH8_CORS_ORIGINS
getToken: () => fetch("/api/orch8/browser-session", { method: "POST" }).then((r) => r.json()),
});
worker.register("confirm_shipping", async (input, ctx) => {
const values = await askUser(input, { signal: ctx.signal }); // abort on lease loss / tab hidden
return { shipping: values, idempotencyKey: ctx.effectId };
});
await worker.start();Place the step on a browser in the workflow:
workflow("fulfilment")
.step("confirm", "confirm_shipping", { orderId: "o-1" }, {
runtime: { runtime_kinds: ["browser"], requires_human_ui: true }, // or runtime_id: "browser-u1"
});How it behaves:
- Lease protocol. Polls each handler with
capabilities { kind: "browser", runtime_id, handlers, expires_at }(≤ 5 minutes, never past the token expiry), heartbeats everylease_secs / 3(default lease 30 s), then completes or fails echoingclaim_epoch. A 404/409 heartbeat abortsctx.signalwithLeaseLostErrorand the result is never acknowledged.ctx.heartbeat()extends the lease on demand. - Page lifecycle. On
pagehideandvisibilitychange → hidden(configure withreleaseOn) every in-flight task is released viaPOST /workers/tasks/{id}/releaseusingfetch(..., { keepalive: true })withstarted: true, handlers seeTaskReleasedErroronctx.signal, and polling pauses until the page is visible again (or restored from the back/forward cache). Claims the page never started are released withstarted: false. Long interactive steps may preferreleaseOn: ["pagehide"]. - Tokens.
getTokenruns at start andrefreshMarginSecs(60) before expiry, and again on a 401/403. If the token lapses without a successful refresh, the worker stops and emits{ type: "stopped", reason: "token_expired" }. - Background tabs. The poll/heartbeat loop runs in a dedicated Web Worker (started from a Blob URL;
worker timers are throttled far less than a hidden tab's), while handlers run on the main thread with DOM
access, bridged by
postMessage. Without Worker support, or under a CSP withoutworker-src blob:, it falls back to the main thread (mode: "main"forces that;mode: "worker"refuses to fall back). - Resilience and privacy. Poll failures back off exponentially with jitter (
pollIntervalMs1 s up tomaxBackoffMs30 s).onEventreceives lifecycle events with ids only; task input and output are never logged. - Page-data helpers.
readForm(selectorOrForm)(never reads password or file inputs, and hidden inputs only withincludeHidden),readSelection(),querySelectorText(),querySelectorAllText(), andassertOutputSize()/OutputTooLargeErrorfor the 1 MiB output guard (maxOutputBytesto change it).
A complete example (HTML page, handler and backend) lives in
examples/browser-worker/; the end-to-end guide to
running steps in a user's tab is docs/browser-executor.md.
Mobile apps running the embedded Orch8 engine as a runtime node (Swift,
React Native, Expo, KMP, Flutter) must never ship an operator or any stored
API key — anyone can extract it from the app binary. Your backend holds the
operator key and mints a short-lived device session (dst_…) for one
device and the phone's persisted nodeRuntimeId; the app fetches it through
the mobile SDK's token provider, which calls again whenever the session
expires (on a 401).
import { Orch8Client } from "@orch8.io/sdk";
const orch8 = new Orch8Client({ baseUrl, headers: { "x-api-key": process.env.ORCH8_OPERATOR_KEY! } });
app.post("/api/orch8/device-session", requireLogin, async (req, res) => {
// deviceId / nodeRuntimeId come from the app; authorize that this user owns the device.
const session = await orch8.createDeviceSession({
deviceId: req.body.deviceId,
runtimeId: req.body.nodeRuntimeId,
handlers: ["scan_document"], // what the phone may claim; [] = delegate only
ttlSecs: 3600, // default 3600, max 86400
});
res.json({ token: session.token }); // only the token goes to the app
});The token reaches only the device's own mobile register / sync / runtime
routes, the lease protocol as its runtime (allowlisted handlers), and the
delegation calls for executions it owns; everything else is 403.
orch8-typegen turns a sequence file's JSON Schema contracts into TypeScript:
<Name>Input from input_schema, <Name><Step>Output for every step
output_schema (including steps nested in parallel, loop, router, saga, ...
blocks), a <Name>StepOutputs map, a <Name>StepId union, and a
<Name>Handlers map from handler name to step ids. Output is deterministic:
no timestamps, sorted keys, stable names, so it can be committed and checked in
CI.
npx orch8-typegen sequences/checkout.json --out src/generated/checkout.ts
npx orch8-typegen sequences/checkout.json --out src/generated/checkout.ts --check # CI: exit 1 on driftimport type { CheckoutFlowInput, CheckoutFlowChargeOutput } from "./generated/checkout.js";
const handlers = {
"payments.charge": async (task): Promise<CheckoutFlowChargeOutput> => { /* ... */ },
};
await client.createInstance({ sequence_id, context: { data: input satisfies CheckoutFlowInput } });With --remote, the engine's typed-dataflow compiler does the work instead
(POST /sequences/dataflow for a file, GET /sequences/{id}/dataflow with
--id). Findings are printed, the command exits 1 when any finding is an
error, and the engine's orch8-dataflow-v2 TypeScript bindings are written.
It reads ORCH8_URL, ORCH8_API_KEY, and ORCH8_TENANT_ID. The same
functions are importable from @orch8.io/sdk/typegen.
A queue switched to push mode (POST /queues/dispatch with
{ tenant_id, queue_name, mode: "push", push_url, secret }) makes the engine
POST a task envelope to push_url when a task is enqueued. The engine signs it
with X-Orch8-Timestamp and X-Orch8-Signature: sha256=<hex HMAC-SHA256(secret, "{ts}.{raw body}")>, and retries the delivery up to three times on a non-2xx
response.
The envelope is a wake-up, not a lease: the task is still pending and has no
claim_epoch. The helpers in @orch8.io/sdk/push therefore:
- verify the signature in constant time and reject timestamps more than 300 s
off (
toleranceSeconds) with401; - claim from the envelope's queue via
POST /workers/tasks/poll/queue(claimLimit, default 1; the claimed task may be an older pending task on the same queue); - run your handler and
complete/failwith the claim epoch, likeOrch8Workerdoes.
Handler failures are reported to the engine and answered with 200; a failed
claim poll answers 502 so the engine retries the push.
// app/api/orch8/route.ts — Next.js App Router
import { Orch8Client } from "@orch8.io/sdk";
import { createNextPushRoute } from "@orch8.io/sdk/push";
const client = new Orch8Client({
baseUrl: process.env.ORCH8_URL!,
tenantId: process.env.ORCH8_TENANT_ID,
headers: { "x-api-key": process.env.ORCH8_API_KEY! },
});
export const { POST } = createNextPushRoute({
client,
secret: process.env.ORCH8_PUSH_SECRET!,
handlers: { "send-email": async (task) => sendEmail(task.params) },
});Other runtimes use the same options:
import {
createWebPushHandler, // (Request) => Promise<Response>: Vercel, Deno, Bun
createLambdaPushHandler, // API Gateway REST/HTTP API, Lambda Function URLs
createCloudflarePushHandler, // { fetch(request, env) }, options may be built from env
} from "@orch8.io/sdk/push";
export const handler = createLambdaPushHandler({ client, secret, handlers });
export default createCloudflarePushHandler((env: Env) => ({
client: new Orch8Client({ baseUrl: env.ORCH8_URL, headers: { "x-api-key": env.ORCH8_API_KEY } }),
secret: env.ORCH8_PUSH_SECRET,
handlers,
}));verifyPushSignature(rawBody, headers, secret) is exported for custom
receivers. Signature checks need the raw body bytes; do not re-serialize parsed
JSON. Keep handlers well inside your platform's function timeout.
Push dispatch wakes a function per task. Serverless executors go the other
way: a scheduled or on-demand invocation pulls a bounded batch of tasks,
runs them inside the time it has left, and leaves no lease behind. They use
the regular worker lease API (poll → heartbeat → complete | fail |
release, echoing claim_epoch); no extra engine endpoints are involved.
// AWS Lambda, triggered by an EventBridge schedule (the event is ignored)
import { Orch8Client, createLambdaExecutor } from "@orch8.io/sdk/serverless";
export const handler = createLambdaExecutor(() => ({
client: new Orch8Client({ baseUrl: process.env.ORCH8_URL!, headers: { "x-api-key": process.env.ORCH8_API_KEY! } }),
handlers: { "thumbnail.render": async (task, ctx) => render(task.params, { signal: ctx.signal }) },
maxTasks: 5, // N tasks claimed per invocation
safetyMarginMs: 3000, // kept free before the Lambda timeout
}));// Cloudflare Workers: Cron Trigger + authenticated POST, fetch only (no nodejs_compat)
import { Orch8Client, createCloudflareExecutor } from "@orch8.io/sdk/serverless";
export default createCloudflareExecutor((env: Env) => ({
client: new Orch8Client({ baseUrl: env.ORCH8_URL, headers: { "x-api-key": env.ORCH8_API_KEY } }),
handlers: { "lead.enrich": async (task) => enrich(task.params) },
budgetMs: 25_000, // Workers have no remaining-time API
triggerSecret: env.ORCH8_TRIGGER_SECRET, // fetch trigger answers 404 without it
}));Both adapters call drainOnce({ client, handlers, deadlineMs, ... }), which
you can use directly from any short-lived process (CronJob, CI, Vercel cron).
deadlineMs is an absolute epoch-ms deadline by which every lease must be
settled. Each drain:
- polls every handler (or
queueName) withlimitup tomaxTasks(default 10) and optionalcapabilities(Lambda advertiseskind: server, Cloudflarekind: edgeby default), in rounds until the queue is empty,maxTasksis reached, or less thanminTaskBudgetMsremains; - runs claimed tasks concurrently, heartbeating at the shorter of
heartbeatIntervalMsand half the lease; - completes or fails each one like
Orch8Worker(thrown errors are retryable unlesserr.retryable === false;timeout_msis enforced); - at
deadlineMs - releaseMarginMs(default 1 s before), abortsctx.signaland releases every still-running task withstarted: true, so the engine re-dispatches it (and marks a side-effecting step's receipt Unknown) instead of waiting for the lease to expire. A claim whosetimeout_mscannot fit the remaining budget is released withstarted: falsewithout running. A result that arrives after its release is never acknowledged.
Handlers are plain Orch8Worker handlers; the context additionally has
signal, deadlineMs, remainingMs() and heartbeat(). The returned
DrainResult lists every task's outcome (completed, failed, released,
released_unstarted, lease_lost, ack_rejected) and why the drain stopped
(empty, deadline, max_tasks, poll_error); the Cloudflare fetch trigger
returns it as JSON (502 on poll_error).
The @orch8.io/sdk/serverless module graph has no Node built-ins and
re-exports Orch8Client; import from it (not the root entry) on edge runtimes.
Handlers still must be idempotent: a released task runs again elsewhere. Full
examples: examples/serverless-lambda/ (with a
SAM template), examples/serverless-cloudflare/
(with wrangler.toml) and examples/serverless-drain/.
Each integration is a separate subpath with its framework as an optional peer dependency, so unused integrations never load.
import express from "express";
import { orch8Express, getOrch8 } from "@orch8.io/sdk/express";
const app = express();
app.use(orch8Express({
client, // attached as req.orch8
push: { // optional push receiver
path: "/orch8/push", // default
secret: process.env.ORCH8_PUSH_SECRET!,
handlers: { "send-email": async (task) => sendEmail(task.params) },
},
}));
app.use(express.json()); // after orch8Express, or:
// app.use(express.json({ verify: orch8RawBody })) before it
app.post("/signup", async (req, res) => {
const job = await getOrch8(req).jobs.enqueue("send-email", { to: req.body.email });
res.json({ job: job.id });
});import { Injectable, Module } from "@nestjs/common";
import { Orch8Client, type WorkerTask } from "@orch8.io/sdk";
import { Orch8Handler, Orch8Module } from "@orch8.io/sdk/nestjs";
@Injectable()
export class EmailHandlers {
@Orch8Handler("send-email")
async send(task: WorkerTask) {
return { sent: true };
}
}
@Injectable()
export class SignupService {
constructor(private readonly orch8: Orch8Client) {} // or @Inject(ORCH8_CLIENT)
}
@Module({
imports: [
Orch8Module.forRoot({
client: { baseUrl: process.env.ORCH8_URL!, tenantId: "acme" },
worker: { workerId: `api-${process.pid}` }, // optional: poll with discovered handlers
}),
],
providers: [EmailHandlers, SignupService],
})
export class AppModule {}forRoot registers the module globally, discovers @Orch8Handler methods on
providers and controllers, starts an Orch8Worker on bootstrap when worker
is set, and stops it on shutdown (enable app.enableShutdownHooks()). Inject
Orch8HandlerRegistry to read the handler map or call
registry.createPushHandler({ secret }) from a controller for push dispatch.
Following the engine's framework-adapter pattern, tool calls go behind
idempotent Orch8 steps and agent loops checkpoint at turn boundaries. Each
wrapped tool call becomes a background job whose idempotency key is
${scope}:${toolName}:${toolCallId}: a retried or replayed turn with the same
call id returns the recorded result instead of charging the card twice. The
original tools run on an ordinary worker. The adapters rely on the /jobs API
(see Background jobs).
// Vercel AI SDK
import { generateText, tool } from "ai";
import { durableTools, aiSdkToolHandlers, checkpointSteps, TurnCheckpointer } from "@orch8.io/sdk/ai-sdk";
const tools = { chargeCard: tool({ description: "...", inputSchema, execute: charge }) };
// Worker process: executes the real tools (handler names "ai-tool.<name>").
new Orch8Worker({ client, workerId: "tools-1", handlers: aiSdkToolHandlers(tools) });
// Agent process (e.g. inside an "agent-turn" worker handler):
async function agentTurn(task: WorkerTask) {
const checkpointer = new TurnCheckpointer({ client, task, workerId: "agent-1" });
const previous = checkpointer.resume(); // state from a crashed attempt, if any
return generateText({
model,
tools: durableTools(tools, { client, scope: task.instance_id }),
messages: previous?.messages ?? initialMessages,
onStepFinish: checkpointSteps(checkpointer), // checkpoint every model step
});
}// OpenAI Agents SDK (JS)
import { Agent, run, RunState } from "@openai/agents";
import { durableAgentTools, agentToolHandlers, checkpointRunState, TurnCheckpointer } from "@orch8.io/sdk/openai-agents";
const agent = new Agent({ name: "support", tools: durableAgentTools([lookupOrder, refund], { client, scope: threadId }) });
const handlers = agentToolHandlers([lookupOrder, refund]); // register on a worker
const checkpointer = new TurnCheckpointer({ client, task, workerId });
const saved = checkpointer.resume();
const input = saved ? await RunState.fromString(agent, saved.state) : userMessage;
const result = await run(agent, input);
await checkpointRunState(checkpointer, result); // one checkpoint per turnHosted tools and handoffs pass through unchanged. A call that ends failed,
cancelled, or dead_lettered throws DurableToolError into the agent loop.
Checkpoints use the worker heartbeat checkpoint API (compare-and-swap on
checkpoint_seq, 256 KiB limit); pass a select function to
checkpointSteps to store less than the full message list.
@orch8.io/sdk/testing provides two in-process environments.
FakeOrch8Server is a pure-TypeScript engine double for unit-testing
workers with Vitest or Jest. It implements the worker protocol (poll, queue
poll, complete, fail, heartbeat and checkpoint compare-and-swap, claim epochs,
leases), instance CRUD/signals/outputs, and the jobs API on a virtual clock.
runUntilIdle() skips time over delays and retry backoffs:
import { describe, expect, it } from "vitest";
import { Orch8Worker } from "@orch8.io/sdk";
import { FakeOrch8Server } from "@orch8.io/sdk/testing";
it("sends the welcome email after an hour", async () => {
const engine = new FakeOrch8Server();
const client = engine.client(); // Orch8Client backed by the fake, no network
const job = await client.jobs.enqueue("send-email", { to: "a@b.c" }, { delayMs: 3_600_000 });
await engine.runUntilIdle({ "send-email": async (task) => ({ sent: true }) });
expect((await client.jobs.get(job.id)).status).toBe("completed");
});
it("runs a real worker against enqueued tasks", async () => {
const engine = new FakeOrch8Server();
const task = engine.enqueueTask({ handler_name: "greet", params: { name: "Ada" } });
const worker = new Orch8Worker({
client: engine.client(),
workerId: "w-1",
pollIntervalMs: 5,
handlers: { greet: async (t) => ({ hi: t.params }) },
});
await worker.start();
expect((await engine.waitForTask(task.id)).state).toBe("completed");
await worker.stop();
});engine.advanceTime(ms) moves the clock by hand (expiring leases whose
heartbeats lapsed), engine.requests records every call, and
engine.listen() serves the same fake over real HTTP for code that cannot take
an injected client. Every Orch8Client also accepts a fetch option.
NativeTestEnvironment runs whole sequences in the engine's Rust core via
the optional peer dependency @orch8/engine-native (napi bindings from the
engine repo, packages/node-native). Delays and backoffs run on virtual time:
import { workflow } from "@orch8.io/sdk";
import { createNativeTestEnvironment } from "@orch8.io/sdk/testing";
const env = await createNativeTestEnvironment(); // throws a clear error if not installed
const seq = workflow("reminder").delay({ duration: 3 * 86_400_000 }).build();
const result = await env.run(seq, { user_id: "u1" });
expect(result.state).toBe("completed");The native runner executes built-in handlers in dry-run mode and auto-approves
human steps. It does not call your external worker handlers; a run that needs
one (or a signal) returns with state: "waiting". Use FakeOrch8Server for
worker logic.
import { Orch8Error } from "@orch8.io/sdk";
try {
await client.getInstance("non-existent");
} catch (err) {
if (err instanceof Orch8Error) {
console.error(`API error ${err.status} on ${err.path}`);
}
}npm install
npm run build
npm test
npm run typecheck
npm run check:browser # browser bundle has no node: imports; lease worker source is fresh
npm run generate:lease-worker # after editing src/browser/engine.ts