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.
npm install @emit.run/sdkThis page covers version 0.2.0.
The client
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 spacetoken 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 hasadmin, for managing spaces and tokens.
Jobs
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 scopecreate 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
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
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
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: 1How many jobs the worker holds at once.
leaseMsnumberThe lease to ask for. By default, each job's own timeout. The worker renews it while the handler runs.
waitMsnumberdefault: 30000How 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.errorTold 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.
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 processrun(input, options?)queues the job and waits for its result. It throwsJobFailedif 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 unlessleaseMssays). 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:
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.
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
EmitErroris a refusal from emit:code,message, the HTTPstatus, andretryAfterMswhen emit asks you to wait.permanentistruewhen trying again unchanged won't help: any 4xx but 429.JobFailedcomes fromjobs.resultandrun: the job'sid, itsstatus("failed"or"cancelled") and itsresult.SchemaErroris 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".