Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TypeScript and JavaScript

The TypeScript SDK, orbit-lang, runs ORBIT task handlers on Node.js. It has no dependencies, is built on fetch and AbortController, ships type declarations, and works from both TypeScript and JavaScript. It handles registration, long polling, heartbeats, cancellation through an AbortSignal, result delivery through runtime outages, and graceful shutdown on SIGINT and SIGTERM. It also includes a small Client for starting workflows. Read How workers work first for the concepts this page relies on.

Install

npm install orbit-lang

The package requires Node.js 18 or later and is an ES module: load it with import, not require. Node.js 22.18 and later run .ts files directly, so you can run a TypeScript worker with node worker.ts without a build step.

A minimal worker

This worker implements text.tokenize, the TypeScript step of examples/polyglot.orbit:

task "text.tokenize" { version "1.0.0" input { text: String } output { tokens: List<String> } }

step tokenize { worker "text.tokenize" using typescript with { text: normalize.text } }
import { TaskError, Worker } from "orbit-lang";

const worker = new Worker({ workerId: "text-typescript" });

worker.task<{ text: string }, { tokens: string[] }>(
  "text.tokenize",
  { version: "1.0.0" },
  async (input, ctx) => {
    if (input.text.length === 0) {
      throw new TaskError("EMPTY_TEXT", "text must not be empty", { retryable: false });
    }
    ctx.log("tokenizing", { chars: input.text.length });
    return { tokens: input.text.split(" ").filter((t) => t.length > 0) };
  },
);

await worker.run();

Run it against a local runtime. The workflow has four steps in four languages, so start the other three example workers from the repository root as well:

orbit server &
node text_worker.ts &
PYTHONPATH=sdks/python python3 examples/workers/polyglot/text_worker.py &
(cd examples/workers/polyglot/go && go run .) &
cargo run -p orbit-worker --example text_worker &

orbit run examples/polyglot.orbit \
  --input '{"text": "The quick brown fox jumps over the lazy dog. The dog sleeps."}'

Registering tasks

worker.task<Input, Output>(name, { version }, handler);

task registers handler for name@version and returns the worker, so calls can be chained. The second argument is required; when version is omitted it defaults to "1.0.0". The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice throws. Register every task before calling run().

The type parameters describe the input and output for the compiler only. The SDK does not validate the input at run time; the runtime has already validated it against the contract.

Handlers and results

A handler receives the task input and a Context, and returns the output object, or a promise of it:

worker.task("inventory.reserve", { version: "1.0.0" }, async (input, ctx) => {
  return { reservation_id: `res-${input.order_id}` };
});
  • When the runtime sends no input, input is {}.
  • Returning undefined or null reports {}.
  • Returning a non-object or an array fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Raising task errors

Throw TaskError to fail an attempt with a structured error:

import { TaskError } from "orbit-lang";

throw new TaskError("CARD_DECLINED", "limit exceeded", {
  retryable: false,
  details: { limit: 5000 },
});
ArgumentTypeDefaultMeaning
codestringrequiredError code, matched exactly by the step’s non_retryable list.
messagestring""Human-readable message.
options.retryablebooleantruefalse fails the step immediately, whatever its retry policy.
options.detailsJsonundefinedStored with the error in the execution history.

Any other thrown value is reported as a retryable UNHANDLED_EXCEPTION. The message is <name>: <message> for an Error, and details.stack holds the first lines of the stack trace. The SDK also logs the error through logger.error.

Context

MemberTypeMeaning
tokenstringIdentifies this delivery. The SDK echoes it in the result.
executionIdstringThe workflow execution.
workflowstringWorkflow name.
workflowVersionstringWorkflow version.
stepIdstringThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
taskNamestringThe task name.
attemptnumber1-based attempt number. Redeliveries keep the number.
idempotencyKeystringStable across retries and redeliveries. Use it for side effects.
redeliverybooleantrue when the outcome of an earlier delivery of this attempt is unknown.
deadlineMsnumber | undefinedAbsolute deadline in Unix epoch milliseconds, when the step has a timeout.
signalAbortSignalAborted when the runtime asks the attempt to stop.
cancelledboolean (getter)true once signal is aborted.
remainingMs()number | undefinedMilliseconds left before deadlineMs, never negative.
log(message, fields?)voidWrites one JSON line to standard output with the execution, step, attempt and fields.

The context does not expose the contract version or timeout_ms.

Cancellation and timeouts

When an attempt times out, or its workflow is cancelled or fails, the runtime lists the attempt in the next heartbeat response and the SDK aborts ctx.signal. Its reason is a non-retryable TaskError with code CANCELLED. Pass the signal to APIs that accept one, or check it between units of work:

worker.task("reports.fetch", { version: "1.0.0" }, async (input, ctx) => {
  const res = await fetch(input.url, { signal: ctx.signal });
  return { status: res.status };
});

worker.task("reports.render", { version: "1.0.0" }, async (input, ctx) => {
  for (const section of input.sections) {
    ctx.signal.throwIfAborted();
    await renderSection(section);
  }
  return {};
});

When an aborted fetch rejects, it rejects with the signal’s reason, so the attempt is reported as CANCELLED. The SDK does not abort the signal when deadlineMs passes; only the runtime’s cancellation does. Use AbortSignal.any([ctx.signal, AbortSignal.timeout(ctx.remainingMs() ?? 30_000)]) to bound a call by the deadline as well.

Concurrency

concurrency (default 4) is the number of poll loops, and therefore the maximum number of tasks this worker runs at once. All loops share the Node.js event loop, so handlers should be asynchronous. A CPU-bound handler blocks every loop and the heartbeat; move that work to a worker_threads worker or run more processes.

Credentials and TLS

OptionEnvironmentDefaultMeaning
serverORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
tokenORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
clientCertORBIT_CLIENT_CERTnonePEM client certificate for mutual TLS.
clientKeyORBIT_CLIENT_KEYthe clientCert filePEM private key.

To trust a runtime whose certificate comes from a private CA, start Node.js with NODE_EXTRA_CA_CERTS=/path/to/ca.pem. The global fetch cannot present client certificates, so when clientCert is set and the server URL is https:, the SDK sends requests through node:https instead. On that path only, ORBIT_CA_CERT is read and replaces Node.js’s default CA list.

const worker = new Worker({
  workerId: "payments-1",
  server: "https://orbit.internal:7233",
  clientCert: "/etc/orbit/payments-1.pem",
  clientKey: "/etc/orbit/payments-1.key",
});
ORBIT_CA_CERT=/etc/orbit/ca.pem node worker.ts

With a per-worker identity from a workers file, set workerId to the identity’s id and the token to the identity’s token.

Logging

SDK messages go to the logger option (default console), which needs info, warn and error methods; messages are prefixed with [orbit]. Each task execution is logged as a JSON line through ctx.log, which always writes to console.log. To silence the SDK in tests, pass a logger with no-op methods.

Shutdown

await worker.run() resolves after worker.stop() is called or the process receives SIGINT or SIGTERM. Shutdown aborts the open long polls, waits for running handlers to finish and deliver their results, unregisters, removes the signal listeners, and resolves. Pass { handleSignals: false } to install your own signal handling and call stop() yourself.

Starting workflows

Client calls the runtime’s HTTP API. Starting executions requires the admin token or the token of a client identity granted start for the workflow. Pass it explicitly when the environment also holds a worker token; a client token is passed exactly like the admin token:

import { Client } from "orbit-lang";

const client = new Client({ token: process.env.ORBIT_ADMIN_TOKEN });
const id = await client.start("DocumentInsights", { text: "Hello world" }, { executionId: "doc-42" });
const result = await client.wait(id, 60_000);
console.log(result.status, result.output);

Signals and queries

signal sends a typed signal declared with signal <name> { ... } in the workflow, and query evaluates a declared query. When authentication is enabled, both need the admin token, or a client token granted signal or query for the execution’s workflow, and wait needs read. A refused request throws ApiError with status 403 and code FORBIDDEN.

import { ApiError, Client } from "orbit-lang";

const client = new Client({ token: process.env.ORBIT_ADMIN_TOKEN });

type Status = { needs_approval: boolean; approved: boolean | null; reviewer: string | null; charged: boolean };
const status = await client.query<Status>("order-A-100", "status");
console.log(status.approved); // null while the review is pending

const seq = await client.signal("order-A-100", "review", { approved: true, reviewer: "dana" });

try {
  await client.signal("order-A-100", "review", { approved: "yes", reviewer: "dana" });
} catch (e) {
  if (e instanceof ApiError) console.log(e.code); // SIGNAL_PAYLOAD_INVALID
}

signal resolves to the history sequence number of the recorded SignalReceived event. The runtime validates the payload and buffers the signal until an await step takes it. See Signals and queries.

API reference

Worker

new Worker(options?: WorkerOptions)

WorkerOptions extends ClientOptions:

OptionTypeDefaultMeaning
workerIdstringtypescript-<pid>Stable identity sent at registration.
concurrencynumber4Number of poll loops; values below 1 become 1.
runtimestring"typescript"Announced runtime. Set "javascript" for steps declared using javascript.
pollWaitMsnumber20000wait_ms of each long poll. The runtime caps it at 60000.
loggerPick<Console, "info" | "warn" | "error">consoleDestination of SDK messages.
MemberMeaning
task<I, O>(name, { version? }, handler): thisRegisters handler for name@version.
run({ handleSignals? }): Promise<void>Registers, polls and resolves after shutdown. handleSignals defaults to true. Rejects when no task is registered, or with ApiError when the runtime refuses registration; connection errors and 5xx responses are retried.
stop(): voidStarts a graceful shutdown.
workerId: stringThe resolved worker id.
client: ClientThe client used for every protocol call.

TaskError

new TaskError(code: string, message?: string, options?: { retryable?: boolean; details?: Json })

Read-only properties: code, message, retryable, details. name is "TaskError".

Context

See Context. The SDK creates it; you do not construct one.

Client

new Client(options?: ClientOptions)
OptionTypeDefaultMeaning
serverstringORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
tokenstringORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
clientCertstringORBIT_CLIENT_CERTPath of a PEM client certificate.
clientKeystringORBIT_CLIENT_KEY, then clientCertPath of the PEM private key.
MethodReturnsMeaning
request<T>(method, path, body?, signal?)Promise<{ status: number; body: T }>Sends a JSON request. body is null for an empty response. Throws ApiError for status 400 and above.
start(workflow, input = {}, { version?, executionId? })Promise<string>Starts an execution and returns its id.
wait(executionId, timeoutMs = 30000)Promise<any>Long-polls until the execution finishes or the timeout elapses, and returns its state.
signal(executionId, name, payload = {})Promise<number>Sends the signal name and resolves to the history sequence number of the recorded signal. Rejects with ApiError (UNKNOWN_SIGNAL, SIGNAL_PAYLOAD_INVALID, EXECUTION_FINISHED, NOT_FOUND).
query<T = any>(executionId, name)Promise<T>Evaluates the query name and resolves to its value. T is not checked at runtime. Rejects with ApiError (UNKNOWN_QUERY, QUERY_FAILED, NOT_FOUND).
serverstringThe resolved runtime URL.

ApiError

Thrown by Client for an error response or a connection failure. Properties: status (0 for a connection failure), code (the runtime’s error code, CONNECTION, or HTTP_ERROR), message, and the getter isConnectionError.

Other exports

ExportMeaning
SDK_VERSION"0.1.0".
JsonType of any JSON value.
TaskAssignmentType of the task message the runtime sends (protocol ExecuteTaskRequest).
Handler<I, O>(input: I, ctx: Context) => O | Promise<O>.
ClientOptions, WorkerOptionsOption types described above.

Testing

Keep handlers as named functions so that you can call them directly. A plain object can stand in for the context:

import { test } from "node:test";
import assert from "node:assert/strict";
import { tokenize } from "./handlers.ts";

test("tokenize", async () => {
  const ctx = { signal: new AbortController().signal, log() {} } as any;
  assert.deepEqual(await tokenize({ text: "a b" }, ctx), { tokens: ["a", "b"] });
});

For an integration test, start orbit server with a temporary database, deploy your workflow, call worker.run({ handleSignals: false }) without awaiting it, drive executions with Client, then call worker.stop() and await the promise from run(). A short pollWaitMs keeps polls responsive. The SDK’s own test, sdks/typescript/test/worker.test.ts, runs the worker against a scripted fake runtime built on node:http.

See also