# 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/<name>.channel.ts` is re-exported from the package root barrel. A feature's `realtime/handlers/<name>.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:<name>")`. 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:<name>").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/<feature>/src/
  realtime/
    <name>.channel.ts                      ← channel descriptor (re-exported from root barrel)
    handlers/
      on-<name>.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 `// <gen:*>` anchor comments (`// <gen:realtime-channels>` in `src/index.ts`, `// <gen:realtime-handler-symbols>` in `src/di/symbols.ts`, `// <gen:realtime-handlers>` 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 <feature> <slug> <scope>` — scaffolds `<slug>.channel.ts` + test; threads the re-export through `<gen:realtime-channels>`.
- `pnpm turbo gen realtime --args handler <feature> <channel-slug>` — scaffolds `on-<channel>.handler.ts` + test; threads the wrapped registration through `<gen:realtime-handler-symbols>` and both `<gen:realtime-handlers>` 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<TName, TSchema>` 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<BindAllDeps>)` 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 `// <gen:realtime-*>` 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)
