# Station in the browser

> Canonical HTML: [https://station.dterminal.net/docs/browser](https://station.dterminal.net/docs/browser)

`station-browser` executes signals, broadcast DAGs, and beacons in Web Workers and service workers. IndexedDB stores the queue, completed steps, workflow progress, and beacon desired state on the device. The browser runtime needs no Node runner, companion app, or Station server. It is experimental and is not a Station Network member.

Local state can outlive a browser execution slice

1.  Application**Trigger locally**The page registers bundled definitions and queues work on this device.
2.  IndexedDB**Retain progress**Store runs, completed steps, workflow state and beacon intent.
3.  Worker**Run while awake**Execute work when the browser allows it. A later wake can recover persisted state.

Storage durability is not continuous execution. A service worker cannot promise one-second polling after the PWA closes.

Persistence survives a page reload; continuous execution does not. Service workers run bounded work when the browser wakes them. Neither an installed PWA nor `event.waitUntil()` guarantees polling after closing the app or browser.

For server-owned browsers, screenshots or native shell workspaces, use [Browser Use](https://station.dterminal.net/docs/browser-use.md) or [Sandboxes](https://station.dterminal.net/docs/sandboxes.md). Those are separate server primitives with their own lifecycles.

## Try the implementation

The experimental package has been available since Station 2.3.0. This checkout targets 3.0.0. After that release is published, install it with  ` pnpm add station-browser@^3.0.0`. To try the release checkout, start with the repository workspace:

```
# From the Station repository checkout containing station-browser
pnpm install
pnpm dev:browser
```

Open `http://127.0.0.1:4317`. The Node process serves static files only. Follow the [browser lab walkthrough](https://station.dterminal.net/docs/examples/browser.md) to exercise interruption, retries, DAGs, and beacon restarts.

For another application in this monorepo, add `station-browser` as a `workspace:*` dependency. Bundle page and worker entries for the browser with esbuild or another browser-aware bundler. The browser condition selects Web Crypto; do not polyfill Node runners into the app. Use HTTPS or localhost, IndexedDB, Web Crypto, and module worker support. TypeScript worker entries need the WebWorker library.

## Choose an execution host

| Host | Use it for | Execution contract |
| --- | --- | --- |
| Web Worker | Interactive local jobs and live beacon sessions | Drain jobs and tick beacons while the worker runs. Page closure or suspension can interrupt it. |
| Service worker | Bounded work on message or supported background events | Call wake() through event.waitUntil(). Beacons suspend at the end of each slice. |

## 1\. Share a workload registry

Import the same definitions and database name in the page and executor. Definitions are bundled code; there is no directory auto-discovery. Broadcast node signals are registered automatically.

```
// src/registry.ts
import { beacon, broadcast, signal, z } from "station-browser";

export const report = signal("report")
  .input(z.object({ text: z.string() }))
  .timeout(5_000).retries(2)
  .step("normalize", async ({ text }) => text.trim())
  .step("count", async (text) => ({ characters: text.length }))
  .build();

const summarize = signal("summarize")
  .input(z.object({ characters: z.number() }))
  .output(z.object({ message: z.string() }))
  .run(async ({ characters }) => ({ message: String(characters) + " characters" }));

export const analysis = broadcast("analysis")
  .input(report).then(summarize)
  .onFailure("skip-downstream").build();

// onDemand creates an instance only when explicitly requested.
export const status = beacon("status")
  .config(z.object({ url: z.string() }))
  .onDemand().restart("on-failure")
  .backoff("1s", { max: "10s" }).stopTimeout(1_000)
  .poll("1s", async (ctx) => {
    const response = await fetch(ctx.config.url, { signal: ctx.signal });
    if (!response.ok) throw new Error("Status request failed");
    ctx.heartbeat();
    ctx.log("HTTP " + response.status);
  });

export const options = {
  database: "my-app-station-v1",
  definitionVersion: "1",
  signals: [report], broadcasts: [analysis], beacons: [status],
  concurrency: 4,
};
```

The poll callback completes before the one-second delay begins, so a slow request lengthens the interval. Suspension, restart backoff, and browser scheduling add further gaps. This is not an exact one-second clock. The example URL must point to an endpoint your app provides; cross-origin requests also need permission from the target server through CORS.

## 2\. Host it in a Web Worker

Drain jobs independently of beacon ticks: waiting for a slow signal must not prevent lease renewal. The current beacon lease is 2.5 seconds; the demo ticks every 100 ms. Catch failures so the host can report them.

```
// src/worker.ts — compile with the WebWorker library
import { BrowserStation, configure } from "station-browser";
import { options } from "./registry.js";

const station = new BrowserStation({ ...options, stationId: "web-worker" });
configure({ triggerAdapter: station });
const reportError = (error: unknown) => postMessage({ error: String(error) });

async function drain() {
  try { await station.drain({ maxJobs: 10, budgetMs: 10_000 }); }
  catch (error) { reportError(error); }
  setTimeout(drain, 300);
}
void drain();
setInterval(() => {
  void station.beacons.tick().catch(reportError);
}, 100);
```

```
// src/app.ts — compile with the DOM library; bundles served at origin root
import { BrowserStation, configure } from "station-browser";
import { options, report, analysis } from "./registry.js";

const station = new BrowserStation(options);
configure({ triggerAdapter: station });
const worker = new Worker("/worker.js", { type: "module" });
worker.onmessage = ({ data }) => { if (data.error) console.error(data.error); };

const runId = await report.trigger({ text: "Hello from this device" });
const workflowId = await analysis.trigger({ text: "A browser workflow" });
await station.beacons.start("status", {
  instanceId: "api-status", config: { url: "/api/status" },
});

// Read these again from your UI to observe progress; trigger() only enqueues.
console.log(await station.store.get(runId));
console.log((await station.broadcasts.list()).find((run) => run.id === workflowId));
console.log(await station.beacons.list());

// User actions can call:
// await station.store.cancel(runId);
// await station.broadcasts.cancel(workflowId);
// await station.beacons.stop("api-status");
```

`configure()` applies to its JavaScript context. Configure every context that calls a definition's `.trigger()`, or use `station.trigger(report, input)` and `station.triggerBroadcast("analysis", input)` directly. `BrowserStation` has no `waitForRun()`; observe the persisted status instead.

## 3\. Alternatively, host it in a service worker

Use this host in place of the dedicated worker above. A message grants an execution opportunity; it does not create a permanent background loop.

```
// src/sw.ts — compile with the WebWorker library
import { BrowserStation, configure } from "station-browser";
import { options } from "./registry.js";

const sw = self as unknown as ServiceWorkerGlobalScope;
const station = new BrowserStation({ ...options, stationId: "service-worker" });
configure({ triggerAdapter: station });
sw.addEventListener("message", (event) => {
  if (event.data?.type !== "station:wake") return;
  event.waitUntil(station.wake({
    maxJobs: 10, budgetMs: 10_000, beaconSliceMs: 1_500,
  }));
});
```

```
// In the page: replace new Worker(...) with this registration.
// After enqueueing work or changing beacon desired state, call wake().
await navigator.serviceWorker.register("/sw.js", { type: "module" });
const registration = await navigator.serviceWorker.ready;
function wake() {
  registration.active?.postMessage({ type: "station:wake" });
}
wake();
// Supply later wake opportunities while the page is open, including for retries.
setInterval(wake, 1_000);
```

`wake()` drains signals and broadcasts while supervising a bounded beacon slice. `drain()` alone never supervises beacons. At slice end, handlers are aborted and cleanup is requested; cleanup may add the configured stop timeout. Beacon desired state remains running, with status suspended, until a later wake resumes it. Explicit stop persists across reloads. Background Sync is optional and feature-detected in the lab; no recurring schedule, push wakeup, or app-shell cache is installed by `BrowserStation` itself.

## Browser API reference

| API | Behavior |
| --- | --- |
| `new BrowserStation(options)` | Optional signals, broadcasts, beacons arrays; database defaults to station-browser, definitionVersion to 1, concurrency to 4, stationId to a generated ID. Concurrency overlaps async signals in one executor. |
| `trigger(definitionOrName, input)` | Validate and enqueue a signal; returns its ID. |
| `triggerBroadcast(name, input)` | Enqueue a registered broadcast; returns its ID. |
| `drain({ maxJobs, budgetMs })` | Returns the number of claimed signal attempts. Defaults: 10 jobs, 20,000 ms. Budget limits new claims; already claimed work may use its full timeout. |
| `wake({ maxJobs, budgetMs, beaconSliceMs })` | Drain jobs and run a beacon slice concurrently. Default slice: 1,500 ms. |
| `store.get(id) / list() / cancel(id)` | Read or cancel signal runs. Input, output, and checkpoint outputs are serialized JSON strings; decode defined values with JSON.parse(). |
| `broadcasts.list() / cancel(id)` | Read workflow and node states or cancel the workflow and fence child writes. |
| `beacons.start(name, { instanceId, config })` | Persist desired-running state and return the instance ID. An executor must tick or wake to launch it. |
| `beacons.list() / stop(instanceId)` | Inspect instances or persist desired-stopped state. Stop before changing active configuration. |
| `beacons.tick() / runSlice(ms) / suspend()` | Host-level supervision. Suspend preserves desired-running state; invoke it in the executor that owns the handlers. Stop host timers before graceful shutdown and close storage after in-flight work settles. |

## Supported definitions and limits

-   Signals support input/output schemas, run handlers, saved steps, retries, and cooperative timeouts. Schedules, env injection, placement, per-signal concurrency policies, and onComplete hooks are rejected.
-   Broadcasts support fan-out, joins, named dependencies, synchronous maps/guards, and fail-fast, skip-downstream, or continue policies. Recurring and dynamic-definition workflows are unsupported. Parent timeouts include time spent suspended.
-   Beacons support run/poll, config, readiness, heartbeat/startup watchdogs, restart/backoff, and manual/auto/on-demand modes. Handlers must honor ctx.signal and release resources through onStop. Listening ports through ctx.expose(), env injection, and placement are unavailable.
-   Manual beacons with required config can wait for an explicit start() with valid values. Existing instances retain saved configuration when a new supervisor starts. Auto-start definitions need valid defaults if no instance has been started yet; use schema defaults or withConfig(). Invalid explicit starts reject without creating an instance.

## Recovery and production boundaries

Signal leases last for the timeout plus one second. An interrupted attempt can be reclaimed when its lease expires if retry attempts remain; retries become eligible after 250 ms and need another drain. Completed steps are reused. Incomplete handlers and steps may execute again: external effects must be idempotent. Lease tokens fence stored writes, not requests already sent or uncooperative JavaScript.

Keep the database name stable across page and worker entries. Change definitionVersion when handler or step semantics change; incompatible runs fail rather than reuse old checkpoints. Coordinate asset and worker updates, or isolate incompatible versions in separate database names. IndexedDB is origin-local storage that can be cleared or evicted, not a server backup. Store scans, retention, blocked upgrades, and cross-browser rollout behavior need further hardening. Never bundle server secrets or assume workers provide an untrusted-code sandbox or Node process isolation.

See the [agent skill](https://station.dterminal.net/docs/agent-skill.md) for building guidance, [llms.txt](https://station.dterminal.net/llms.txt) for the documentation index, and [this guide as Markdown](https://station.dterminal.net/docs/browser.md) for agent context.
