Skip to content
emitDocs

SDKs and CLI

TypeScript SDK

Create jobs, run workers, define typed work and write workflows from TypeScript, in Node.js, Bun, Deno or the browser.

@emit.run/sdk has no dependencies. It uses web-standard APIs (fetch, streams, Web Crypto), so it runs in Node.js 20.3 or later, Bun, Deno and the browser. Workers that run workflows need Node.js 20.16 or later, or Bun.

Shell
npm install @emit.run/sdk

This page covers version 0.2.0.

The client

TypeScript
import { Emit } from "@emit.run/sdk";

const emit = new Emit({
  url: process.env.EMIT_URL!,     // your cluster's endpoint, from its Overview page
  token: process.env.EMIT_TOKEN!, // an API token, from its Tokens tab
  space: "orders",                // the space this client works in
});

const billing = emit.space("billing"); // the same client, another space

token and space are required, and a variable that might be undefined doesn't compile. Pass null to leave one out on purpose:

  • token: null: no API token. The client can only follow jobs from a browser.
  • space: null: no space. The client only has admin, for managing spaces and tokens.

Jobs

TypeScript
const { id, outcome } = await emit.jobs.create("resize-image", { imageId: "img_881", width: 800 }, { key: "resize-img_881-800" });

await emit.jobs.createMany([
  { name: "resize-image", payload: { imageId: "img_881", width: 1600 } },
  { name: "resize-image", payload: { imageId: "img_882", width: 800 } },
]);

const job = await emit.jobs.get(id);                    // status, payload, result, progress, events
const page = await emit.jobs.list({ name: "resize-image", status: "failed", limit: 50 });
const next = await emit.jobs.list({ page: page.nextPage });
const lines = await emit.jobs.lines(id, { minLevel: "warn" });
await emit.jobs.cancel(id);                             // needs the admin scope

create takes the options in Creating a job: key, delayMs, runAtMs, maxAttempts, timeoutMs, backoffMs, backoff and callback. createMany sends every job in one request and returns the answers in the same order.

Waiting for a result

TypeScript
import { JobFailed } from "@emit.run/sdk";

try {
  const result = await emit.jobs.result(id);
} catch (error) {
  if (error instanceof JobFailed) console.error(`job ${error.id} ${error.status}`, error.result);
  else throw error;
}

result follows the job until it ends and returns its result. It throws JobFailed if the job failed or was cancelled, with the error in result. If the connection drops, it reconnects.

Following a job

TypeScript
for await (const event of emit.jobs.follow(id, { lines: true })) {
  if (event.progress) console.log(`${event.progress.percent}% ${event.progress.message}`);
  if (event.finished) console.log(event.finished.status, event.finished.result);
}

The first event is now, the job as it is. Then one arrives per change (claimed, progress, event, lines, retrying, released), and finished last. Pass signal, an AbortSignal, to stop early. emit.jobs.followToken(id, { expiresIn: "1h" }) makes a token that lets a browser follow the job.

Workers

TypeScript
import { Release, RetryAfter, Unrecoverable } from "@emit.run/sdk";

type Resize = { imageId: string; width: number };

const worker = emit.worker<Resize>({ names: ["resize-image"], concurrency: 8 }, async (job) => {
  if (job.payload.width > 10_000) throw new Unrecoverable("wider than 10,000 pixels");

  await job.progress(10, "downloading");
  await job.log("info", "downloaded", { metadata: { bytes: 52_000 } });
  await job.event({ stage: "downloaded" });
  // ...resize, passing job.signal to anything that takes one...
  return { url: `https://images.example.com/${job.payload.imageId}-${job.payload.width}.webp` };
});

process.once("SIGTERM", () => worker.stop());
The handler The job
returns a value completes, with that value as its result
throws an error fails this attempt, and is retried while attempts are left
throws new Unrecoverable(message) fails for good
throws new RetryAfter(message, ms) fails this attempt, and is retried ms from now (at most a day)
throws new Release() goes back to the queue at once; the attempt isn't counted

job has id, name, payload, attempt (1 for the first try), signal, and progress(percent, message?), log(level, message, extra?), event(data) and note(text). job.signal aborts when the worker loses the job: stop, because nothing more from this attempt is accepted.

A log or progress message, or a note, is at most 4,032 bytes, and an event 4 KiB as JSON: longer throws at the call. An error message is cut to 4 KiB. A result is at most 4 MiB as JSON; a larger one fails the attempt.

Options

namesstring[]

The job names to claim, at most 32. Leave it out to claim any job in the space.

concurrencynumberdefault: 1

How many jobs the worker holds at once.

leaseMsnumber

The lease to ask for. By default, each job's own timeout. The worker renews it while the handler runs.

waitMsnumberdefault: 30000

How long one claim waits for work before asking again.

retry{ attempts, backoffMs }default: { attempts: 3, backoffMs: 250 }

How a report (progress, a log line, an event) that fails on the way, from a network error, a 5xx or a 429, is retried before the handler sees the error. The wait doubles each time. The final report of a job is retried until its lease ends.

onError(error) => voiddefault: console.error

Told of what the worker can't handle itself: a final report that never landed, or the loop ending on an error.

await worker.stop() stops claiming, lets the jobs in hand finish, and then throws whatever ended the loop, if anything did. worker.done settles when the loop ends: after stop(), or with the EmitError that ended it, such as a revoked token.

Defined work

emit.define names a kind of work and the schemas of what goes in and what comes out, with any Standard Schema library: Zod, Valibot or ArkType. Every process that defines it and calls emit.serve() runs it, and whoever calls run gets a typed result, wherever it ran.

TypeScript
import { z } from "zod";

const resize = emit.define(
  "resize-image",
  {
    input: z.object({ imageId: z.string(), width: z.number() }),
    output: z.object({ url: z.string() }),
  },
  async (input, job) => {
    await job.progress(50);
    return { url: `https://images.example.com/${input.imageId}-${input.width}.webp` };
  },
);

const worker = emit.serve({ concurrency: 8 }); // one worker for everything defined so far

const { url } = await resize.run({ imageId: "img_881", width: 800 });   // queue it, wait for the result
const id = await resize.enqueue({ imageId: "img_882", width: 800 });    // queue it, return its id
const here = await resize.here({ imageId: "img_883", width: 800 });     // run it in this process
  • run(input, options?) queues the job and waits for its result. It throws JobFailed if the job fails or is cancelled.
  • enqueue(input, options?) queues the job and returns its id.
  • here(input, options?) runs the job in this process, held under a lease (five minutes unless leaseMs says). If the process dies, a worker that serves the name takes the next attempt.

The input is checked against its schema before it's queued, and a mismatch throws SchemaError. Define everything before calling serve(), which takes the same options as emit.worker except names. Define work without a handler when another service runs it: run and enqueue work the same, and serve() doesn't claim it.

Workflows

emit.workflow(name, schemas, body) defines a workflow, and emit.serve() runs it. See Workflows for the whole model. The body gets the input and wf:

Call What it does
await step.run(input, options?) runs a step and returns its result
step.start(input, options?) starts a step and returns a handle to await or cancel()
step.enqueue(input, options?) starts a step that outlives the run; returns its id
await wf.do(name, fn) runs fn once, here, and keeps what it returns
await wf.sleep("2h"), wf.sleepUntil(date) pauses the run
await wf.wait(name, { timeout?, schema? }) waits for a signal; undefined if the timeout passes
await wf.race({ a: ..., b: ... }) whichever of several started steps, waits or sleeps finishes first
await wf.note(text) adds a line to the run's timeline
return wf.continueAsNew(input) starts the run over with a fresh history

A workflow has run, enqueue and result(id) like defined work, plus signal(target, name, data) and cancel(id).

Admin

A token with the admin scope manages spaces and tokens:

TypeScript
const { admin } = new Emit({ url: process.env.EMIT_URL!, token: process.env.EMIT_ADMIN_TOKEN!, space: null });

const spaces = await admin.spaces.list();
const counts = await admin.spaces.counts("orders"); // jobs by status, and by name
const { name, ...settings } = await admin.spaces.get("orders");
await admin.spaces.configure("orders", { ...settings, maxUnfinished: 100_000 });

const created = await admin.tokens.create({ name: "api-server", spaces: ["orders"], scopes: ["create", "read"] });
console.log(created.secret); // shown once
await admin.tokens.revoke(created.id);

configure replaces the space's settings whole, so read them first, as above. See Spaces.

Webhook signatures

verifySignature(secret, header, body, toleranceMs?) checks the Emit-Signature header of a callback or a wake-up against its raw body. It returns false for a bad signature, or one more than five minutes old. See Checking the signature.

The firehose

The firehose streams every job change in your cluster, filtered, in batches. It has its own address, your cluster's endpoint followed by /firehose, and its own token: the firehose token from the cluster's Tokens tab, not an API token.

TypeScript
import { Firehose } from "@emit.run/sdk";

const hose = new Firehose({ url: process.env.EMIT_FIREHOSE_URL!, token: process.env.EMIT_FIREHOSE_TOKEN! });

for await (const batch of hose.watch({ spaces: ["orders"], transition: { to: "failed" } })) {
  for (const change of batch.changes) console.log(change.space, change.id, change.name, change.from, change.to);
}

Filter by spaces, names, id and transition (from and to a status, or made: true for new jobs). Keep each batch's cursor and pass it back as after to resume. batch.gap is true when changes before the batch were no longer available.

Errors

  • EmitError is a refusal from emit: code, message, the HTTP status, and retryAfterMs when emit asks you to wait. permanent is true when trying again unchanged won't help: any 4xx but 429.
  • JobFailed comes from jobs.result and run: the job's id, its status ("failed" or "cancelled") and its result.
  • SchemaError is a value that doesn't fit a defined work's schema.

The SDK retries by itself in three places: a worker's claims, a worker's reports, and the streams behind jobs.result and run. Other calls aren't retried. If you retry a create yourself, give the job a key. See When emit is busy.

Types

Every request and response type is exported: Job, JobEvent, Space, Token, CreateOptions, WorkerOptions and more. Field names are camelCase, times and durations are milliseconds (createdAtMs, timeoutMs), and statuses are lower-case strings: "pending", "running", "completed", "failed", "cancelled".