Files
agentic-dev-template/docs/decisions/adr-016-realtime-layer.md
Danijel Martinek 89d47cce5c docs: strip dead docs/superpowers/ refs across ADRs + guides + glossary
The docs/superpowers/{specs,plans}/ directory was archived to .archive/
in an earlier session (and .archive/ is gitignored). Every md link
into that path is now a broken reference for anyone consuming the
template fresh.

Stripped:
  - ADR-011: **Spec:** header line
  - ADR-015: **Spec:** + **Plan:** header lines
  - ADR-016: **Spec:** + **Plan:** header lines + footer "Spec —"
    bullet (the design rationale is captured in the ADR body itself)
  - ADR-017: **Spec:** + **Plan:** header lines
  - ADR-018: **Spec:** + **Plan:** header lines
  - guides/realtime.md: inline "the full spec" link + footer
    [Spec] entry (folded its description into the ADR-016 entry)
  - guides/events-and-jobs.md: inline "the full spec" link
  - architecture/vertical-feature-spec.md: stale "Deleted" subsection
    referencing docs/superpowers/plans/*

Updated:
  - glossary.md "PRD" entry: clarified status flow now matches the
    shipped pnpm work prd-ship lifecycle (draft -> in-review ->
    approved -> shipped); removed the parenthetical pointing at
    docs/superpowers/specs/ as a definition of "spec"
  - glossary.md "spec" flagged-ambiguity: rewritten to reflect that
    durable design lives in ADRs (docs/decisions/adr-NNN-*.md) and
    implementation seeds live in PRDs (docs/work/prds/*.prd.md) —
    "spec" should be avoided in this template

Preserved (legitimate refs to the SuperPowers plugin, not the dir):
  - agent-first-workflow-and-conformance.md mentions of
    `superpowers:brainstorming` — these reference the external
    plugin skill, not a file in the repo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:00:11 +02:00

104 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 R41R44).
## 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)