Beacons
A beacon is a long-running, supervised process. Where a signal runs to completion and exits, and a broadcast wires signals into a DAG, a beacon stays up — an HTTP server, a queue consumer, a poller, a websocket client. The BeaconRunner supervises each beacon in its own child process: it keeps it alive according to a restart policy, backs off between restarts, detects startup timeouts and heartbeat stalls, and shuts it down gracefully — reconciling a per-beacon desired state (running / stopped) you can flip at runtime.
beacon(name)
Creates a named beacon definition. The name must be unique, start with a letter, and contain only letters, digits, hyphens, and underscores. Returns a builder. There are two terminals: .run() for a general long-running handler, and .poll() for a framework-managed interval loop. Import beacon and z from station-beacon.
import { beacon, z } from "station-beacon";import { createServer } from "node:http"; export const webhookServer = beacon("webhook-server") .config(z.object({ port: z.number().default(8080) })) .restart("always") .run(async (ctx) => { const server = createServer(handler).listen(ctx.config.port); ctx.ready(); // mark healthy (optional) ctx.onStop(() => server.close()); // cleanup when asked to stop await ctx.untilStopped(); // park until stopped });BeaconRunner, which keeps it alive per its restart policy. Export one beacon per file for auto-discovery.Builder methods
.config(schema) · .withConfig(data)
.config() declares a Zod schema for the beacon's configuration. It is validated (with defaults applied) in the child process before each start; the parsed value is available as ctx.config. An invalid config is a fatal error — the beacon goes to errored and is never restarted (retrying with the same bad config would just loop). .withConfig() sets the default config used when the beacon is started without an override.
beacon("indexer") .config(z.object({ batchSize: z.number().default(100), source: z.string() })) .withConfig({ source: "s3://bucket/data" }) .run(async (ctx) => { /* ctx.config.batchSize === 100 */ });.restart(policy)
How the supervisor reacts when the process exits. Default: "on-failure".
| Policy | Behavior |
|---|---|
"always" | Bring it back up on any exit — clean or crash. For servers and clients that should always be running. |
"on-failure" | Restart only on a crash/failure, a heartbeat stall, or a startup timeout. A clean return parks the beacon. The default. |
"never" | Run once — a clean return or a failure is terminal. |
.backoff(base, opts?)
Configures exponential backoff between restarts. base is the first-restart delay (an interval string like "1s" or a millisecond number). The delay grows as base × factor^n, capped at max. After the process stays up longer than resetAfter, the consecutive- restart counter resets, so a beacon that ran fine for a while then blips restarts quickly instead of at the top of the curve.
| Option | Type | Default | Description |
|---|---|---|---|
factor | number | 2 | Multiplier applied per consecutive restart. Must be ≥ 1. |
max | string | number | "30s" | Upper bound on any single restart delay. |
resetAfter | string | number | "60s" | Uptime after which the consecutive-restart counter resets. |
beacon("stream-consumer") .restart("on-failure") .backoff("1s", { factor: 2, max: "30s", resetAfter: "60s" }) .run(connectAndConsume);.heartbeat(interval, opts?)
Opts into stall detection. The handler must call ctx.heartbeat() at least every interval; if the supervisor sees no heartbeat within the timeout (default 3× the interval) it treats the process as stalled and restarts it. The clock starts when the handler actually starts, so process boot time never counts against the deadline.
beacon("worker") .heartbeat("10s", { timeout: "45s" }) .run(async (ctx) => { ctx.ready(); for await (const job of queue.stream({ signal: ctx.signal })) { ctx.heartbeat(); await process(job); } });.startupTimeout(ms)
Sets a deadline, measured from spawn, for the beacon to reach ready via ctx.ready(). If it doesn't come up in time the supervisor kills the process and restarts it per the restart policy, recording the exit reason as startup-timeout. This catches two things heartbeat detection can't: a boot or module import that never resolves (the handler never even runs), and a handler that starts but wedges before it's ready — a server that never binds its port, say. Startup timeout covers the pre-ready window; heartbeats cover everything after. Off by default; requires the beacon to call ctx.ready().
beacon("api-server") .startupTimeout("30s") // must call ctx.ready() within 30s of spawn .heartbeat("10s") // ...and keep reporting liveness once ready .run(async (ctx) => { const server = await listen(ctx.config.port); ctx.ready(); ctx.onStop(() => server.close()); await ctx.untilStopped(); });.stopTimeout(ms)
Sets the grace period a beacon gets to exit after a stop is requested before it is force-killed (default "10s").
.manualStart() · .onDemand() · .maxInstances(n)
These decide how a beacon comes to be running. By default a beacon is seeded with one instance on discovery and started. .manualStart() still seeds that instance but leaves it stopped until startBeacon(name) is called. .onDemand() seeds nothing — the beacon becomes a template whose instances are created at runtime, each with its own config. See Instances below. .maxInstances(n) caps how many can exist at once.
.env(...keys)
Declares environment variables the beacon requires. Before each launch the supervisor checks each key against the env store and the host process.env; if any is missing it marks the beacon errored instead of spawning a process that can't come up. Provided values are injected into the child's process.env.
beacon("price-feed") .env("EXCHANGE_API_KEY") // errored (not spawned) if unset .restart("always") .run(async (ctx) => { /* ... */ });.placement({ labels })
In a Station Network, restricts the beacon to execution stations whose labels exactly match. A shared, fenced instance lease ensures only one eligible station owns each instance.
.run(handler)
Finalizes with a long-running handler. It runs until it returns, throws, or ctx.signal aborts. Use it for servers and stream clients. A server handler typically starts the thing, calls ctx.ready(), registers ctx.onStop() cleanup, and parks on await ctx.untilStopped(). Returning early is treated as a clean completion.
.poll(interval, fn)
Finalizes as a poller — the framework calls fn every interval until the beacon is stopped, and marks it ready on the first tick. Throwing from fn crashes the incarnation and lets the restart policy take over; catch inside fn to keep polling through transient errors.
beacon("price-watcher").poll("30s", async (ctx) => { const price = await fetchPrice({ signal: ctx.signal }); if (price > 100) await priceAlert.trigger({ price });});The beacon context
Every handler receives a ctx — its window into the supervisor.
| Member | Type | Description |
|---|---|---|
ctx.config | TConfig | Validated config for this incarnation (schema defaults applied). |
ctx.name | string | The beacon's name. |
ctx.incarnation | number | 1 on first start, incremented on each supervised restart. |
ctx.signal | AbortSignal | Fires when the beacon should stop. Pass it to fetch, stream iterators, etc. so in-flight work unwinds promptly. |
ctx.ready() | () => void | Mark the beacon ready/healthy (records readyAt). Optional. |
ctx.heartbeat() | () => void | Report liveness. Required if you declared .heartbeat(). |
ctx.expose(opts) | () => void | Advertise an HTTP/WebSocket protocol, port, and optional base path for Headquarters discovery and HTTP proxying. |
ctx.log(msg) | (string) => void | Emit a structured log line to subscribers. |
ctx.onStop(fn) | (fn) => void | Register cleanup to run when a stop is requested. Multiple run in order. |
ctx.untilStopped() | () => Promise<void> | Resolves when ctx.signal aborts — the idiomatic tail of a server handler. |
The three modes
Server
Start the server, mark ready, register cleanup, and park on untilStopped(). restart("always") keeps it up.
export const api = beacon("api") .config(z.object({ port: z.number().default(3000) })) .restart("always") .run(async (ctx) => { const server = createServer(app).listen(ctx.config.port); ctx.ready(); ctx.onStop(() => new Promise((r) => server.close(() => r()))); await ctx.untilStopped(); });Poller
The framework drives the interval; the beacon can trigger signals as it polls.
export const healthPoller = beacon("health-poller").poll("15s", async (ctx) => { const res = await fetch("https://api.example.com/health", { signal: ctx.signal }); if (!res.ok) await pageOncall.trigger({ status: res.status });});Client
Maintain a connection; throwing on a dropped connection lets the supervisor reconnect with backoff. Heartbeats guard against a silently wedged connection.
export const consumer = beacon("consumer") .restart("on-failure") .backoff("1s", { max: "30s" }) .heartbeat("10s") .run(async (ctx) => { const conn = await connect(); ctx.ready(); for await (const msg of conn.stream({ signal: ctx.signal })) { ctx.heartbeat(); await ingest.trigger(msg); } });BeaconRunner
The supervisor. It discovers beacons, runs each enabled one in its own child process, keeps it alive per its restart policy, and reconciles the per-beacon desired state each tick.
import path from "node:path";import { BeaconRunner, ConsoleBeaconSubscriber } from "station-beacon"; const runner = new BeaconRunner({ beaconsDir: path.join(import.meta.dirname, "beacons"), subscribers: [new ConsoleBeaconSubscriber()], signalRunner, // optional — lets beacons trigger signals into the shared queue}); await runner.start(); // discovers beacons and supervises them (blocks until stop)Constructor options
| Option | Type | Default | Description |
|---|---|---|---|
beaconsDir | string | — | Directory for auto-discovery. Recursively imports .ts/.js files and registers exported beacon definitions. |
adapter | BeaconStateAdapter | BeaconMemoryAdapter | Storage for supervision state (status, desired state, counters, events). |
signalRunner | SignalRunner | — | Wire a signal runner so beacons can signal.trigger() into the same queue it drains (its adapter manifest is passed to children). |
signalAdapter | SignalQueueAdapter | — | Alternative to signalRunner — pass the signal adapter directly. |
subscribers | BeaconSubscriber[] | [] | Objects notified on beacon lifecycle events. |
pollIntervalMs | number | 1000 | Milliseconds between reconcile ticks. |
Methods
| Method | Returns | Description |
|---|---|---|
start() | Promise<void> | Discover beacons, seed/resume state, install shutdown handlers, and run the reconcile loop. Blocks until stop(). |
stop(opts?) | Promise<void> | Stop the supervisor. With { graceful: true, timeoutMs }, running beacons are asked to stop and awaited before being force-killed. Desired state is left untouched so a restart resumes them. |
startBeacon(name, opts?) | Promise<void> | Set desired state to running and schedule an immediate launch. Accepts { config } to override the config for this run. Recovers an errored beacon. |
stopBeacon(name) | Promise<void> | Set desired state to stopped and gracefully stop the running incarnation. |
restartBeacon(name) | Promise<void> | Gracefully stop the current incarnation, then relaunch with a fresh incarnation. |
createInstance(name, opts?) | Promise<BeaconInstance> | Create a new instance with its own config and (by default) start it. { id?, label?, config?, start? }. |
updateInstance(id, opts) | Promise<BeaconInstance> | Change an instance's config or label. Takes effect on the next start, or immediately with { restart: true }. |
deleteInstance(id, opts?) | Promise<void> | Stop the instance and remove its record. Runtime-created instances only — stop a definition-owned one instead. |
startInstance(id, opts?) · stopInstance(id) · restartInstance(id) | Promise<void> | The per-instance equivalents of the definition-level controls above. |
stopAllInstances(name) | Promise<number> | Stop every instance of a beacon; returns how many were stopped. |
getInstance(id) | Promise<BeaconInstance | null> | An instance record by id (status, desired state, counters, timestamps). A beacon's definition-owned instance uses the beacon name as its id. |
listInstances(filter?) | Promise<BeaconInstance[]> | All known instance records, optionally narrowed with { beaconName }. |
whenReady() | Promise<void> | Resolves once start() has finished discovery, hydration, and seeding. start() itself never settles while supervising, so await this before serving an API or creating instances at boot. |
register(beacon, filePath) | this | Register a beacon explicitly (alternative to beaconsDir). Call before start(). |
listRegistered() | Array<{ name, filePath, mode, restartPolicy, startMode, maxInstances }> | Metadata for all registered beacons. |
subscribe(subscriber) | this | Add a subscriber after construction. |
Runtime control
Flip a beacon's desired state at any time — the supervisor reconciles toward it on the next tick.
await runner.stopBeacon("consumer"); // stop and keep stoppedawait runner.startBeacon("consumer", { // start with a config override config: { source: "s3://other-bucket" },});await runner.restartBeacon("consumer"); // graceful stop, then relaunch const inst = await runner.getInstance("consumer");// { status: "running", desiredState: "running", incarnation: 3, restartCount: 0, ... }Running many instances of one beacon
A beacon definition can back many running instances. Each is supervised independently — its own process, config, status, restart counter, and logs — so the same beacon can run once per tenant, queue, or stream, driven from the dashboard or the API.
| Start mode | Behaviour |
|---|---|
auto (default) | One instance is seeded on discovery and started. Its id is the beacon name. |
.manualStart() | One instance is seeded but left stopped until someone starts it. |
.onDemand() | Nothing is seeded. Instances exist only once created at runtime. |
The instance seeded from the file has origin: "definition" and uses the beacon name as its id, so startBeacon / stopBeacon keep acting on it. Instances created at runtime have origin: "api", their own ids, and can be deleted outright.
// beacons/queue-worker.ts — a template, not a single processexport const queueWorker = beacon("queue-worker") .config(z.object({ queue: z.string(), batchSize: z.number().default(10) })) .onDemand() .maxInstances(8) .run(async (ctx) => { ctx.log(`worker ${ctx.instanceId} draining ${ctx.config.queue}`); ctx.ready(); await ctx.untilStopped(); });// start() only settles when the supervisor stops, so wait for setuprunner.start().catch(console.error);await runner.whenReady(); const worker = await runner.createInstance("queue-worker", { id: "worker-acme", // optional — generated when omitted label: "acme", config: { queue: "acme", batchSize: 25 }, // validated against the config schema}); // starts immediately (start: false to stage it) await runner.listInstances({ beaconName: "queue-worker" });await runner.updateInstance(worker.id, { config: { queue: "acme2" }, restart: true });await runner.stopInstance(worker.id);await runner.deleteInstance(worker.id); // stops the process, then removes the recordInstance ids are unique across all beacons, must match /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/, and are at most 128 characters. Runtime-created instances are persisted, so a supervisor restart resumes them; an instance whose definition is no longer registered is surfaced as errored with an explanation rather than silently dropped.
Over the API
The same operations are available over HTTP. The authenticated v1 API mirrors these under /api/v1, with creating and starting on the trigger scope, stopping on cancel, and editing or deleting an instance on admin.
POST /api/beacons/:name/instances { id?, label?, config?, start? }GET /api/beacons/:name/instancesGET /api/beacons/:name/instances/:idPOST /api/beacons/:name/instances/:id/{start,stop,restart}PATCH /api/beacons/:name/instances/:id { config?, label?, restart? }DELETE /api/beacons/:name/instances/:idPOST /api/beacons/:name/stop?all=true stop every instanceCreation failures are distinguishable: 400 invalid_config, 404 not_found for an unknown beacon, 409 instance_exists for a taken id, and 409 instance_limit at the cap.
BeaconInstance
The supervised record for one instance of a beacon, updated as incarnations start, become ready, and exit. A definition may have many.
| Field | Type | Description |
|---|---|---|
id | string | Unique instance id. A beacon's definition-owned instance uses the beacon name. |
beaconName | string | The name of the beacon definition this instance runs. |
label | string | undefined | Optional human-readable label for a runtime-created instance. |
origin | "definition" | "api" | Whether the instance was seeded from the beacon file or created at runtime. Only api instances can be deleted. |
status | "stopped" | "starting" | "running" | "stopping" | "backoff" | "errored" | Observed lifecycle status. backoff means a (re)start is scheduled at nextRestartAt; errored is terminal (won't auto-restart). |
desiredState | "running" | "stopped" | What the operator wants. The supervisor reconciles toward this. |
incarnation | number | How many times the beacon has been started over its lifetime. |
restartCount | number | Consecutive restart attempts since the beacon was last healthy. |
pid | number | undefined | OS process id of the current incarnation, when running. |
readyAt / startedAt / lastHeartbeatAt | Date | undefined | Timestamps for readiness, incarnation start, and the last heartbeat. |
lastExitReason | "clean" | "failure" | "stopped" | "stalled" | "startup-timeout" | How the most recent incarnation ended. |
lastError / nextRestartAt | string / Date | undefined | Last error message; and, in backoff, when the next restart fires. |
BeaconSubscriber
All methods are optional. Subscriber errors are caught and logged without affecting supervision. The built-in ConsoleBeaconSubscriber logs every event with a [station-beacon] prefix.
| Method | When it fires |
|---|---|
onBeaconDiscovered | A beacon file was found during auto-discovery. |
onBeaconInstanceCreated | A new instance was created at runtime (dashboard / API). |
onBeaconInstanceRemoved | A runtime-created instance was stopped and removed. |
onBeaconStarting | The supervisor is about to spawn a child process. |
onBeaconStarted | The child reported the handler has started executing. |
onBeaconReady | The handler called ctx.ready(). |
onBeaconHeartbeat | A heartbeat was received. |
onBeaconExited | The child process exited (with reason and code). |
onBeaconRestartScheduled | A restart was scheduled after an exit (with the backoff delay). |
onBeaconStopped | The beacon reached a cleanly stopped state. |
onBeaconErrored | The beacon failed terminally and will not be restarted. |
onBeaconStalled | A heartbeat deadline or startup timeout was missed; the process is being restarted. |
onBeaconLog | Log output — from ctx.log() or captured stdout/stderr. |
Triggering signals from a beacon
Beacons commonly trigger signals — a poller firing an alert, a consumer enqueuing work. Wire a SignalRunner into the BeaconRunner and use a persistent signal adapter so the trigger — which happens in the beacon's child process — reaches the same queue the SignalRunner drains.
import { SignalRunner } from "station-signal";import { BeaconRunner } from "station-beacon";import { SqliteAdapter } from "station-adapter-sqlite"; const signalRunner = new SignalRunner({ signalsDir: "./signals", adapter: new SqliteAdapter({ dbPath: "./jobs.db" }),}); const beaconRunner = new BeaconRunner({ beaconsDir: "./beacons", signalRunner, // beacons can now signal.trigger() into the shared queue}); await signalRunner.start();await beaconRunner.start();signal.trigger() writes to an isolated adapter in its own child process, so the parent SignalRunner never sees it. Use a persistent signal adapter (SQLite/Postgres/…) whenever beacons trigger signals.Dashboard
Point the dashboard at a beacons directory and it supervises them and surfaces them under a Beacons page — live status, incarnation and restart counts, lifecycle events, streaming logs, and start / stop / restart controls.
// station.config.tsimport { defineConfig } from "station-kit"; export default defineConfig({ beaconsDir: "./beacons", // beaconAdapter: new BeaconSqliteAdapter(...), // optional, for durable state});Then run npx station and open /beacons. A beacon's page lists its instances, builds new ones from the config schema, and scopes logs and controls to the selected instance. The REST surface behind the page (see Instances) is available for your own tooling, and beaconMaxInstances in defineConfig sets the default instance cap.
Persistence
Supervision state (the instance record + lifecycle event log) lives behind a BeaconStateAdapter. The default BeaconMemoryAdapter is single-process; on restart the supervisor re-derives desired state from each beacon's start mode, and runtime-created instances do not survive. For durable state across restarts — and to keep instances created through the API — use a /beacon subpath adapter:
import { BeaconSqliteAdapter } from "station-adapter-sqlite/beacon";// or /postgres/beacon, /mysql/beacon (async .create()), /redis/beacon const adapter = new BeaconSqliteAdapter({ dbPath: "./station.db" });// new BeaconRunner({ beaconsDir, adapter })// or defineConfig({ beaconsDir, beaconAdapter: adapter })Each adapter persists the instance records and the lifecycle event log, so a supervisor restart resumes desired state, brings back instances created through the API, and keeps the dashboard's history. A database written before instances existed is migrated in place on first open — the old per-beacon record becomes that beacon's definition-owned instance, keeping its desired state and counters.