diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 1203eb9..7f350bf 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -1,14 +1,104 @@ # Architecture -::: warning Migrating -This page is being migrated from the [README architecture section](https://github.com/CodeThicket/tabmesh#architecture). The diagrams and full explainer arrive in the next docs PR. -::: +TabMesh is hub-and-spoke. Every tab is a spoke; the hub holds the backend transport and the durable outbox. The hub has two implementations — a `SharedWorker` (primary) and an elected leader tab (fallback) — both behind the same `Hub` interface. -For now, the short version: +## Primary mode — SharedWorker -- **Primary mode** uses a `SharedWorker` shared across all tabs of the same origin. The worker holds the transport and the IndexedDB outbox. Tabs talk to it over `MessagePort`. -- **Fallback mode** elects a leader tab via Web Locks API → BroadcastChannel heartbeat → IndexedDB heartbeat. Used when SharedWorker isn't available (some mobile browsers). -- **Outbox** is IndexedDB-backed with TTL, priority ordering, and an in-memory degraded fallback. -- **Service Worker** (optional) takes over outbox draining after the last tab closes, via Background Sync. +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Tab A │ │ Tab B │ │ Tab C │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ + │ MessagePort │ MessagePort │ MessagePort + └────────┬───────┴────────────────┘ + ▼ + ┌─────────────┐ + │ SharedWorker│ ← single point of fan-out + outbox + └──────┬──────┘ + │ WebSocket + ▼ + ┌──────────┐ + │ backend │ + └──────────┘ +``` -→ See [ADR-0001](/adr/0001-write-through-outbox) and [ADR-0002](/adr/0002-sharedworker-primary-hub) for the design rationale. +A single `SharedWorker` instance is shared across every tab of the same origin. It: + +- Holds the one and only `WebSocket` to the backend. +- Owns the IndexedDB-backed event outbox. +- Maintains a registry of connected tab ports. +- Relays events between tabs via `MessagePort`. + +Browser support: Chrome, Edge, Firefox, Safari, iOS Safari 16+. + +Why it's the primary path: the browser guarantees one worker instance per `name`, which means leader election, split-brain, and the interregnum problem are eliminated by construction. See [ADR-0002](/adr/0002-sharedworker-primary-hub). + +## Fallback mode — elected leader + +When `SharedWorker` is unavailable (Chrome Android, Samsung Internet, older browsers), one tab is elected the leader. The leader plays the role the worker would have: + +- Holds the WebSocket. +- Drains the outbox. +- Broadcasts events to followers via `BroadcastChannel`. + +Election uses a layered strategy, picked once at startup based on browser capabilities: + +1. **Web Locks API** — sub-50ms failover. Lock is released when the leader tab closes; the next tab waiting on the lock takes over. +2. **BroadcastChannel heartbeat** — leader broadcasts every 500ms; followers declare candidacy after 1.5s of silence. Failover in ~1–2s. +3. **IndexedDB heartbeat** — leader writes a timestamp every 2s; tabs race to claim leadership if the timestamp is stale for more than 5s. Failover in ~2–5s. + +Split-brain (two tabs both believing they're leader) is resolved by a monotonic `term` number plus `tabId` as a tiebreaker. The lower-priority leader demotes itself and closes its transport. + +## The outbox + +Every outbound event passes through the IndexedDB outbox before delivery. The flow differs by mode but the contract is identical: + +- **Primary**: tab posts to the worker → worker writes to IndexedDB → worker drains to transport + fans out to tabs. +- **Fallback**: tab writes to IndexedDB directly (write-through) → notifies the leader via `BroadcastChannel` → leader drains. + +The outbox supports: + +- **TTL** — events past their `createdAt + ttl` are dropped at the next drain. +- **Priority** — `readPending()` returns events ordered by priority desc, then `createdAt` asc. +- **Bounded size** — default 1000 events; eviction prefers oldest delivered, then oldest pending. +- **Degraded mode** — when IndexedDB is unavailable (private browsing on some browsers), an in-memory queue takes over with the same API. The mesh emits a `storage.degraded` system event and logs a warning. + +The write-through pattern is non-obvious; see [ADR-0001](/adr/0001-write-through-outbox) for why we picked it over optimistic send. + +## Service Worker handoff (optional) + +When all tabs close, the `SharedWorker` dies and pending events sit in IndexedDB. The optional Service Worker integration takes over via Background Sync: + +1. While tabs are alive, the SW is registered but idle. +2. When the last tab closes, the browser fires a Background Sync event some time later. +3. The SW reads pending entries from IndexedDB and POSTs them to a configured `deliveryUrl`. +4. Successfully delivered entries are removed from the outbox. + +This is best-effort and browser-dependent. It requires `serviceWorker.deliveryUrl` to be set — without it, the SW leaves entries in the outbox for the next Hub session (see the [SW handoff gotcha](./gotchas#service-worker-handoff-requires-deliveryurl)). + +## Inbound event flow + +``` +backend → WebSocket → Hub → all connected tabs +``` + +The Hub does **not** write inbound events to the outbox — persistence is the backend's responsibility. There's no replay buffer for late-joining tabs. New tabs start with a clean slate and fetch state from your backend if they need it. + +The Hub deduplicates server echoes of locally-sent events (the SharedWorker remembers `id`s it forwarded, and drops bounce-backs). See [ADR-0003](/adr/0003-distribute-prebuilt-worker-bundles) for context on related bundling concerns. + +## Identity + +- **Tab ID**: 8 hex chars from `crypto.getRandomValues()`, stored in `sessionStorage` so it survives reloads. +- **Event ID**: `{tabId}-{monotonicCounter}`. Cheap to generate, unique within a channel, sortable per-tab. Generated by the sending tab at `mesh.send()` time. +- **Channel name**: scopes the SharedWorker instance, the IndexedDB database, the BroadcastChannel, and the `sessionStorage` keys. Two meshes on the same origin with different `channelName` are independent. + +## Boundary — what TabMesh is and isn't + +- **It is**: a coordination layer for the same-origin multi-tab problem. Hub-and-spoke. Browser-side. +- **It isn't**: cross-origin (browser platform constraint), peer-to-peer (we tried, hub wins), or a state management library (it moves events; you decide how to react). + +## Further reading + +- [`CONTEXT.md`](https://github.com/CodeThicket/tabmesh/blob/main/CONTEXT.md) — domain language, glossary, and design notes +- [ADR-0001](/adr/0001-write-through-outbox) — why write-through and not optimistic +- [ADR-0002](/adr/0002-sharedworker-primary-hub) — why SharedWorker is the primary path +- [ADR-0003](/adr/0003-distribute-prebuilt-worker-bundles) — why we ship pre-built worker bundles diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 4073c90..bc4b384 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,15 +1,159 @@ # Getting Started -::: warning Migrating -This page is being migrated from the [project README](https://github.com/CodeThicket/tabmesh#readme). The full quickstart, install instructions, and first-event walkthrough land in the next docs PR. +This walkthrough takes you from `npm install` to events flowing across two tabs in about five minutes. It assumes you're using Vite — adjust paths for Webpack, Next, or Turbopack as noted. + +## 1. Install + +```bash +pnpm install @tabmesh/core @tabmesh/transport-websocket +# or: npm install / yarn add +``` + +If you're using React, also install the hooks package: + +```bash +pnpm install @tabmesh/react +``` + +## 2. Deploy the SharedWorker bundle + +This is the step most easily missed. TabMesh's `SharedWorker` script needs to be served from your app's origin at a stable URL — it's a separate file, not bundled into your JavaScript. + +`@tabmesh/core` ships the pre-built bundle inside its `dist/`. Copy it into your app's static-asset directory: + +::: code-group + +```bash [Vite / generic SPA] +cp node_modules/@tabmesh/core/dist/tabmesh-worker.js public/ +``` + +```bash [Next.js] +cp node_modules/@tabmesh/core/dist/tabmesh-worker.js public/ +# Files in public/ are served at the URL root, so this becomes /tabmesh-worker.js +``` + +```bash [Webpack with copy-webpack-plugin] +# webpack.config.js +new CopyPlugin({ + patterns: [ + { from: 'node_modules/@tabmesh/core/dist/tabmesh-worker.js', to: '.' }, + ], +}), +``` + +::: + +::: tip Automate it +Add the copy step to your `build` and `dev` scripts so you never forget. Whenever you upgrade `@tabmesh/core`, the new bundle is copied automatically. ::: -For now: see the [README quickstart](https://github.com/CodeThicket/tabmesh#quick-start). +The default `workerUrl` is `/tabmesh-worker.js`. If you serve it elsewhere (e.g. behind a CDN at `/_static/tabmesh-worker.js`), pass `workerUrl: '/_static/tabmesh-worker.js'` to the constructor. + +## 3. Construct and start the mesh + +```ts +import { TabMesh } from '@tabmesh/core'; +import { WebSocketTransport } from '@tabmesh/transport-websocket'; + +const mesh = new TabMesh({ + channelName: 'my-app', + transport: new WebSocketTransport({ url: 'wss://api.example.com/events' }), + // Strongly recommended in production. See the gotcha on SharedWorker + // name caching for why. + workerVersion: process.env.GIT_SHA, +}); + +await mesh.start(); +``` + +`channelName` is the only required field. It scopes the SharedWorker name, the IndexedDB database, and the BroadcastChannel — pick something unique to your app, and incorporate session identity if you serve multiple tenants on the same origin (e.g. `'my-app:tenant-42'`). + +`mesh.start()` is async because it has to handshake with the SharedWorker. It resolves successfully even if the transport fails to connect — the mesh keeps retrying in the background and you can subscribe to `transport.*` events to react. + +## 4. Send and receive events + +```ts +// Subscribe — this handler fires for events from this tab AND from other tabs. +mesh.on('chat.message', (event) => { + console.log(event.payload, 'source:', event.source); +}); + +// Send — reaches the backend AND every other tab on the same origin. +await mesh.send({ + type: 'chat.message', + payload: { text: 'Hello' }, +}); +``` + +The handler's `event.source` is `'local'` when the event came from this tab and `'remote'` when it came from another tab or the backend. You can filter: + +```ts +mesh.on('chat.message', (event) => { + if (event.source === 'remote') { + showNotification(`New message: ${event.payload.text}`); + } +}); +``` + +## 5. Open two tabs + +Open your app in two browser tabs at the same origin. Send an event from one — the other receives it as `source: 'remote'`. Inspect the network tab: there's only **one** WebSocket, regardless of how many tabs are open. That's a `SharedWorker` doing its job. + +## React quickstart + +If you're using `@tabmesh/react`: + +```tsx +import { TabMeshProvider, useTabMesh, useTabMeshEvent } from '@tabmesh/react'; +import { mesh } from './mesh'; // your TabMesh instance + +function App() { + return ( + + + + ); +} + +function Chat() { + const { status, send } = useTabMesh(); + + useTabMeshEvent('chat.message', (event) => { + // handle incoming + }); + + return ( +
+

Hub: {status.hubMode}, transport: {status.transportState}

+ +
+ ); +} +``` + +The provider is optional — you can also pass the `mesh` instance directly as the first argument to each hook. + +→ [Full React guide](./react) + +## Troubleshooting + +**"Failed to construct 'SharedWorker'" / 404 on `/tabmesh-worker.js`** +You skipped step 2 or the file isn't being served. Check the network tab for the request to `/tabmesh-worker.js` and verify the response is a 200 with a JavaScript MIME type. + +**"Transport is reconnecting" forever** +Check `wss://` URL is correct and reachable. The mesh logs each reconnect attempt as a `transport.reconnecting` system event — subscribe with `mesh.on('*', console.log)` to watch. + +**Events from other tabs aren't appearing** +Verify both tabs are on the **same origin** (protocol + host + port must match). `https://app.example.com:443` and `https://app.example.com` are the same. `https://www.example.com` and `https://example.com` are not. + +**Status panel says "Transport: disconnected" even though sends work** +This was a real bug fixed in PR #7. If you're seeing it on a current version, please [open an issue](https://github.com/CodeThicket/tabmesh/issues/new). -## What's here next +## What's next -- Install commands for `@tabmesh/core` + `@tabmesh/transport-websocket` (+ optional `@tabmesh/react`). -- The "copy the worker bundle to `public/`" step (per [ADR-0003](/adr/0003-distribute-prebuilt-worker-bundles)). -- Minimal example: send and receive an event across two tabs. -- React hooks variant. -- First time troubleshooting. +→ [Architecture](./architecture) — how the SharedWorker, outbox, and elected-leader fallback fit together +→ [Gotchas](./gotchas) — sharp edges to know about before adopting in production (read this!) +→ [Configuration reference](/reference/config) — every field, every default +→ [Try the playground](/playground) — interactive multi-tab demo diff --git a/docs/guide/gotchas.md b/docs/guide/gotchas.md index 7870b42..17f1d39 100644 --- a/docs/guide/gotchas.md +++ b/docs/guide/gotchas.md @@ -1,15 +1,140 @@ # Gotchas -::: warning Migrating -The full gotchas list — the most important page for adopters — is being moved here from the [README gotchas section](https://github.com/CodeThicket/tabmesh#gotchas). Read the README until this page is filled in. -::: +A small library this protocol-heavy has sharp edges. Read this page before adopting in production — most adoption pain comes from one of these six items. -The shortlist, until this page expands: +## SharedWorker name caching → set `workerVersion` per deploy -- **SharedWorker name caching** → set `workerVersion` per deploy. -- **`delivered` ≠ "the backend processed it"** → wait for explicit ACK if you need it; `ackMode: 'server'` is on the [roadmap](/roadmap). -- **No replay buffer for late-joining tabs** — by design. -- **In-browser only**, same-origin only. -- **Service Worker handoff requires `deliveryUrl`** — without it, the SW leaves pending events in the outbox for the next Hub session. +Browsers cache `SharedWorker` instances by **name**, not by script content. Without a per-deploy version suffix, an updated `tabmesh-worker.js` doesn't reach users until every client tab closes **AND** the browser garbage-collects the idle worker — which can take many minutes. New tabs in the same browser session in the meantime keep using the **old** worker. -→ Read the [README gotchas](https://github.com/CodeThicket/tabmesh#gotchas) for the explanations until this page fills out. +This bit hard during development when shipping bug fixes (see [PR #11](https://github.com/CodeThicket/tabmesh/pull/11) for the full discovery). + +**Fix**: include your build identifier in `workerVersion`: + +```ts +new TabMesh({ + channelName: 'my-app', + workerVersion: process.env.GIT_SHA, + // also accepts: package.json version, release tag, build timestamp, ... +}); +``` + +What happens with `workerVersion` set: + +- Each deploy spawns a fresh `SharedWorker` instance for new tabs. +- Old tabs keep talking to the old worker until they reload — natural migration, no surprise behaviour. +- The same `channelName` can coexist across versions during the rollout window. + +What happens without it: the bug fix you shipped is invisible for an unbounded amount of time. + +→ Implementation detail: the worker name becomes `tabmesh:{channelName}:{workerVersion}`. Without the version, it's just `tabmesh:{channelName}`. + +## `delivered` ≠ "the backend processed it" + +Today the outbox marks an event `delivered` once `Transport.send()` returns successfully — i.e. the bytes left the browser. It does **not** wait for a backend acknowledgement. + +If you need at-least-once delivery semantics, gate on an explicit ack message in your protocol layer: + +```ts +mesh.send({ type: 'order.create', payload: order }); + +// Wait for the backend to confirm. Your backend should echo an event +// with the same id once the work is durable. +await new Promise((resolve, reject) => { + const off = mesh.on('order.created', (event) => { + if (event.source === 'remote' && event.meta.eventId === order.id) { + off(); + resolve(event); + } + }); + setTimeout(() => { off(); reject(new Error('timeout')); }, 10_000); +}); +``` + +A built-in `ackMode: 'server'` option is on the [roadmap](/roadmap) — when it lands, events stay `pending` in the outbox until the backend sends an explicit ack message. Until then, the protocol-level pattern above is the documented approach. + +## No replay buffer for late-joining tabs + +A tab that opens after another tab broadcast an event will **not** receive that event. There is no historical log. New tabs start with a clean slate. + +This is by design ([`CONTEXT.md → Inbound Event Flow`](https://github.com/CodeThicket/tabmesh/blob/main/CONTEXT.md#events)). The rationale is that maintaining a replay buffer cross-tab is a tarpit — TTLs, ordering, deduplication, memory bounds — and your backend already has authoritative state for anything important. + +**Pattern**: when a new tab connects, fetch the current state from your backend. Subscribe to ongoing events for incremental updates. + +```ts +async function bootstrap() { + const initialState = await fetch('/api/state').then(r => r.json()); + applyState(initialState); + + mesh.on('order.updated', (event) => applyDelta(event.payload)); +} +``` + +If you genuinely need an opt-in replay buffer (e.g. for chat history within an active session), it's on the [Considering tier of the roadmap](/roadmap) — open an issue with your use case. + +## In-browser only + +TabMesh is browser-side, same-origin only. **Not** for: + +- **Node.js** or **Bun** runtimes — no `SharedWorker`, no `BroadcastChannel` semantics that match. +- **React Native** — same reason. +- **Cross-origin** coordination — `https://app.example.com` and `https://admin.example.com` cannot share a `SharedWorker`. This is a browser platform constraint, not a TabMesh limitation. +- **Cross-browser-profile** or **cross-incognito-window** coordination — same constraint. + +If your tabs span subdomains, the standard workaround is to host your app on a single subdomain (e.g. `app.example.com`) and route within it. If you really need cross-origin event coordination, you need a backend pubsub channel — TabMesh isn't the right tool. + +## Mobile fallback paths get less coverage + +The SharedWorker primary path is exercised by both unit tests and the [Playwright harness](https://github.com/CodeThicket/tabmesh/blob/main/e2e/multi-tab.spec.ts). The elected-leader fallback path gets: + +- Unit tests for leader election (Web Locks, BroadcastChannel heartbeat, IndexedDB heartbeat strategies) +- One Playwright e2e test for failover ([PR #15](https://github.com/CodeThicket/tabmesh/pull/15)) +- Split-brain resolution is unit-tested but not end-to-end + +Mobile carriers and OS power management can throttle `BroadcastChannel` and Web Locks in ways that are hard to reproduce in CI: + +- Background tabs may have their timers coalesced or paused. +- iOS Safari may suspend `BroadcastChannel` in low-power mode. +- Chrome on Android can put inactive workers to sleep aggressively. + +**If you're shipping a mobile-heavy product**, exercise the fallback path on real devices before depending on TabMesh for anything critical. Specifically: test what happens when the leader tab is backgrounded for >30 seconds, and verify failover to a foreground tab. + +## Service Worker handoff requires `deliveryUrl` + +The Service Worker can drain pending events from IndexedDB **after all tabs close**, but it has nowhere to send them by default. You must configure `serviceWorker.deliveryUrl` to an HTTP endpoint that accepts JSON event POSTs: + +```ts +new TabMesh({ + channelName: 'my-app', + serviceWorker: { + enabled: true, + deliveryUrl: '/api/events', + }, +}); +``` + +Without `deliveryUrl`, the SW leaves pending entries in the outbox for the next Hub session to drain. This was previously a silent data-loss bug — the SW would mark events as delivered without sending them anywhere (fixed in [PR #10](https://github.com/CodeThicket/tabmesh/pull/10)). + +The backend endpoint receives events as JSON `POST` with the shape: + +```json +{ + "type": "order.create", + "payload": { "...": "..." }, + "id": "abc123-1", + "sourceTabId": "abc123" +} +``` + +Return `200` to mark the event delivered. Non-200 (or network failure) leaves it pending for the next sync. The browser schedules Background Sync at its own discretion — there's no API to force it from the page side. + +## Bonus: drain happens once per tab, even on reconnect + +This isn't a gotcha you'll likely hit, but worth knowing for debugging: when the WebSocket drops and reconnects, the worker doesn't re-fan-out previously-distributed events to your tab. It only retries the WS forward. So if your tab's UI dropped an event during a transient disconnect (e.g. the user navigated away from the page), reloading is the recovery path — TabMesh won't replay it. + +Same reasoning as "no replay buffer for late-joining tabs": cross-session reliable delivery is your backend's job. + +## What's next + +→ [Architecture](./architecture) +→ [Roadmap](/roadmap) +→ [Open an issue](https://github.com/CodeThicket/tabmesh/issues/new) if you hit a gotcha not listed here diff --git a/docs/guide/react.md b/docs/guide/react.md index eb10553..51ea6ca 100644 --- a/docs/guide/react.md +++ b/docs/guide/react.md @@ -1,13 +1,194 @@ # React -::: warning Migrating -React-specific quickstart, hooks reference, and provider example arrive in the next docs PR. +`@tabmesh/react` ships three pieces: + +- `` — context provider for the mesh instance +- `useTabMesh()` — returns `{ status, send }` and re-renders when status changes +- `useTabMeshEvent(type, handler)` — subscribe to an event type (or `'*'` for all) + +The package is small (~40 lines) and stays close to the underlying `mesh.on()` / `mesh.getStatus()` API. + +## Setup + +```bash +pnpm install @tabmesh/core @tabmesh/transport-websocket @tabmesh/react +``` + +Create the mesh in its own module so it's a singleton: + +```ts +// src/mesh.ts +import { TabMesh } from '@tabmesh/core'; +import { WebSocketTransport } from '@tabmesh/transport-websocket'; + +export const mesh = new TabMesh({ + channelName: 'my-app', + transport: new WebSocketTransport({ url: 'wss://api.example.com' }), + workerVersion: import.meta.env.VITE_GIT_SHA, +}); + +await mesh.start(); +``` + +Wrap your app in ``: + +```tsx +// src/main.tsx +import { createRoot } from 'react-dom/client'; +import { TabMeshProvider } from '@tabmesh/react'; +import { mesh } from './mesh'; +import { App } from './App'; + +createRoot(document.getElementById('root')!).render( + + + , +); +``` + +::: tip Provider is optional +If you'd rather not use context, every hook accepts the `mesh` instance as its first argument: + +```tsx +const { status, send } = useTabMesh(mesh); +useTabMeshEvent(mesh, 'chat.message', handler); +``` + +Use the provider when most components need the same mesh; pass explicitly for one-off cases or testing. ::: -`@tabmesh/react` ships: +## `useTabMesh` + +Returns the current status and a stable `send` function. Re-renders when status changes (hub-connected, transport-state, role, degraded, etc.). + +```tsx +import { useTabMesh } from '@tabmesh/react'; + +function StatusBar() { + const { status, send } = useTabMesh(); + + return ( +
+ Hub: {status.hubMode} · Transport: {status.transportState} + +
+ ); +} +``` + +The returned `send` reference is stable across renders — safe to use in `useEffect` dependency arrays. + +The full `status` shape: + +```ts +{ + started: boolean; + hubMode: 'shared-worker' | 'elected-leader' | 'degraded' | null; + hubConnected: boolean; + role: 'hub' | 'follower' | null; // 'hub' = elected leader in fallback mode + transportState: 'connected' | 'disconnected' | 'reconnecting'; + tabId: string; + degraded: boolean; + leaderTabId?: string | null; // only set in elected-leader mode + term?: number; // election term, only in fallback mode +} +``` + +## `useTabMeshEvent` + +Subscribes to an event type. The handler fires for every matching event from any tab (including this one — filter on `event.source` if you want remote-only). + +```tsx +import { useTabMeshEvent } from '@tabmesh/react'; + +function Inbox() { + const [messages, setMessages] = useState([]); + + useTabMeshEvent('chat.message', (event) => { + setMessages((prev) => [...prev, event.payload]); + }); + + return
    {messages.map(...)}
; +} +``` + +The handler is automatically unsubscribed on unmount. The latest handler ref is always used, so closures over fresh state work without re-subscribing: + +```tsx +function Counter() { + const [count, setCount] = useState(0); + + // Closure captures fresh `count` every render — no stale-closure bug. + useTabMeshEvent('ping', () => { + console.log(`Got ping, current count is ${count}`); + }); + + return ; +} +``` + +### Wildcard subscription + +Pass `'*'` to receive every event including the [system events](/reference/system-events): + +```tsx +useTabMeshEvent('*', (event) => { + if (event.type.startsWith('transport.')) { + console.log('Transport state change:', event.type, event.payload); + } +}); +``` + +The playground's activity feed is built on this — it shows the live event stream as a debug surface. + +## Patterns + +### Showing connection status + +```tsx +function ConnectionBadge() { + const { status } = useTabMesh(); + + if (!status.started) return Offline; + if (status.transportState === 'connected') return Live; + if (status.transportState === 'reconnecting') return Reconnecting…; + return Disconnected; +} +``` + +### Reacting to remote events only + +```tsx +useTabMeshEvent('todo.completed', (event) => { + if (event.source === 'local') return; // already handled in the local UI + showToast(`Another tab completed: ${event.payload.title}`); +}); +``` + +### Logout flow + +The recommended sequence is documented in [Recipes → Auth & logout](/recipes/auth-and-logout). In a React hook: + +```tsx +function useLogout() { + return useCallback(async () => { + await mesh.clearOutbox(); + await mesh.disconnectTransport(); + mesh.broadcast({ type: 'auth.logout', payload: {} }); + await mesh.stop(); + window.location.href = '/login'; + }, []); +} +``` -- `` — context provider for the mesh instance. -- `useTabMesh()` — returns `{ status, send }`. -- `useTabMeshEvent(type, handler)` — subscribe to an event type (or `'*'` for all). +## What's next -Until this page fills out, see the [README React snippet](https://github.com/CodeThicket/tabmesh#react) and the [playground source](https://github.com/CodeThicket/tabmesh/tree/main/packages/playground/src) for a working example. +→ [Configuration reference](/reference/config) +→ [System events](/reference/system-events) +→ [Recipes](/recipes/) — common React patterns diff --git a/docs/reference/config.md b/docs/reference/config.md index 22e8b07..73c364a 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -1,19 +1,215 @@ # Configuration -::: warning Migrating -Field-by-field configuration table lands in the next docs PR. -::: - -The full type is exported from [`@tabmesh/core` types.ts](https://github.com/CodeThicket/tabmesh/blob/main/packages/core/src/types.ts). Until this page is filled out, the [README configuration table](https://github.com/CodeThicket/tabmesh#configuration) covers the same ground. - -Quick reference (defaults in parens): - -- `channelName` (required) -- `transport` (none — transport-less mode is valid) -- `workerUrl` (`/tabmesh-worker.js`) -- `workerVersion` (none — strongly recommended in production) -- `pingMs` (`10000`) -- `staleTimeoutMs` (`30000`) -- `persistence.defaultTTL` (24h), `persistence.maxQueueSize` (1000) -- `reconnect.maxAttempts` (10), `initialDelayMs` (1000), `backoffMultiplier` (2), `maxDelayMs` (30000) -- `serviceWorker.enabled` (`false`), `serviceWorker.scriptUrl` (`/tabmesh-sw.js`), `serviceWorker.deliveryUrl` (none — required if you want SW handoff to actually deliver) +`TabMeshConfig` is the single options object passed to `new TabMesh(config)`. All fields are type-safe; the canonical source is [`packages/core/src/types.ts`](https://github.com/CodeThicket/tabmesh/blob/main/packages/core/src/types.ts). + +## Required + +### `channelName: string` + +App-level identifier that scopes the SharedWorker name, the IndexedDB database, the BroadcastChannel, and the `sessionStorage` keys. **Required.** + +```ts +new TabMesh({ channelName: 'my-app' }); +``` + +Recommended pattern when serving multiple tenants on the same origin: + +```ts +new TabMesh({ channelName: `my-app:${tenantId}:${userId}` }); +``` + +Multiple meshes with different `channelName` on the same origin are independent — each gets its own SharedWorker instance and IndexedDB database. This costs one WebSocket per channel, so don't sub-divide more than you need to. + +## Transport + +### `transport?: Transport` + +Backend connection adapter. Today this is `@tabmesh/transport-websocket`; SSE and long-poll adapters are on the [roadmap](/roadmap). + +```ts +import { WebSocketTransport } from '@tabmesh/transport-websocket'; + +new TabMesh({ + channelName: 'my-app', + transport: new WebSocketTransport({ url: 'wss://api.example.com' }), +}); +``` + +**Transport-less mode**: omit `transport` entirely. The mesh still works for cross-tab event broadcasting; it just has no backend to forward events to. Useful for local-only coordination patterns. + +### `reconnect?: Partial` + +Configures the transport reconnection loop. Defaults shown: + +```ts +new TabMesh({ + channelName: 'my-app', + transport: ..., + reconnect: { + maxAttempts: 10, // retry cap; emits transport.error after this + initialDelayMs: 1000, // first reconnect delay + backoffMultiplier: 2, // exponential factor + maxDelayMs: 30000, // backoff ceiling + }, +}); +``` + +Real-world reconnect sequence with defaults: 1s, 2s, 4s, 8s, 16s, 30s, 30s, … (capped). + +## SharedWorker + +### `workerUrl?: string` — default `/tabmesh-worker.js` + +Where the SharedWorker script is served from. The file must be served from the same origin as the page (browser security requirement). + +```ts +new TabMesh({ + channelName: 'my-app', + workerUrl: '/_static/tabmesh-worker.js', // behind a CDN/cache layer +}); +``` + +→ Deploying the worker bundle: see [Getting Started → Deploy the SharedWorker bundle](/guide/getting-started#_2-deploy-the-sharedworker-bundle). + +### `workerVersion?: string` — **strongly recommended** + +Build-time version string appended to the SharedWorker `name`. Without it, deploy upgrades don't propagate to existing browser sessions. + +```ts +new TabMesh({ + channelName: 'my-app', + workerVersion: process.env.GIT_SHA, +}); +``` + +→ Why this matters: see [Gotchas → SharedWorker name caching](/guide/gotchas#sharedworker-name-caching-set-workerversion-per-deploy). + +### `pingMs?: number` — default `10000` + +Interval (ms) between tab-to-worker keepalive pings. Lower values detect tab freeze faster; higher values reduce wakeups in idle apps. + +### `staleTimeoutMs?: number` — default `30000` + +How long (ms) the SharedWorker waits before treating a port as stale and evicting it from the registry. The **first** connecting tab's value wins for the lifetime of the worker; subsequent tabs cannot change it. + +These two values are tuned together: `pingMs < staleTimeoutMs / 2` is the safe ratio. Lower values are useful for tests but not for production. + +## Persistence / outbox + +### `persistence?: Partial` + +```ts +new TabMesh({ + channelName: 'my-app', + persistence: { + defaultTTL: 86_400_000, // 24h — events past createdAt+ttl are dropped at drain + maxQueueSize: 1000, // outbox cap; eviction prefers oldest delivered → oldest pending + }, +}); +``` + +The outbox uses IndexedDB by default. When IndexedDB is unavailable (private browsing on some browsers), it falls back to an in-memory queue and emits a `storage.degraded` system event with a `console.warn`. The API surface is identical in degraded mode but events don't survive tab close. + +## Service Worker handoff + +### `serviceWorker?: Partial` + +Optional Background Sync integration that drains pending events after all tabs close. + +```ts +new TabMesh({ + channelName: 'my-app', + serviceWorker: { + enabled: true, + scriptUrl: '/tabmesh-sw.js', // default + deliveryUrl: '/api/events', // required for handoff to actually deliver + }, +}); +``` + +- `enabled` — default `false`. The SW is registered only when this is `true`. +- `scriptUrl` — where the Service Worker script is served from. Defaults to `/tabmesh-sw.js`. +- `deliveryUrl` — HTTP endpoint the SW POSTs pending events to during sync. **Without it, the SW leaves entries in the outbox for the next Hub session.** + +→ Full details: [Gotchas → Service Worker handoff requires `deliveryUrl`](/guide/gotchas#service-worker-handoff-requires-deliveryurl) and [Recipes → Service Worker handoff](/recipes/service-worker-handoff). + +## Leader election (fallback mode only) + +### `leader?: Partial` + +```ts +new TabMesh({ + channelName: 'my-app', + leader: { + strategy: 'auto', // 'auto' | 'web-locks' | 'broadcast-heartbeat' | 'indexeddb-heartbeat' + }, +}); +``` + +By default `auto` selects the best available strategy based on browser support. Override only when debugging election behaviour on specific platforms. See [Architecture → Fallback mode](/guide/architecture#fallback-mode-elected-leader) for the strategy details. + +## Per-event options (passed to `mesh.send`) + +These aren't part of `TabMeshConfig` but worth documenting in one place — they go on each `OutboundEvent`: + +### `priority?: number` — default `0` + +Higher values drain first. Useful for getting an urgent event out before a backlog of routine ones. + +```ts +await mesh.send({ + type: 'auth.logout', + payload: {}, + priority: 100, +}); +``` + +Priority does **not** affect TTL or delivery guarantees, only drain order. + +### `ttl?: number` — milliseconds + +Relative to event creation time. Events past `createdAt + ttl` are discarded at the next drain — never delivered to the backend, never re-broadcast to tabs. + +```ts +await mesh.send({ + type: 'presence.heartbeat', + payload: { tabId }, + ttl: 5_000, // drop if not delivered within 5s +}); +``` + +## Full example + +```ts +import { TabMesh } from '@tabmesh/core'; +import { WebSocketTransport } from '@tabmesh/transport-websocket'; + +const mesh = new TabMesh({ + // Required + channelName: `chat:${tenantId}:${userId}`, + transport: new WebSocketTransport({ url: 'wss://chat.example.com' }), + + // Strongly recommended + workerVersion: process.env.GIT_SHA, + + // Reconnect tuning + reconnect: { + maxAttempts: 20, + maxDelayMs: 60_000, + }, + + // Outbox sizing + persistence: { + defaultTTL: 60 * 60 * 1000, // 1h + maxQueueSize: 5000, + }, + + // Background sync + serviceWorker: { + enabled: true, + deliveryUrl: '/api/events', + }, +}); + +await mesh.start(); +``` diff --git a/docs/reference/system-events.md b/docs/reference/system-events.md index dc03a19..14b48e7 100644 --- a/docs/reference/system-events.md +++ b/docs/reference/system-events.md @@ -1,18 +1,162 @@ # System events -::: warning Migrating -Per-event payload shapes and emission rules land in the next docs PR. -::: - -`mesh.on('*', handler)` sees every event including these system ones: - -- `hub.connected` -- `hub.disconnected` -- `transport.connected` -- `transport.disconnected` -- `transport.reconnecting` -- `transport.error` -- `event.delivery.failed` -- `storage.degraded` - -Until this page expands, the canonical source is [`packages/core/src/types.ts`](https://github.com/CodeThicket/tabmesh/blob/main/packages/core/src/types.ts) (search for `SystemEventType`). +TabMesh emits internal lifecycle and runtime events alongside your application events. Subscribe with `mesh.on(type, handler)` for a specific event, or `mesh.on('*', handler)` to receive everything. + +System events are differentiated from app events only by their `type` strings — same `TabMeshEvent` shape, same handler API. + +## Hub lifecycle + +### `hub.connected` + +This tab has connected to the Hub (SharedWorker or elected Leader). Emitted once during `mesh.start()`. + +```ts +{ + type: 'hub.connected', + payload: { tabId: string; hubMode: 'shared-worker' | 'elected-leader' | 'degraded' }, + source: 'local', + meta: { ... }, +} +``` + +### `hub.disconnected` + +This tab has lost contact with the Hub. Emitted when `mesh.stop()` runs, or when the Hub goes away unexpectedly (e.g. SharedWorker terminated by the browser). + +```ts +{ + type: 'hub.disconnected', + payload: { tabId: string }, + source: 'local', + meta: { ... }, +} +``` + +## Transport lifecycle + +### `transport.connected` + +The backend transport (WebSocket today) has opened. For late-joining tabs that arrive after the connection was already open, this event is synthesised on handshake so the status panel reflects reality (see [PR #7](https://github.com/CodeThicket/tabmesh/pull/7)). + +```ts +{ + type: 'transport.connected', + payload: {}, + source: 'local', + meta: { ... }, +} +``` + +### `transport.disconnected` + +The transport closed. Could be a network drop (retries will kick in) or an explicit `mesh.disconnectTransport()` call (no retries). + +```ts +{ + type: 'transport.disconnected', + payload: { reason?: string }, + source: 'local', + meta: { ... }, +} +``` + +When `reason: 'explicit'`, the disconnect was intentional (e.g. logout flow). Reconnects are suppressed. + +### `transport.reconnecting` + +The transport is retrying. Includes the attempt number and the delay until the next try. + +```ts +{ + type: 'transport.reconnecting', + payload: { attempt: number; delayMs: number }, + source: 'local', + meta: { ... }, +} +``` + +### `transport.error` + +A transport-level error occurred. Includes a message; payload shape varies by transport adapter. + +```ts +{ + type: 'transport.error', + payload: { message: string; reason?: string; attempts?: number }, + source: 'local', + meta: { ... }, +} +``` + +`reason: 'max_retries_exhausted'` is emitted by the elected-leader hub when the reconnect cap is hit (the SharedWorker hub retries forever). + +## Delivery failures + +### `event.delivery.failed` + +The Hub exhausted retries (or failed for a non-retriable reason) for a specific event. Includes the `eventId` so you can correlate with the original send. + +```ts +{ + type: 'event.delivery.failed', + payload: { eventId: string; reason: string }, + source: 'local', + meta: { ... }, +} +``` + +Common `reason` values: + +- `transport_send_failed` — the transport threw when forwarding the event. The event stays in the outbox for the next drain. + +## Storage degraded + +### `storage.degraded` + +IndexedDB is unavailable; the mesh has fallen back to an in-memory outbox. Events sent during this session won't survive a tab close. + +```ts +{ + type: 'storage.degraded', + payload: { reason: string }, + source: 'local', + meta: { ... }, +} +``` + +The mesh also logs a `console.warn` when this fires, and disables Service Worker handoff (no IndexedDB → nothing for the SW to drain). + +Common causes: private browsing on Safari, sandboxed iframes, browser storage quota exceeded. + +## Wildcard subscription + +`mesh.on('*', handler)` receives every event — system and application — in delivery order. Useful for: + +- Debug surfaces (the playground's Activity Feed uses this) +- Logging / telemetry +- Building DevTools-style introspection + +```ts +mesh.on('*', (event) => { + if (event.type.startsWith('transport.')) { + metrics.recordTransportEvent(event.type, event.payload); + } +}); +``` + +## `event.meta` + +Every event includes a `meta` object with debug-only fields. Public: + +- `meta.eventId: string` — the unique id of the event (matches what `send` returned). +- `meta.sourceTabId: string` — the tab id that originated the event. +- `meta.createdAt: number` — timestamp at send time. + +Internal (for debugging only, not part of the public API): + +- `meta.internalSource: 'port' | 'broadcast' | 'transport'` — which mechanism delivered the event to this tab. + +## What's next + +→ [TabMesh class reference](./tabmesh-class) +→ [Types](./types)