# ADR-016 — Realtime layer (Socket.IO) **Status:** Optional — scaffold via `pnpm turbo gen core-package realtime`. The package is not in the default template; this ADR documents the design that the generator emits. **Date:** 2026-05-08 ## Context Until this ADR the monorepo had no mechanism for the server to push state to a connected browser without polling. tRPC covers request/response; the event bus from ADR-015 covers in-process cross-feature publish/subscribe. Neither delivers a server-side state change to an open browser tab without polling. Two concerns drove this work. First, establishing the abstraction seam — the same logic that motivated `IEventBus` and `IJobQueue` in ADR-015: define the interface once so individual features can adopt realtime on demand without each designing its own socket integration. Second, an admin live-observability dashboard that streams event/job traffic is the first concrete consumer of the seam, but its UI work is large enough for a separate PR. v1 therefore ships a built-in `realtime-ping` channel as the proof-of-life and defers the dashboard. The vendor-isolation principle from ADR-014 and ADR-015 carries over without modification: the wire-protocol library (`socket.io`) is hidden behind `IRealtimeBroadcaster` / `IRealtimeServer` interfaces. Feature packages MUST NOT import `socket.io` directly. ## Decision **1. Three rules, ESLint-enforced.** - **R0 — Realtime is for state delivery, not for replacing tRPC.** Persistent operations with request/response semantics belong on tRPC procedures. Use realtime when (a) the server needs to push without a request, or (b) the data is too high-frequency for HTTP. - **R1 — Channel descriptors are exported; handlers are private.** A feature's `realtime/.channel.ts` is re-exported from the package root barrel. A feature's `realtime/handlers/.handler.ts` is wired only inside that feature's own `bind-production` / `bind-dev-seed` and is never re-exported from any subpath. Custom rule `no-realtime-handler-reexport` enforces this — parallel to ADR-015's `no-handler-reexport` for event handlers. - **R2 — `socket.io` lives in one package only.** Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. The only allowlist entries are `packages/core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this — parallel to ADR-015's `no-direct-payload-jobs`. **2. New package `@repo/core-realtime`.** Parallel in shape to `@repo/core-events` from ADR-015. Tagged `core`. Exports pure TypeScript interfaces (`IRealtimeBroadcaster`, `IRealtimeServer`, `IRealtimeAuthenticator`, `IRealtimeHandlerRegistry`), the `defineRealtimeChannel` factory, four scope kinds (`"public"`, `"authenticated"`, `{ role }`, `{ userScoped }`), and Socket.IO adapter classes (`SocketIORealtimeBroadcaster`, `SocketIORealtimeServer`). Also exports `InMemoryRealtimeBroadcaster` for dev/test use — no Socket.IO dependency, stores broadcasts in memory. Feature packages depend on interfaces only; the Socket.IO adapter classes are consumed exclusively by the app's custom Node server. **3. Four auth checkpoints, pure function authorization.** The connect handler (gate 1) reads the session cookie, calls `IRealtimeAuthenticator.authenticate()`, and attaches `{ userId, roles } | null` to `socket.data.user`. Channel subscribe (gate 2) matches the requested name against registered descriptors (template-aware for `"notifications.user.{userId}"`-style channels), calls `authorize(descriptor, params, user)`, and on success calls `socket.join("ch:")`. Inbound message (gate 3) re-validates schema and re-applies `authorize` as defense-in-depth, then invokes the wrapped handler with `ctx = { userId, roles }`. Broadcast (gate 4) has no gate — `io.to("ch:").emit(...)` fans out to whoever cleared gate 2; subscribe is the single source of truth. `authorize` is a pure function with no DB hit. **4. Hybrid bus-bridge / direct-broadcast model.** The existing `IEventBus` from ADR-015 is reused as a _third consumer_ in a bridge pattern: a `bindRealtimeBridge(bus, broadcaster, allowlist)` step in `bindAll()` subscribes to allowlisted bus events and forwards them onto realtime channels. The bridge allowlist ships empty in v1; the first entries land with the dashboard PR. Direct broadcast (feature use case adds `realtime: IRealtimeBroadcaster` to its factory deps and calls `realtime.broadcast(channel, payload)`) is the primary path; the bridge is for cases where bus events already exist and realtime is additive. **5. Custom Node server for `apps/web-next`.** `apps/web-next/server.ts` replaces `next start` / `next dev` as the boot entry. Both Next.js and Socket.IO share one http server on port 3000. The `bindAll()` dispatcher gains two new resolution steps: `resolveRealtime()` (picks `InMemoryRealtimeBroadcaster` vs `SocketIORealtimeBroadcaster` by env) and the bridge wiring call. `bindAll(deps?)` is optional — callers may pass pre-constructed broadcaster/registry instances (the server does) or omit them and receive `InMemoryRealtimeBroadcaster` defaults (page-handler callers, existing tests). A `bound` guard ensures the Noop defaults are never silently accepted in production. **6. Per-feature folder layout (all optional).** ``` packages//src/ realtime/ .channel.ts ← channel descriptor (re-exported from root barrel) handlers/ on-.handler.ts ← inbound handler (private, never re-exported) ``` Top-level `realtime/`, not under `integrations/`. Channel descriptors are descriptor-shaped (same shape as event descriptors from ADR-015, which also live at top level in `events/`). There is no per-feature transport code — the Socket.IO server lives once in `core-realtime`. **7. Three new anchors per feature, two new generators.** All five existing features plus the `feature` generator template gain three new `// ` anchor comments (`// ` in `src/index.ts`, `// ` in `src/di/symbols.ts`, `// ` in both `bind-*.ts` files). The CI anchor guard in `packages/core-eslint/anchors.test.js` extends to assert these stay present. - `pnpm turbo gen realtime --args channel ` — scaffolds `.channel.ts` + test; threads the re-export through ``. - `pnpm turbo gen realtime --args handler ` — scaffolds `on-.handler.ts` + test; threads the wrapped registration through `` and both `` anchors. Handlers are wrapped in the same `withSpan(tracer, { op: "realtime-handler" }, withCapture(logger, { layer: "realtime-handler" }, handlerFactory(deps)))` sandwich as use cases and controllers (ADR-014 R41–R44). ## Alternatives considered - **WebSockets without Socket.IO.** Rejected — Socket.IO's room-based fan-out, built-in reconnect, and namespace support cover the subscription / user-scoped-channel use cases cleanly. The vendor-isolation interface means swapping later is one adapter rewrite. - **Server-Sent Events (SSE).** Considered for server-push-only scenarios. Rejected — the inbound handler (client → server) use case rules it out, and maintaining two separate realtime primitives for push vs bidirectional adds protocol surface without architectural benefit. - **Merge realtime into `core-shared`.** Rejected — `core-realtime` depends on `socket.io` for the production adapter. Adding that dep to `core-shared` would force every feature to transitively pull the SDK, violating the vendor-isolation principle that ADR-014 and ADR-015 both enforce. A new package keeps the dep graph minimal. - **No bridge; direct broadcast only.** Considered and partially adopted (the bridge ships empty in v1). Rejected as the permanent answer — cases where a bus event should fan out to multiple durable consumers AND push to live clients are real (article published → search index + live feed). Two APIs, mutually independent, cover the matrix without forcing one shape on the other. - **Per-feature transport code (each feature owns its socket event names).** Rejected — channel descriptors and `SocketIORealtimeServer` handle the dispatch table centrally. Spreading socket event name strings across feature packages recreates the problem that `EventDescriptor.name` solved in ADR-015. ## Consequences **Positive:** - Server-push to connected browser tabs without polling, across any feature on demand. - Vendor-swappable: replacing Socket.IO means writing one new adapter pair (`IRealtimeBroadcaster` + `IRealtimeServer`). - The existing `IEventBus` is reused as the bridge source; features that already publish events get realtime fan-out for free via one `allowlist` entry. - Four auth checkpoints are centrally managed — features don't each implement cookie parsing or scope authorization. - `RecordingRealtimeBroadcaster` in `core-testing` gives use-case tests a drop-in broadcaster that records calls, mirroring `RecordingEventBus`. **Negative:** - `bindProductionX` / `bindDevSeedX` now take seven arguments `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. Future expansion may warrant collapsing to a single `BindContext` object; deferred. - The custom Node server for `apps/web-next` means `next start` / `next dev` are no longer sufficient entry points. The CMS and TanStack apps still use their existing runtimes until they need realtime. - `InMemoryRealtimeBroadcaster` has no room/socket model — it stores all broadcasts in a flat array. Sufficient for unit testing; insufficient for integration tests that assert specific sockets received a broadcast. The `realtime-ping` integration test uses a real `SocketIORealtimeServer` in-process. ## Notes from execution - **`RecordingRealtimeBroadcaster` scope-type alias widened.** The spec's local type alias `RealtimeChannelDescriptor` in `core-testing` was widened to handle the discriminated-union shape of the actual descriptor correctly. The recorded-broadcast entries use `{ channel: string; payload: unknown }` to avoid tying the recording type to the exact generic parameters. - **`IRealtimeHandlerRegistry` gained `registerChannel` / `listChannels`.** The original spec had only `register` / `getInboundDescriptor` / `list`. `registerChannel` and `listChannels` were added to support outbound-only channel subscription: gate 2 (subscribe authorization) iterates `listChannels` + `register`ed descriptors independently of inbound handler registration, separating the "is this a known channel?" check from "does this channel have an inbound handler?" - **`bindAll(deps?)` is optional with `InMemoryRealtimeBroadcaster` defaults.** Existing page-handler callers that invoke `bindAll()` with no args continue to work without modification. A `bound` guard ensures that in production, where `bindAll()` is always called from `server.ts` with explicit `SocketIORealtimeBroadcaster` / `RealtimeHandlerRegistry` args, the in-memory fallback is never silently wired. ## Out of scope (deferred) 1. **Live observability dashboard.** The first concrete realtime consumer. Bridge allowlist entries land in that PR; v1 ships the allowlist empty. 2. **DB-backed roles / permissions.** `authorize` today reads whatever `IRealtimeAuthenticator` returns. When the auth feature gains a `RolesRepository`, `authenticate()` will return populated `roles` / `permissions` arrays with no changes to `core-realtime`. 3. **Multi-instance fanout.** Redis adapter / sticky sessions for horizontally scaled deployments. The `IRealtimeBroadcaster` interface is the seam. 4. **Custom Node server for `cms` and `web-tanstack`.** Both apps continue on their existing runtimes until they need realtime. 5. **Production-mode e2e test.** The `realtime-ping` integration test exercises the four checkpoints in-process. A multi-socket test that verifies fan-out across N connected clients is deferred to v2. ## Known follow-ups (post-merge polish) Items surfaced by the final branch review that were intentionally not landed in v1: 1. **`bindRealtimeBridge` stub has no test scaffolding.** v1 ships `apps/web-next/src/server/bind-production.ts:bindRealtimeBridge` as a no-op (`_`-prefixed args). The dashboard PR adds the first allowlist entries; a minimal contract-shaped test (e.g. accept `allowlist: BridgeEntry[]` and assert subscribe wiring) should land alongside the first entry, not before. 2. **`IRealtimeHandlerRegistry.register` + `registerChannel` precedence is implicit.** Calling both for the same channel name silently overwrites the channel-map descriptor while leaving the entry-map intact. Behaviour is correct for current callers; document the precedence in the interface JSDoc or reject conflicting re-registration once the second outbound channel ships. 3. **`matchChannelTemplate` placeholders cannot contain dots** (`packages/core-realtime/src/channel-template.ts:14-17` uses `([^.]+)`). Fine for UUID-style identifiers; document the constraint in `defineRealtimeChannel`'s JSDoc when the first non-UUID key shape arrives. 4. **`SocketIORealtimeServer` swallows handler errors with bare `catch {}`** (`packages/core-realtime/src/socket-io-realtime-server.ts:108-117`). Wrapped handlers (`withCapture`) already record the error; unwrapped handlers lose it. Adding a server-injected logger that records "handler_error for channel X" would help debug connection-level issues — defer until a debugging incident actually motivates it. 5. **`bindAll(deps?: Partial)` permits a half-populated deps object** that mixes a real broadcaster with a fresh registry (or vice versa). In practice no caller does this, but the type doesn't enforce all-or-nothing semantics. Tighten to `deps?: BindAllDeps` (full or absent) when the next consumer lands. 6. **AGENTS.md anchor count phrasing.** AGENTS.md says "three fixed `// ` anchor comments per feature." There are three _kinds_ but four placements (the handlers anchor lives in both `bind-production.ts` and `bind-dev-seed.ts`). Tighten to "three anchor kinds across both bind files" when the AGENTS.md is next touched. ## Related - ADR-008 — per-feature DI containers - ADR-010 — Turborepo boundaries - ADR-014 — Instrumentation & Sentry logging (span+capture sandwich reused for realtime handlers) - ADR-015 — Cross-feature events and background jobs (`IEventBus` reused as the bridge source)