Final sweep for setup-process bookkeeping not caught by template-reset-v1. ADRs drop Plan-N qualifiers; spec collapses the historical 11-phase migration table; scaffolding guide drops "Phase added" column; comment prefixes referencing R-numbers in test describes / eslint inline comments are normalized. Architecture-level rule IDs (R40, R52, E0, J0, etc.) are preserved where they serve as stable cross-references in ADRs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
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
Spec: docs/superpowers/specs/2026-05-08-realtime-design.md
Plan: docs/superpowers/plans/2026-05-08-realtime-layer.md
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.tsis re-exported from the package root barrel. A feature'srealtime/handlers/<name>.handler.tsis wired only inside that feature's ownbind-production/bind-dev-seedand is never re-exported from any subpath. Custom ruleno-realtime-handler-reexportenforces this — parallel to ADR-015'sno-handler-reexportfor event handlers. - R2 —
socket.iolives in one package only. Feature packages MUST NOTimport "socket.io"orimport "socket.io-client". The only allowlist entries arepackages/core-realtime/src/socket-io-*.tsandapps/*/server.ts. ESLint ruleno-direct-socket-ioenforces this — parallel to ADR-015'sno-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>— scaffoldson-<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-realtimedepends onsocket.iofor the production adapter. Adding that dep tocore-sharedwould 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
SocketIORealtimeServerhandle the dispatch table centrally. Spreading socket event name strings across feature packages recreates the problem thatEventDescriptor.namesolved 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
IEventBusis reused as the bridge source; features that already publish events get realtime fan-out for free via oneallowlistentry. - Four auth checkpoints are centrally managed — features don't each implement cookie parsing or scope authorization.
RecordingRealtimeBroadcasterincore-testinggives use-case tests a drop-in broadcaster that records calls, mirroringRecordingEventBus.
Negative:
bindProductionX/bindDevSeedXnow take seven arguments(config, tracer, logger, bus, queue, realtime, realtimeRegistry). Future expansion may warrant collapsing to a singleBindContextobject; deferred.- The custom Node server for
apps/web-nextmeansnext start/next devare no longer sufficient entry points. The CMS and TanStack apps still use their existing runtimes until they need realtime. InMemoryRealtimeBroadcasterhas 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. Therealtime-pingintegration test uses a realSocketIORealtimeServerin-process.
Notes from execution
RecordingRealtimeBroadcasterscope-type alias widened. The spec's local type aliasRealtimeChannelDescriptor<TName, TSchema>incore-testingwas 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.IRealtimeHandlerRegistrygainedregisterChannel/listChannels. The original spec had onlyregister/getInboundDescriptor/list.registerChannelandlistChannelswere added to support outbound-only channel subscription: gate 2 (subscribe authorization) iterateslistChannels+registered 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 withInMemoryRealtimeBroadcasterdefaults. Existing page-handler callers that invokebindAll()with no args continue to work without modification. Aboundguard ensures that in production, wherebindAll()is always called fromserver.tswith explicitSocketIORealtimeBroadcaster/RealtimeHandlerRegistryargs, the in-memory fallback is never silently wired.
Out of scope (deferred)
- Live observability dashboard. The first concrete realtime consumer. Bridge allowlist entries land in that PR; v1 ships the allowlist empty.
- DB-backed roles / permissions.
authorizetoday reads whateverIRealtimeAuthenticatorreturns. When the auth feature gains aRolesRepository,authenticate()will return populatedroles/permissionsarrays with no changes tocore-realtime. - Multi-instance fanout. Redis adapter / sticky sessions for horizontally scaled deployments. The
IRealtimeBroadcasterinterface is the seam. - Custom Node server for
cmsandweb-tanstack. Both apps continue on their existing runtimes until they need realtime. - Production-mode e2e test. The
realtime-pingintegration 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:
bindRealtimeBridgestub has no test scaffolding. v1 shipsapps/web-next/src/server/bind-production.ts:bindRealtimeBridgeas a no-op (_-prefixed args). The dashboard PR adds the first allowlist entries; a minimal contract-shaped test (e.g. acceptallowlist: BridgeEntry[]and assert subscribe wiring) should land alongside the first entry, not before.IRealtimeHandlerRegistry.register+registerChannelprecedence 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.matchChannelTemplateplaceholders cannot contain dots (packages/core-realtime/src/channel-template.ts:14-17uses([^.]+)). Fine for UUID-style identifiers; document the constraint indefineRealtimeChannel's JSDoc when the first non-UUID key shape arrives.SocketIORealtimeServerswallows handler errors with barecatch {}(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.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 todeps?: BindAllDeps(full or absent) when the next consumer lands.- 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 bothbind-production.tsandbind-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 (
IEventBusreused as the bridge source) - Spec — docs/superpowers/specs/2026-05-08-realtime-design.md