Build with Station
Choose the runtime first. For browser-local execution, read browser.md
and use BrowserStation from station-browser with explicit registries and
IndexedDB. It is experimental and included in the Station 2.3.0 release. Do not
create a Node server, native companion, or Station Network for browser-only work.
Do not promise continuous polling after a PWA closes.
For Node applications, use station-kit as the application entry point. Create a station.config.ts
with defineConfig, export definitions from the configured directories, and run
the application with npx station. Construct runners directly only for an
embedded/headless runtime or a focused test that cannot use station-kit.
The workflow and Node runner examples below apply to server and desktop work. Browser builds follow the host setup and supported subset in browser.md.
Follow this workflow
- Inspect
package.json,station.config.ts, definition directories, and the selected adapters before changing code. - Decide whether the process is
standalone,headquarters, orstation. Keepstandalonefor a single node. Use a network only when multiple processes must share work or supervise services. - Match every shared concern to a durable backend. In a network, share the signal queue and network adapter across all nodes; share beacon state when running beacons; share schedules across Headquarters replicas.
- Export signals, broadcasts, and beacons from their files so auto-discovery can find them.
- Validate inputs with the
zre-export from the relevant Station package. - Run typecheck, package tests, and a realistic local integration test. For a network, start one Headquarters and at least two stations against shared storage and verify ownership, concurrency, placement, and shutdown.
Apply these rules
- Import
signalandzfromstation-signal; do not add a separate Zod dependency merely for Station schemas. - Use
.run()for one-handler signals. Use.step(...).build()for multi-step signals. Do not mix the two forms. - Use
.jsin relative ESM import paths, including imports whose source file is TypeScript. - Treat
.retries(n)asnretries after the initial attempt. - Treat
.trigger()as enqueue-only. Userunner.waitForRun(id)when code must wait for a terminal result. - Use subscribers for metrics, audit logs, alerts, and other cross-cutting
effects. Pass them through
defineConfig({ subscribers: ... }). - Stop beacons, then broadcasts, then signals during a hand-built shutdown.
station-kitalready applies the safe order. - Instantiate MySQL adapters with their async
.create()factories. Other official adapters use constructors. - Import broadcast, beacon, schedule, env, and network adapters from their
package subpaths:
/broadcast,/beacon,/schedules,/env,/network. - Use memory adapters only for tests or a single process. They cannot coordinate separate processes.
- Use runtime schedules for editable interval/cron schedules. Use
.every()for schedules owned by code. Provide an adapter with atomicclaimDuein a multi-controller deployment. - Treat a scheduled timestamp as the moment work becomes eligible. Polling, queue pressure, and capacity can delay the handler start. Station advances from the planned occurrence so polling delay does not accumulate as drift.
- Use beacons for supervised long-running servers, pollers, and clients. Use signals for bounded jobs and broadcasts for DAG orchestration.
- Keep expressions pure and JSON-serializable. Put arbitrary code in a signal.
- Never expose an unauthenticated Station on a non-loopback interface. Protect the v1 API with a session or scoped API key.
Configure a standalone station
// station.config.ts
import { defineConfig } from "station-kit";
import { SqliteAdapter } from "station-adapter-sqlite";
import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast";
import { BeaconSqliteAdapter } from "station-adapter-sqlite/beacon";
import { ScheduleSqliteAdapter } from "station-adapter-sqlite/schedules";
const dbPath = "./station.db";
export default defineConfig({
signalsDir: "./src/signals",
broadcastsDir: "./src/broadcasts",
beaconsDir: "./src/beacons",
adapter: new SqliteAdapter({ dbPath }),
broadcastAdapter: new BroadcastSqliteAdapter({ dbPath }),
beaconAdapter: new BeaconSqliteAdapter({ dbPath }),
scheduleAdapter: new ScheduleSqliteAdapter({ dbPath }),
auth: {
username: process.env.STATION_AUTH_USERNAME!,
password: process.env.STATION_AUTH_PASSWORD!,
},
});
Run it with:
npx station
npx station deploy
For pnpm 10 and SQLite, allow the native build in the consumer package:
{
"pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] }
}
Define signals
import { signal, z } from "station-signal";
export const render = signal("render")
.input(z.object({ assetId: z.string() }))
.output(z.object({ url: z.string().url() }))
.timeout(60_000)
.retries(2)
.concurrency(4)
.run(async ({ assetId }) => ({ url: await renderAsset(assetId) }));
For network-aware limits and placement:
export const gpuRender = signal("gpu-render")
.input(z.object({ assetId: z.string() }))
.concurrency({ station: 2, network: 10 })
.placement({ labels: { gpu: "true", region: "ke" } })
.run(async ({ assetId }) => renderOnGpu(assetId));
The station limit bounds one worker process. The network limit uses shared, fenced controller leases across the fleet. Placement labels match exactly.
Build broadcasts
import { broadcast } from "station-broadcast";
import { checkout } from "../signals/checkout.js";
import { lint } from "../signals/lint.js";
import { test } from "../signals/test.js";
import { deploy } from "../signals/deploy.js";
export const pipeline = broadcast("pipeline")
.input(checkout)
.then(lint, test)
.then(deploy)
.onFailure("fail-fast")
.build();
Use runtime-editable dynamic broadcasts only when operators must change the DAG without a deployment. They use the v1 API and pure expressions for mappings and guards; Station snapshots the chosen definition version into each run.
Build beacons
import { beacon, z } from "station-beacon";
export const gateway = beacon("gateway")
.config(z.object({ port: z.number().int().positive() }))
.placement({ labels: { region: "ke" } })
.restart("on-failure")
.run(async (ctx) => {
const server = await startServer(ctx.config.port);
ctx.expose({ protocol: "http", port: ctx.config.port, path: "/gateway" });
ctx.ready();
await ctx.untilStopped();
await server.close();
});
Call ctx.ready() only after the service is usable. Long-running handlers must
observe ctx.signal, use ctx.untilStopped(), or run another abort-aware loop.
Use .poll(interval, fn) instead of .run() for pollers; never use both.
Scale with a Station Network
Use one logical Headquarters and one or more execution stations. Headquarters accepts API requests, exposes fleet inventory, and reconciles schedules. It does not claim signal work or own beacons. Stations advertise definitions and capacity, then atomically claim eligible work from the shared queue.
// station.hq.config.ts
import { defineConfig } from "station-kit";
import { PostgresAdapter } from "station-adapter-postgres";
import { StationNetworkPostgresAdapter } from "station-adapter-postgres/network";
import { SchedulePostgresAdapter } from "station-adapter-postgres/schedules";
const connectionString = process.env.DATABASE_URL!;
export default defineConfig({
role: "headquarters",
signalsDir: "./src/signals",
adapter: new PostgresAdapter({ connectionString }),
scheduleAdapter: new SchedulePostgresAdapter({ connectionString }),
network: {
id: "production",
stationId: "hq-1",
adapter: new StationNetworkPostgresAdapter({ connectionString }),
},
auth: {
username: process.env.STATION_AUTH_USERNAME!,
password: process.env.STATION_AUTH_PASSWORD!,
},
});
// station.worker.config.ts
export default defineConfig({
role: "station",
signalsDir: "./src/signals",
beaconsDir: "./src/beacons",
adapter: new PostgresAdapter({ connectionString }),
beaconAdapter,
network: {
id: "production",
stationId: process.env.STATION_ID!,
name: process.env.STATION_NAME,
adapter: new StationNetworkPostgresAdapter({ connectionString }),
labels: { region: process.env.REGION!, gpu: process.env.HAS_GPU! },
endpoint: process.env.STATION_ENDPOINT,
},
runner: { maxConcurrent: 12 },
});
Assign a stable, unique stationId to every process. Keep the network.id
identical. Advertise an endpoint only when Headquarters can reach it. Drain a
station before maintenance so it stops claiming new work while current work
finishes. A worker loss is recovered after its run lease expires; fencing
prevents the old process from completing the recovered attempt.
For exposed HTTP beacon services, Headquarters proxies through:
/api/v1/beacons/:name/instances/:id/proxy/*
Require a trigger or admin scope. The owner must be online and advertise a
reachable HTTP(S) endpoint. WebSocket upgrades are not proxied; connect to the
station endpoint directly.
Verify the result
Run the repository's own scripts first:
pnpm typecheck
pnpm build
pnpm test
For network changes, also exercise a real local topology with shared durable adapters and assert:
- one atomic owner per run and per schedule occurrence;
- distribution across multiple eligible stations;
- per-station and network concurrency never exceed their declarations;
- label placement excludes ineligible stations;
- draining prevents new claims;
- expired leases recover and stale owners cannot commit terminal state;
- a scheduled run retains
scheduleIdandscheduledFor; - beacon service ownership and proxy authorization are enforced;
- graceful shutdown closes producers before their dependent adapters.
Treat microbenchmarks as local diagnostics, not production capacity promises. Measure the intended production adapter and workload before sizing a fleet.
Read the focused references
- Read browser.md first for browser-local applications: shared registries, Web Worker/service-worker hosts, cooperative execution, API usage, versioning, and beacon configuration and start modes.
- Read api-reference.md for exact types, methods, adapters, v1 endpoints, and package exports. Station Networks are in §15.
- Read examples.md for complete applications and deployment patterns. The multi-process Headquarters/worker example is §26.
- Prefer the relevant section rather than loading either reference wholesale.