From 82002a0ee8d577947df9b2b0f5ea203a104bb1f1 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 20:31:26 +0200 Subject: [PATCH] docs(plan): realtime layer implementation plan (45 tasks, 11 phases) --- .../plans/2026-05-08-realtime-layer.md | 2944 +++++++++++++++++ 1 file changed, 2944 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-realtime-layer.md diff --git a/docs/superpowers/plans/2026-05-08-realtime-layer.md b/docs/superpowers/plans/2026-05-08-realtime-layer.md new file mode 100644 index 0000000..61a9ae7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-realtime-layer.md @@ -0,0 +1,2944 @@ +# Realtime layer (Socket.IO) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a vendor-isolated bidirectional realtime layer over Socket.IO, with cookie-session auth, four channel scope kinds, a hybrid bus-bridge / direct-broadcaster model, two scaffolding generators, and a `realtime-ping` integration smoke test. Defers the observability dashboard, DB-backed roles/permissions, and multi-instance fanout to follow-up PRs. + +**Architecture:** Mirrors the ADR-015 pattern. A new `@repo/core-realtime` package holds vendor-neutral interfaces (`IRealtimeBroadcaster`, `IRealtimeServer`, `IRealtimeAuthenticator`, `RealtimeChannelDescriptor`) plus a `SocketIORealtimeServer` adapter. Feature packages use the interfaces only — `socket.io` is ESLint-blocked outside `core-realtime` and the apps' custom Node servers. Auth runs at four lifecycle gates (connect / subscribe / inbound / broadcast); broadcast skips the gate because subscribe already filters who's in the room. Bus events forward to realtime channels through an explicit per-app allowlist (empty in v1; populated when the dashboard PR ships). + +**Tech Stack:** TypeScript, Node 22, `socket.io` ^4, `socket.io-client` ^4, Zod, Vitest, ESLint, Turborepo, Inversify, Next.js, Payload CMS. + +**Spec:** `docs/superpowers/specs/2026-05-08-realtime-design.md` — read first, especially §3 (interfaces), §7 (auth), §13 (v1 scope). + +--- + +## Phase 0 — Read first + +- [ ] **Step 1: Read the spec end-to-end** + +Open `docs/superpowers/specs/2026-05-08-realtime-design.md` in your editor. The plan below maps to its sections roughly 1:1. The four checkpoints in §7, the authorize function in §7.3, the file shapes in §5, and the v1 scope in §13 are load-bearing references. + +- [ ] **Step 2: Read ADR-015 (events and jobs)** + +Open `docs/decisions/adr-015-events-and-jobs.md`. The realtime layer reuses the same patterns: vendor-isolated interface in core, recording test helper in core-testing, custom ESLint rule against direct vendor imports, anchor protocol for generators, span+capture sandwich at bind time. Familiarity halves the cognitive load. + +- [ ] **Step 3: Skim the navigation feature for the canonical shape** + +Open `packages/navigation/src/di/bind-production.ts` and `packages/navigation/src/di/bind-dev-seed.ts`. They show the post-ADR-015 binder shape `(config, tracer, logger, bus, queue)`. The realtime layer extends this signature to `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. + +--- + +## File map (summary) + +``` +packages/core-realtime/ ← new package, tag: core +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── eslint.config.js +├── turbo.json +├── AGENTS.md +└── src/ + ├── index.ts + ├── symbols.ts + ├── realtime-channel.ts + ├── realtime-broadcaster.interface.ts + ├── realtime-handler.interface.ts + ├── realtime-server.interface.ts + ├── realtime-authenticator.interface.ts + ├── realtime-handler-registry.ts + ├── channel-template.ts + ├── authorize.ts + ├── in-memory-realtime-broadcaster.ts + ├── socket-io-realtime-broadcaster.ts + ├── socket-io-realtime-server.ts + └── realtime-ping.ts ← proof-of-life + +packages/core-testing/src/instrumentation/ +└── recording-realtime-broadcaster.ts ← test helper + +packages/core-eslint/ +├── rules/ +│ ├── no-direct-socket-io.js ← new rule +│ └── no-realtime-handler-reexport.js ← new rule +└── anchors.test.js ← extend with new anchors + +packages//src/ ← all 5 features +├── index.ts ← + // +├── di/ +│ ├── symbols.ts ← + // +│ ├── bind-production.ts ← signature ext + // +│ └── bind-dev-seed.ts ← same +└── (eventually) realtime/ ← optional, generator-driven + +apps/web-next/ +├── server.ts ← NEW: replaces `next start` / `next dev` +├── package.json ← gain socket.io + scripts change +└── src/server/bind-production.ts ← + resolveRealtime() + bindRealtimeBridge() + +turbo/generators/ +├── config.ts ← + realtime generator +├── lib/anchor-validate.ts ← already exists from ADR-015 +└── templates/realtime/ + ├── channel/ + │ ├── channel.ts.hbs + │ └── channel.test.ts.hbs + └── handler/ + ├── handler.ts.hbs + └── handler.test.ts.hbs + +docs/ +├── decisions/adr-016-realtime-layer.md ← NEW +├── guides/realtime.md ← NEW +├── architecture/vertical-feature-spec.md ← § 13 update +├── architecture/dependency-flow.md ← bindAll diagram update +└── superpowers/specs/2026-05-08-realtime-design.md ← already exists +``` + +--- + +## Phase 1 — Scaffold `@repo/core-realtime` package + +### Task 1: Create the package skeleton + +**Files:** +- Create: `packages/core-realtime/package.json` +- Create: `packages/core-realtime/tsconfig.json` +- Create: `packages/core-realtime/vitest.config.ts` +- Create: `packages/core-realtime/eslint.config.js` +- Create: `packages/core-realtime/turbo.json` +- Create: `packages/core-realtime/AGENTS.md` +- Create: `packages/core-realtime/src/index.ts` (empty for now; populated in Phase 2) + +- [ ] **Step 1: Write `package.json`** + +```json +{ + "name": "@repo/core-realtime", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "socket.io": "^4.7.0", + "zod": "^3.23.0" + }, + "peerDependencies": { + "payload": "^3.0.0" + }, + "peerDependenciesMeta": { + "payload": { "optional": true } + }, + "devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0" + } +} +``` + +(No core-testing devDep — same lesson from ADR-015 Task 56. The setup file resolves via the workspace symlinks; we don't need it in deps.) + +- [ ] **Step 2: Write `tsconfig.json`** + +```json +{ + "extends": "@repo/core-typescript/tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*", "*.config.ts"] +} +``` + +- [ ] **Step 3: Write `vitest.config.ts`** + +```ts +import path from "node:path"; +import { mergeConfig } from "vitest/config"; +import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; + +export default mergeConfig(nodeVitestConfig, { + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, +}); +``` + +- [ ] **Step 4: Write `eslint.config.js`** + +```js +import baseConfig from "@repo/core-eslint/base"; +export default baseConfig; +``` + +- [ ] **Step 5: Write `turbo.json` (boundary tag)** + +```json +{ + "$schema": "https://turborepo.dev/schema.json", + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 6: Write a stub `AGENTS.md`** (will be filled in once all the components land — keep this lean for now) + +```markdown +# @repo/core-realtime + +Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`. + +See `docs/superpowers/specs/2026-05-08-realtime-design.md` for the full design and `docs/decisions/adr-016-realtime-layer.md` for the rationale (added during implementation). + +## Public exports + +- `IRealtimeBroadcaster` — server → client broadcasts +- `IRealtimeServer` — lifecycle, used at app boot only +- `IRealtimeAuthenticator` — connect-time identity attachment (cookie / header → user) +- `IRealtimeHandlerRegistry` + `RealtimeHandlerRegistry` — inbound handler registration +- `defineRealtimeChannel`, `RealtimeChannelDescriptor`, `ChannelScope` +- `InMemoryRealtimeBroadcaster` (test/dev), `SocketIORealtimeBroadcaster`, `SocketIORealtimeServer` (production) +- `CORE_REALTIME_SYMBOLS` + +## Boundary + +Tagged `core`. The only place in the repo where `import "socket.io"` is allowed is `src/socket-io-*.ts` here, plus `apps/*/server.ts`. Enforced by the ESLint rule `core-eslint/no-direct-socket-io`. +``` + +- [ ] **Step 7: Write `src/index.ts`** (empty barrel — populated in Phase 2) + +```ts +// Public barrel — populated as components land in Phase 2. +export {}; +``` + +- [ ] **Step 8: Verify the package installs and lints** + +Run: `pnpm install && pnpm --filter @repo/core-realtime lint typecheck` +Expected: PASS (zero TS errors, zero ESLint errors). + +- [ ] **Step 9: Commit** + +```bash +git add packages/core-realtime/ +git commit -m "chore(core-realtime): scaffold package" +``` + +### Task 2: Tag `@repo/core-realtime` in the boundary map + +**Files:** +- Modify: `packages/core-eslint/base.js` (or wherever the boundaries plugin tag map lives — check via `grep -n "boundaries" packages/core-eslint/*.js`) + +- [ ] **Step 1: Find the tag map** + +Run: `grep -n "tag\|boundaries" packages/core-eslint/base.js | head` +Expected: a configuration that lists each package and its tag (`core | core-composition | feature | tooling | app`). + +- [ ] **Step 2: Add `@repo/core-realtime` as `core`** + +Add to the appropriate section: + +```js +{ + type: "core", + pattern: "packages/core-realtime", + mode: "folder", +} +``` + +- [ ] **Step 3: Verify** + +Run: `pnpm turbo boundaries` +Expected: PASS — no new violations. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-eslint/ +git commit -m "chore(core-eslint,turbo): tag @repo/core-realtime as core" +``` + +--- + +## Phase 2 — Interfaces + descriptors (TDD foundation) + +### Task 3: Define `RealtimeChannelDescriptor` + `defineRealtimeChannel` + +**Files:** +- Create: `packages/core-realtime/src/realtime-channel.ts` +- Create: `packages/core-realtime/src/realtime-channel.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core-realtime/src/realtime-channel.test.ts +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +describe("defineRealtimeChannel", () => { + it("returns a descriptor with name, schema, and scope", () => { + const ch = defineRealtimeChannel( + "test.channel", + z.object({ id: z.string() }).strict(), + { scope: "public" }, + ); + expect(ch.name).toBe("test.channel"); + expect(ch.scope).toBe("public"); + expect(() => ch.schema.parse({ id: "x" })).not.toThrow(); + }); + + it("preserves all four scope shapes", () => { + expect(defineRealtimeChannel("a", z.object({}), { scope: "public" }).scope).toBe("public"); + expect(defineRealtimeChannel("a", z.object({}), { scope: "authenticated" }).scope).toBe("authenticated"); + expect(defineRealtimeChannel("a", z.object({}), { scope: { role: "admin" } }).scope).toEqual({ role: "admin" }); + expect( + defineRealtimeChannel("a", z.object({}), { scope: { userScoped: true, template: "x.{id}" } }).scope, + ).toEqual({ userScoped: true, template: "x.{id}" }); + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```ts +// packages/core-realtime/src/realtime-channel.ts +import type { z } from "zod"; + +export type ChannelScope = + | "public" + | "authenticated" + | { role: string } + | { userScoped: true; template: string }; + +export type RealtimeChannelDescriptor = { + readonly name: TName; + readonly schema: TSchema; + readonly scope: ChannelScope; +}; + +export function defineRealtimeChannel( + name: TName, + schema: TSchema, + options: { scope: ChannelScope }, +): RealtimeChannelDescriptor { + return { name, schema, scope: options.scope }; +} +``` + +- [ ] **Step 4: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: PASS — both tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-realtime/src/realtime-channel.ts packages/core-realtime/src/realtime-channel.test.ts +git commit -m "feat(core-realtime): RealtimeChannelDescriptor + defineRealtimeChannel" +``` + +### Task 4: Define `IRealtimeBroadcaster` interface + symbol + +**Files:** +- Create: `packages/core-realtime/src/realtime-broadcaster.interface.ts` +- Create: `packages/core-realtime/src/symbols.ts` + +- [ ] **Step 1: Write the interface** + +```ts +// packages/core-realtime/src/realtime-broadcaster.interface.ts +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export interface IRealtimeBroadcaster { + broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise; +} +``` + +- [ ] **Step 2: Write the symbols registry** + +```ts +// packages/core-realtime/src/symbols.ts +export const CORE_REALTIME_SYMBOLS = { + IRealtimeBroadcaster: Symbol.for("core-realtime:IRealtimeBroadcaster"), + IRealtimeServer: Symbol.for("core-realtime:IRealtimeServer"), + IRealtimeAuthenticator: Symbol.for("core-realtime:IRealtimeAuthenticator"), + IRealtimeHandlerRegistry: Symbol.for("core-realtime:IRealtimeHandlerRegistry"), +} as const; +``` + +- [ ] **Step 3: Verify typecheck** + +Run: `pnpm --filter @repo/core-realtime typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-realtime/src/realtime-broadcaster.interface.ts packages/core-realtime/src/symbols.ts +git commit -m "feat(core-realtime): IRealtimeBroadcaster interface + symbol registry" +``` + +### Task 5: Define `IRealtimeHandler` + `IInboundDescriptor` types + +**Files:** +- Create: `packages/core-realtime/src/realtime-handler.interface.ts` + +- [ ] **Step 1: Write the types** + +```ts +// packages/core-realtime/src/realtime-handler.interface.ts +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export type RealtimeContext = { + userId: string | null; + roles: string[]; +}; + +export type IRealtimeHandler = (input: T, ctx: RealtimeContext) => Promise; + +export type IInboundDescriptor = { + readonly descriptor: RealtimeChannelDescriptor; + readonly handler: IRealtimeHandler>; +}; +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm --filter @repo/core-realtime typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-realtime/src/realtime-handler.interface.ts +git commit -m "feat(core-realtime): IRealtimeHandler + IInboundDescriptor types" +``` + +### Task 6: Define `IRealtimeAuthenticator` interface + +**Files:** +- Create: `packages/core-realtime/src/realtime-authenticator.interface.ts` + +- [ ] **Step 1: Write the interface** + +```ts +// packages/core-realtime/src/realtime-authenticator.interface.ts +export interface IRealtimeAuthenticator { + authenticate(handshake: { + cookies: Record; + headers: Record; + }): Promise<{ userId: string; roles: string[] } | null>; +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm --filter @repo/core-realtime typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-realtime/src/realtime-authenticator.interface.ts +git commit -m "feat(core-realtime): IRealtimeAuthenticator interface" +``` + +### Task 7: Implement channel-template matcher + +**Files:** +- Create: `packages/core-realtime/src/channel-template.ts` +- Create: `packages/core-realtime/src/channel-template.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core-realtime/src/channel-template.test.ts +import { describe, it, expect } from "vitest"; +import { matchChannelTemplate } from "@/channel-template"; + +describe("matchChannelTemplate", () => { + it("matches a plain channel name exactly", () => { + expect(matchChannelTemplate("blog.feed", "blog.feed")).toEqual({ params: {} }); + expect(matchChannelTemplate("blog.feed", "blog.other")).toBeNull(); + }); + + it("matches a templated channel and extracts params", () => { + expect( + matchChannelTemplate("notifications.user.{userId}", "notifications.user.user_42"), + ).toEqual({ params: { userId: "user_42" } }); + }); + + it("returns null when a templated channel doesn't match the shape", () => { + expect( + matchChannelTemplate("notifications.user.{userId}", "notifications.user"), + ).toBeNull(); + expect( + matchChannelTemplate("notifications.user.{userId}", "blog.feed"), + ).toBeNull(); + }); + + it("supports multiple placeholders", () => { + expect( + matchChannelTemplate( + "rooms.{roomId}.user.{userId}", + "rooms.r1.user.u1", + ), + ).toEqual({ params: { roomId: "r1", userId: "u1" } }); + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/core-realtime/src/channel-template.ts +const PLACEHOLDER = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g; + +export function matchChannelTemplate( + template: string, + candidate: string, +): { params: Record } | null { + // No placeholders: exact match. + if (!template.includes("{")) { + return template === candidate ? { params: {} } : null; + } + + // Build a regex from the template, replacing {name} with named groups. + const names: string[] = []; + const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); // escape regex specials + // The above escapes `{` and `}` too — restore them around placeholders. + const pattern = escaped.replace(/\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/g, (_m, name) => { + names.push(name); + return `([^.]+)`; + }); + const re = new RegExp(`^${pattern}$`); + const match = candidate.match(re); + if (!match) return null; + const params: Record = {}; + names.forEach((name, i) => { + params[name] = match[i + 1]!; + }); + return { params }; +} +``` + +- [ ] **Step 4: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: PASS — all four cases. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-realtime/src/channel-template.ts packages/core-realtime/src/channel-template.test.ts +git commit -m "feat(core-realtime): channel-template matcher (placeholder extraction)" +``` + +### Task 8: Implement the `authorize` function + +**Files:** +- Create: `packages/core-realtime/src/authorize.ts` +- Create: `packages/core-realtime/src/authorize.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core-realtime/src/authorize.test.ts +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { authorize } from "@/authorize"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const schema = z.object({}).strict(); + +describe("authorize", () => { + describe("public", () => { + const ch = defineRealtimeChannel("a", schema, { scope: "public" }); + it("allows anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(true); + }); + it("allows authenticated", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true); + }); + }); + + describe("authenticated", () => { + const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" }); + it("rejects anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(false); + }); + it("allows any user", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true); + }); + }); + + describe("{ role }", () => { + const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } }); + it("rejects anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(false); + }); + it("rejects user without role", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe(false); + }); + it("allows user with role", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] })).toBe(true); + }); + }); + + describe("{ userScoped }", () => { + const ch = defineRealtimeChannel("a", schema, { + scope: { userScoped: true, template: "notifications.user.{userId}" }, + }); + it("rejects anonymous", async () => { + expect(await authorize(ch, { userId: "u1" }, null)).toBe(false); + }); + it("rejects user requesting someone else's channel", async () => { + expect( + await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }), + ).toBe(false); + }); + it("allows user requesting own channel", async () => { + expect( + await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }), + ).toBe(true); + }); + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/core-realtime/src/authorize.ts +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export async function authorize( + descriptor: RealtimeChannelDescriptor, + params: Record, + user: { userId: string; roles: string[] } | null, +): Promise { + const scope = descriptor.scope; + + if (scope === "public") return true; + if (scope === "authenticated") return user !== null; + + if (typeof scope === "object" && "role" in scope) { + return user !== null && user.roles.includes(scope.role); + } + if (typeof scope === "object" && "userScoped" in scope) { + return user !== null && params.userId === user.userId; + } + + return false; +} +``` + +- [ ] **Step 4: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: PASS — all 9 cases. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-realtime/src/authorize.ts packages/core-realtime/src/authorize.test.ts +git commit -m "feat(core-realtime): authorize function (4 scope kinds)" +``` + +### Task 9: Implement `RealtimeHandlerRegistry` + +**Files:** +- Create: `packages/core-realtime/src/realtime-handler-registry.ts` +- Create: `packages/core-realtime/src/realtime-handler-registry.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core-realtime/src/realtime-handler-registry.test.ts +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; +import { RealtimeHandlerRegistry } from "@/realtime-handler-registry"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const ch = defineRealtimeChannel( + "test.ch", + z.object({ x: z.number() }).strict(), + { scope: "authenticated" }, +); + +describe("RealtimeHandlerRegistry", () => { + it("registers and retrieves a handler by channel name", () => { + const reg = new RealtimeHandlerRegistry(); + const handler = vi.fn(); + reg.register({ descriptor: ch, handler }); + const got = reg.getInboundDescriptor("test.ch"); + expect(got).not.toBeNull(); + expect(got!.descriptor.name).toBe("test.ch"); + expect(got!.handler).toBe(handler); + }); + + it("returns null for unknown channel name", () => { + const reg = new RealtimeHandlerRegistry(); + expect(reg.getInboundDescriptor("unknown")).toBeNull(); + }); + + it("list() returns all registered descriptors", () => { + const reg = new RealtimeHandlerRegistry(); + reg.register({ descriptor: ch, handler: vi.fn() }); + expect(reg.list()).toHaveLength(1); + expect(reg.list()[0]!.descriptor.name).toBe("test.ch"); + }); + + it("re-registering the same channel replaces the previous entry", () => { + const reg = new RealtimeHandlerRegistry(); + const h1 = vi.fn(); + const h2 = vi.fn(); + reg.register({ descriptor: ch, handler: h1 }); + reg.register({ descriptor: ch, handler: h2 }); + expect(reg.getInboundDescriptor("test.ch")!.handler).toBe(h2); + expect(reg.list()).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/core-realtime/src/realtime-handler-registry.ts +import type { z } from "zod"; +import type { IInboundDescriptor } from "./realtime-handler.interface"; + +export interface IRealtimeHandlerRegistry { + register(entry: IInboundDescriptor>): void; + getInboundDescriptor(channelName: string): IInboundDescriptor | null; + list(): IInboundDescriptor[]; +} + +export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry { + private readonly entries = new Map>(); + + register(entry: IInboundDescriptor>): void { + this.entries.set(entry.descriptor.name, entry as IInboundDescriptor); + } + + getInboundDescriptor(channelName: string): IInboundDescriptor | null { + return this.entries.get(channelName) ?? null; + } + + list(): IInboundDescriptor[] { + return Array.from(this.entries.values()); + } +} +``` + +- [ ] **Step 4: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: PASS — all 4 cases. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-realtime/src/realtime-handler-registry.ts packages/core-realtime/src/realtime-handler-registry.test.ts +git commit -m "feat(core-realtime): RealtimeHandlerRegistry" +``` + +### Task 10: Implement `InMemoryRealtimeBroadcaster` (test/dev impl) + +**Files:** +- Create: `packages/core-realtime/src/in-memory-realtime-broadcaster.ts` +- Create: `packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const ch = defineRealtimeChannel( + "a.b", + z.object({ x: z.number() }).strict(), + { scope: "public" }, +); + +describe("InMemoryRealtimeBroadcaster", () => { + it("validates payload via the descriptor schema", async () => { + const b = new InMemoryRealtimeBroadcaster(); + await expect( + b.broadcast(ch, { x: "not a number" } as never), + ).rejects.toThrow(); + }); + + it("delivers to subscribers in order", async () => { + const b = new InMemoryRealtimeBroadcaster(); + const got: number[] = []; + b.subscribe(ch, async (p) => { got.push(p.x); }); + await b.broadcast(ch, { x: 1 }); + await b.broadcast(ch, { x: 2 }); + expect(got).toEqual([1, 2]); + }); + + it("does nothing when no subscribers", async () => { + const b = new InMemoryRealtimeBroadcaster(); + await b.broadcast(ch, { x: 1 }); + // does not throw + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/core-realtime/src/in-memory-realtime-broadcaster.ts +import type { z } from "zod"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +type Listener = (payload: T) => Promise | void; + +export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster { + private readonly listeners = new Map[]>(); + + async broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + descriptor.schema.parse(payload); + const arr = this.listeners.get(descriptor.name) ?? []; + for (const l of arr) await l(payload); + } + + // Test-friendly: lets unit tests subscribe directly without a Socket.IO server. + subscribe( + descriptor: RealtimeChannelDescriptor>, + listener: Listener, + ): void { + const arr = this.listeners.get(descriptor.name) ?? []; + arr.push(listener as Listener); + this.listeners.set(descriptor.name, arr); + } +} +``` + +- [ ] **Step 4: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-realtime/src/in-memory-realtime-broadcaster.ts packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts +git commit -m "feat(core-realtime): InMemoryRealtimeBroadcaster (test/dev impl)" +``` + +### Task 11: Define `IRealtimeServer` interface + +**Files:** +- Create: `packages/core-realtime/src/realtime-server.interface.ts` + +- [ ] **Step 1: Write the interface** + +```ts +// packages/core-realtime/src/realtime-server.interface.ts +import type { Server as HttpServer } from "node:http"; +import type { Server as IOServer } from "socket.io"; +import type { IRealtimeAuthenticator } from "./realtime-authenticator.interface"; +import type { IRealtimeHandlerRegistry } from "./realtime-handler-registry"; + +export type IRealtimeServerOptions = { + httpServer: HttpServer; + io: IOServer; + authenticator: IRealtimeAuthenticator; + registry: IRealtimeHandlerRegistry; +}; + +export interface IRealtimeServer { + start(): Promise; + stop(): Promise; +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm --filter @repo/core-realtime typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-realtime/src/realtime-server.interface.ts +git commit -m "feat(core-realtime): IRealtimeServer interface" +``` + +### Task 12: Implement `SocketIORealtimeBroadcaster` + +**Files:** +- Create: `packages/core-realtime/src/socket-io-realtime-broadcaster.ts` +- Create: `packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts` + +- [ ] **Step 1: Write the failing test (with a stub Socket.IO server)** + +```ts +// packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; +import { SocketIORealtimeBroadcaster } from "@/socket-io-realtime-broadcaster"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const ch = defineRealtimeChannel( + "a.b", + z.object({ x: z.number() }).strict(), + { scope: "public" }, +); + +describe("SocketIORealtimeBroadcaster", () => { + it("emits to the channel's room with the channel name as event", async () => { + const emit = vi.fn(); + const to = vi.fn(() => ({ emit })); + const io = { to } as never; + const b = new SocketIORealtimeBroadcaster(io); + await b.broadcast(ch, { x: 1 }); + expect(to).toHaveBeenCalledWith("ch:a.b"); + expect(emit).toHaveBeenCalledWith("a.b", { x: 1 }); + }); + + it("validates payload before emitting", async () => { + const emit = vi.fn(); + const to = vi.fn(() => ({ emit })); + const io = { to } as never; + const b = new SocketIORealtimeBroadcaster(io); + await expect( + b.broadcast(ch, { x: "not a number" } as never), + ).rejects.toThrow(); + expect(emit).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/core-realtime/src/socket-io-realtime-broadcaster.ts +import type { Server as IOServer } from "socket.io"; +import type { z } from "zod"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export class SocketIORealtimeBroadcaster implements IRealtimeBroadcaster { + constructor(private readonly io: IOServer) {} + + async broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + descriptor.schema.parse(payload); + this.io.to(`ch:${descriptor.name}`).emit(descriptor.name, payload); + } +} +``` + +- [ ] **Step 4: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-realtime/src/socket-io-realtime-broadcaster.ts packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts +git commit -m "feat(core-realtime): SocketIORealtimeBroadcaster" +``` + +### Task 13: Implement `SocketIORealtimeServer` + +**Files:** +- Create: `packages/core-realtime/src/socket-io-realtime-server.ts` +- Create: `packages/core-realtime/src/socket-io-realtime-server.test.ts` + +This is the largest single component. The test exercises the full lifecycle (connect → subscribe → inbound) against a real `socket.io-server` + `socket.io-client` pair on a transient port. + +- [ ] **Step 1: Add `socket.io-client` to devDependencies** + +Edit `packages/core-realtime/package.json`: + +```json +"devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", + "socket.io-client": "^4.7.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0" +} +``` + +Run: `pnpm install` + +- [ ] **Step 2: Write the failing integration test** + +```ts +// packages/core-realtime/src/socket-io-realtime-server.test.ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { z } from "zod"; +import { createServer, type Server as HttpServer } from "node:http"; +import { Server as IOServer } from "socket.io"; +import { io as ioClient, type Socket as ClientSocket } from "socket.io-client"; +import type { AddressInfo } from "node:net"; +import { SocketIORealtimeServer } from "@/socket-io-realtime-server"; +import { RealtimeHandlerRegistry } from "@/realtime-handler-registry"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const pingChannel = defineRealtimeChannel( + "test.ping", + z.object({ at: z.string() }).strict(), + { scope: "authenticated" }, +); + +describe("SocketIORealtimeServer", () => { + let httpServer: HttpServer; + let io: IOServer; + let server: SocketIORealtimeServer; + let port: number; + + beforeEach(async () => { + httpServer = createServer(); + io = new IOServer(httpServer); + const registry = new RealtimeHandlerRegistry(); + + let received: { input: unknown; ctx: unknown } | null = null; + registry.register({ + descriptor: pingChannel, + handler: async (input, ctx) => { + received = { input, ctx }; + }, + }); + + server = new SocketIORealtimeServer({ + httpServer, + io, + authenticator: { + authenticate: async ({ cookies }) => { + if (cookies.session === "valid") return { userId: "u1", roles: [] }; + return null; + }, + }, + registry, + }); + await server.start(); + + await new Promise((resolve) => httpServer.listen(0, resolve)); + port = (httpServer.address() as AddressInfo).port; + + // expose received via a closure for the assertions + (server as unknown as { received: typeof received }).received = received; + }); + + afterEach(async () => { + await server.stop(); + httpServer.close(); + }); + + it("rejects subscribe to authenticated channel from anonymous socket", async () => { + const client = ioClient(`http://localhost:${port}`); + await new Promise((r) => client.on("connect", () => r())); + + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("subscribe", "test.ping", r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("forbidden"); + client.disconnect(); + }); + + it("allows subscribe + invokes handler with ctx for authenticated socket", async () => { + const client = ioClient(`http://localhost:${port}`, { + extraHeaders: { Cookie: "session=valid" }, + }); + await new Promise((r) => client.on("connect", () => r())); + + const subAck = await new Promise<{ ok: boolean }>((r) => + client.emit("subscribe", "test.ping", r), + ); + expect(subAck.ok).toBe(true); + + const sentAt = new Date().toISOString(); + const ack = await new Promise<{ ok: boolean }>((r) => + client.emit("test.ping", { at: sentAt }, r), + ); + + expect(ack.ok).toBe(true); + client.disconnect(); + }); + + it("rejects unknown channel subscribe", async () => { + const client = ioClient(`http://localhost:${port}`); + await new Promise((r) => client.on("connect", () => r())); + + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("subscribe", "does.not.exist", r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("unknown_channel"); + client.disconnect(); + }); + + it("rejects malformed inbound input", async () => { + const client = ioClient(`http://localhost:${port}`, { + extraHeaders: { Cookie: "session=valid" }, + }); + await new Promise((r) => client.on("connect", () => r())); + await new Promise<{ ok: boolean }>((r) => + client.emit("subscribe", "test.ping", r), + ); + + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("test.ping", { at: 123 } as never, r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("invalid_input"); + client.disconnect(); + }); +}); +``` + +- [ ] **Step 3: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-realtime test socket-io-realtime-server` +Expected: FAIL. + +- [ ] **Step 4: Implement** + +```ts +// packages/core-realtime/src/socket-io-realtime-server.ts +import type { Server as HttpServer } from "node:http"; +import type { Server as IOServer, Socket } from "socket.io"; +import { authorize } from "./authorize"; +import { matchChannelTemplate } from "./channel-template"; +import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface"; + +function parseCookies(header: string): Record { + const out: Record = {}; + for (const part of header.split(";")) { + const [k, ...rest] = part.trim().split("="); + if (k) out[k] = decodeURIComponent(rest.join("=")); + } + return out; +} + +export class SocketIORealtimeServer implements IRealtimeServer { + private readonly httpServer: HttpServer; + private readonly io: IOServer; + private readonly opts: IRealtimeServerOptions; + + constructor(opts: IRealtimeServerOptions) { + this.opts = opts; + this.httpServer = opts.httpServer; + this.io = opts.io; + } + + async start(): Promise { + const { authenticator, registry } = this.opts; + + // Gate 1: connect — read cookie, authenticate, attach user. + this.io.use(async (socket, next) => { + const cookies = parseCookies(socket.handshake.headers.cookie ?? ""); + socket.data.user = await authenticator.authenticate({ + cookies, + headers: socket.handshake.headers as Record, + }); + next(); + }); + + this.io.on("connection", (socket: Socket) => { + // Gate 2: subscribe. + socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => { + // Find a registered descriptor whose name (or template) matches requestedName. + let matched: { descriptor: { name: string; scope: unknown }; params: Record } | null = null; + for (const entry of registry.list()) { + const m = matchChannelTemplate(entry.descriptor.name, requestedName); + if (m) { + matched = { descriptor: entry.descriptor, params: m.params }; + break; + } + } + if (!matched) { + ack?.({ ok: false, error: "unknown_channel" }); + return; + } + + const allowed = await authorize( + matched.descriptor as never, + matched.params, + socket.data.user ?? null, + ); + if (!allowed) { + ack?.({ ok: false, error: "forbidden" }); + return; + } + + socket.join(`ch:${requestedName}`); + ack?.({ ok: true }); + }); + + // Gate 3: inbound — one listener per registered channel. + for (const entry of registry.list()) { + socket.on(entry.descriptor.name, async (payload: unknown, ack?: (r: unknown) => void) => { + const parsed = entry.descriptor.schema.safeParse(payload); + if (!parsed.success) { + ack?.({ ok: false, error: "invalid_input" }); + return; + } + const allowed = await authorize(entry.descriptor, {}, socket.data.user ?? null); + if (!allowed) { + ack?.({ ok: false, error: "forbidden" }); + return; + } + try { + await entry.handler(parsed.data, { + userId: socket.data.user?.userId ?? null, + roles: socket.data.user?.roles ?? [], + }); + ack?.({ ok: true }); + } catch { + ack?.({ ok: false, error: "handler_error" }); + } + }); + } + }); + } + + async stop(): Promise { + this.io.close(); + } +} +``` + +- [ ] **Step 5: Run; expect PASS** + +Run: `pnpm --filter @repo/core-realtime test socket-io-realtime-server` +Expected: PASS — all 4 cases. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-realtime/src/socket-io-realtime-server.ts packages/core-realtime/src/socket-io-realtime-server.test.ts packages/core-realtime/package.json pnpm-lock.yaml +git commit -m "feat(core-realtime): SocketIORealtimeServer (4 lifecycle gates)" +``` + +### Task 14: Public barrel + +**Files:** +- Modify: `packages/core-realtime/src/index.ts` + +- [ ] **Step 1: Replace the empty barrel with the full surface** + +```ts +// packages/core-realtime/src/index.ts +export type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel"; +export { defineRealtimeChannel } from "./realtime-channel"; +export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +export type { IRealtimeHandler, IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface"; +export type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface"; +export type { IRealtimeAuthenticator } from "./realtime-authenticator.interface"; +export type { IRealtimeHandlerRegistry } from "./realtime-handler-registry"; +export { RealtimeHandlerRegistry } from "./realtime-handler-registry"; +export { CORE_REALTIME_SYMBOLS } from "./symbols"; +export { InMemoryRealtimeBroadcaster } from "./in-memory-realtime-broadcaster"; +export { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster"; +export { SocketIORealtimeServer } from "./socket-io-realtime-server"; +export { authorize } from "./authorize"; +export { matchChannelTemplate } from "./channel-template"; +``` + +- [ ] **Step 2: Verify** + +Run: `pnpm --filter @repo/core-realtime typecheck test` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-realtime/src/index.ts +git commit -m "feat(core-realtime): public barrel" +``` + +--- + +## Phase 3 — Recording test helper + +### Task 15: `RecordingRealtimeBroadcaster` in core-testing + +**Files:** +- Create: `packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts` +- Create: `packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts` +- Modify: `packages/core-testing/src/instrumentation/index.ts` + +Critical: use the **local-type-alias pattern** from ADR-015 — do NOT import `IRealtimeBroadcaster` from `@repo/core-realtime`, that causes a build-graph cycle (core-testing is `tooling`-tagged, core-realtime is `core`-tagged, and turbo's typecheck graph would walk the dep both ways). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { RecordingRealtimeBroadcaster } from "@/instrumentation/recording-realtime-broadcaster"; + +const ch = { + name: "test.ch" as const, + schema: z.object({ x: z.number() }).strict(), + scope: "public" as const, +}; + +describe("RecordingRealtimeBroadcaster", () => { + it("records every broadcast call after schema validation", async () => { + const b = new RecordingRealtimeBroadcaster(); + await b.broadcast(ch, { x: 1 }); + await b.broadcast(ch, { x: 2 }); + expect(b.broadcasts).toEqual([ + { channel: "test.ch", payload: { x: 1 } }, + { channel: "test.ch", payload: { x: 2 } }, + ]); + }); + + it("rejects payloads that fail schema validation", async () => { + const b = new RecordingRealtimeBroadcaster(); + await expect(b.broadcast(ch, { x: "wrong" } as never)).rejects.toThrow(); + expect(b.broadcasts).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run; expect FAIL** + +Run: `pnpm --filter @repo/core-testing test recording-realtime` +Expected: FAIL. + +- [ ] **Step 3: Implement (with local type aliases — DO NOT import from @repo/core-realtime)** + +```ts +// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts +// Local type aliases mirroring @repo/core-realtime's contracts. Kept inline to +// avoid a build-graph cycle between core-testing (tooling) and core-realtime (core). +// Same pattern recording-event-bus + recording-job-queue use. +import type { z } from "zod"; + +type RealtimeChannelDescriptor = { + readonly name: TName; + readonly schema: TSchema; +}; + +interface IRealtimeBroadcaster { + broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise; +} + +export class RecordingRealtimeBroadcaster implements IRealtimeBroadcaster { + readonly broadcasts: { channel: string; payload: unknown }[] = []; + + async broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + descriptor.schema.parse(payload); + this.broadcasts.push({ channel: descriptor.name, payload }); + } +} +``` + +- [ ] **Step 4: Add to the instrumentation barrel** + +Modify `packages/core-testing/src/instrumentation/index.ts`: + +```ts +export { RecordingTracer, type RecordedSpan } from "./recording-tracer"; +export { RecordingLogger, type RecordedCapture } from "./recording-logger"; +export { RecordingJobQueue } from "./recording-job-queue"; +export { RecordingEventBus } from "./recording-event-bus"; +export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster"; +``` + +- [ ] **Step 5: Run; expect PASS** + +Run: `pnpm --filter @repo/core-testing test` +Expected: PASS — every existing test plus the 2 new cases. + +- [ ] **Step 6: Verify boundaries still pass** + +Run: `pnpm turbo boundaries` +Expected: PASS — no new cycle (we're using local type aliases, no actual import of `@repo/core-realtime`). + +- [ ] **Step 7: Commit** + +```bash +git add packages/core-testing/src/instrumentation/ +git commit -m "feat(core-testing): RecordingRealtimeBroadcaster (local-type-alias pattern)" +``` + +--- + +## Phase 4 — ESLint rules + +### Task 16: Write `no-direct-socket-io` rule + +**Files:** +- Create: `packages/core-eslint/rules/no-direct-socket-io.js` +- Create: `packages/core-eslint/rules/no-direct-socket-io.test.js` + +Mirrors `no-direct-payload-jobs` from ADR-015. Read that rule first for pattern: `packages/core-eslint/rules/no-direct-payload-jobs.js`. + +- [ ] **Step 1: Write the failing test** + +```js +// packages/core-eslint/rules/no-direct-socket-io.test.js +import { RuleTester } from "eslint"; +import rule from "./no-direct-socket-io.js"; + +const tester = new RuleTester({ + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, +}); + +tester.run("no-direct-socket-io", rule, { + valid: [ + // Allowed inside core-realtime + { code: 'import { Server } from "socket.io";', filename: "/repo/packages/core-realtime/src/socket-io-realtime-server.ts" }, + // Allowed in app servers + { code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/server.ts" }, + // Allowed elsewhere when not importing socket.io + { code: 'import { foo } from "bar";', filename: "/repo/packages/blog/src/foo.ts" }, + ], + invalid: [ + { + code: 'import { Server } from "socket.io";', + filename: "/repo/packages/blog/src/foo.ts", + errors: [{ messageId: "noDirectSocketIO" }], + }, + { + code: 'import { io } from "socket.io-client";', + filename: "/repo/packages/blog/src/ui/Component.tsx", + errors: [{ messageId: "noDirectSocketIOClient" }], + }, + ], +}); +``` + +- [ ] **Step 2: Implement** + +```js +// packages/core-eslint/rules/no-direct-socket-io.js +const ALLOWED = [ + /\/packages\/core-realtime\/src\//, + /\/apps\/[^/]+\/server\.ts$/, +]; + +export default { + meta: { + type: "problem", + docs: { description: "Block direct socket.io imports outside core-realtime + app servers" }, + messages: { + noDirectSocketIO: 'Import from "@repo/core-realtime" instead of "socket.io". Direct imports allowed only in packages/core-realtime/src/ and apps/*/server.ts.', + noDirectSocketIOClient: 'Use the realtime helpers from "@repo/core-realtime" / "@repo/core-testing/instrumentation" instead of "socket.io-client".', + }, + schema: [], + }, + create(context) { + const filename = context.filename ?? context.getFilename(); + const allowed = ALLOWED.some((re) => re.test(filename)); + if (allowed) return {}; + + return { + ImportDeclaration(node) { + const source = node.source.value; + if (source === "socket.io") { + context.report({ node, messageId: "noDirectSocketIO" }); + } else if (source === "socket.io-client") { + context.report({ node, messageId: "noDirectSocketIOClient" }); + } + }, + }; + }, +}; +``` + +- [ ] **Step 3: Wire into the ESLint config** + +Find where `no-direct-payload-jobs` is registered (probably `packages/core-eslint/base.js`). Add the same registration for `no-direct-socket-io`. Apply the rule to all packages by default. + +- [ ] **Step 4: Run rule's own tests** + +Run: `pnpm --filter @repo/core-eslint test` +Expected: PASS. + +- [ ] **Step 5: Run workspace lint to confirm no new violations** + +Run: `pnpm lint` +Expected: PASS — no feature package imports `socket.io` yet. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-eslint/ +git commit -m "feat(core-eslint): rule no-direct-socket-io" +``` + +### Task 17: Write `no-realtime-handler-reexport` rule + +**Files:** +- Create: `packages/core-eslint/rules/no-realtime-handler-reexport.js` +- Create: `packages/core-eslint/rules/no-realtime-handler-reexport.test.js` + +Parallel to ADR-015's `no-handler-reexport`. Read that rule first; this is the same pattern targeting `realtime/handlers/` instead of `events/handlers/`. + +- [ ] **Step 1: Write the failing test** + +```js +// packages/core-eslint/rules/no-realtime-handler-reexport.test.js +import { RuleTester } from "eslint"; +import rule from "./no-realtime-handler-reexport.js"; + +const tester = new RuleTester({ + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, +}); + +tester.run("no-realtime-handler-reexport", rule, { + valid: [ + // Importing a handler from inside a feature's bind-* file is allowed. + { + code: 'import { onPingHandler } from "../realtime/handlers/on-ping.handler";', + filename: "/repo/packages/blog/src/di/bind-production.ts", + }, + // Re-exporting a channel descriptor is allowed. + { + code: 'export { presenceChannel } from "./realtime/presence.channel";', + filename: "/repo/packages/blog/src/index.ts", + }, + ], + invalid: [ + // Re-exporting a handler from any non-bind file is forbidden. + { + code: 'export { onPingHandler } from "./realtime/handlers/on-ping.handler";', + filename: "/repo/packages/blog/src/index.ts", + errors: [{ messageId: "noRealtimeHandlerReexport" }], + }, + { + code: 'export * from "./handlers/on-ping.handler";', + filename: "/repo/packages/blog/src/realtime/index.ts", + errors: [{ messageId: "noRealtimeHandlerReexport" }], + }, + ], +}); +``` + +- [ ] **Step 2: Implement (mirror the existing `no-handler-reexport` rule)** + +Read `packages/core-eslint/rules/no-handler-reexport.js` and adapt: change the path matcher from `events/handlers/` to `realtime/handlers/`, and the message ID accordingly. + +- [ ] **Step 3: Wire into ESLint config** + +Add to `packages/core-eslint/base.js` next to `no-handler-reexport`. + +- [ ] **Step 4: Run rule's tests + workspace lint** + +Run: `pnpm --filter @repo/core-eslint test && pnpm lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-eslint/ +git commit -m "feat(core-eslint): rule no-realtime-handler-reexport" +``` + +--- + +## Phase 5 — Anchor retrofit + CI guard + +### Task 18: Retrofit anchors into `auth` + +**Files:** +- Modify: `packages/auth/src/index.ts` +- Modify: `packages/auth/src/di/symbols.ts` +- Modify: `packages/auth/src/di/bind-production.ts` +- Modify: `packages/auth/src/di/bind-dev-seed.ts` + +Three new anchors. Same protocol as ADR-015. The anchors are inert until generators target them. + +- [ ] **Step 1: Add `// ` to `src/index.ts`** + +Place at the end of the file (mirror where `` sits — should be the last line). + +- [ ] **Step 2: Add `// ` to `src/di/symbols.ts`** + +Place inside the `AUTH_SYMBOLS` object next to the existing `` and `` lines. + +- [ ] **Step 3: Add `// ` to both `bind-production.ts` and `bind-dev-seed.ts`** + +Place at the end of the function body, just before the closing `}` and after the existing `` and `` anchor comments. + +- [ ] **Step 4: Verify** + +Run: `pnpm --filter @repo/auth typecheck lint test` +Expected: PASS — anchors are comments, no behavior change. + +- [ ] **Step 5: Commit** + +```bash +git add packages/auth/ +git commit -m "chore(auth): add // anchor comments" +``` + +### Task 19: Retrofit anchors into `blog` + +Repeat Task 18 for `packages/blog/`. + +Commit: `chore(blog): add // anchor comments` + +### Task 20: Retrofit anchors into `media` + +Repeat Task 18 for `packages/media/`. + +Commit: `chore(media): add // anchor comments` + +### Task 21: Retrofit anchors into `marketing-pages` + +Repeat Task 18 for `packages/marketing-pages/`. + +Commit: `chore(marketing-pages): add // anchor comments` + +### Task 22: Retrofit anchors into `navigation` + +Repeat Task 18 for `packages/navigation/`. + +Commit: `chore(navigation): add // anchor comments` + +### Task 23: Extend the anchor-presence CI guard + +**Files:** +- Modify: `packages/core-eslint/anchors.test.js` + +- [ ] **Step 1: Add new anchors to the assertion table** + +Append to the `ANCHORS` map in the test file: + +```js +"src/index.ts": [ + "// ", + "// ", // NEW +], +"src/di/symbols.ts": [ + "// ", + "// ", + "// ", // NEW +], +"src/di/bind-production.ts": [ + "// ", + "// ", + "// ", // NEW +], +"src/di/bind-dev-seed.ts": [ + "// ", + "// ", + "// ", // NEW +], +``` + +- [ ] **Step 2: Run the guard** + +Run: `pnpm --filter @repo/core-eslint test` +Expected: PASS — every feature has every anchor. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-eslint/anchors.test.js +git commit -m "test(core-eslint): extend anchor-presence guard for realtime anchors" +``` + +### Task 24: Add anchors to the `feature` generator template + +**Files:** +- Modify: `turbo/generators/templates/feature/src/index.ts.hbs` +- Modify: `turbo/generators/templates/feature/src/di/symbols.ts.hbs` +- Modify: `turbo/generators/templates/feature/src/di/bind-production.ts.hbs` +- Modify: `turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs` + +Add the same three new anchors to the `feature` generator's templates so newly-scaffolded features ship with them. Same shape as Task 18 — exact placement, just in `.hbs` files. + +- [ ] **Step 1: Edit each template** + +Add the same anchor comments at the same positions (end of file / inside symbols object / end of bind function body). + +- [ ] **Step 2: Verify** + +Run: `cd turbo/generators && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add turbo/generators/templates/feature/ +git commit -m "chore(generators): add // anchors to feature template" +``` + +--- + +## Phase 6 — Per-feature binder signature extension + +### Task 25: Extend `bindProductionAuth` and `bindDevSeedAuth` signatures + +**Files:** +- Modify: `packages/auth/src/di/bind-production.ts` +- Modify: `packages/auth/src/di/bind-dev-seed.ts` +- Modify: `packages/auth/src/di/bind-dev-seed.test.ts` +- Modify: `packages/auth/package.json` + +Per the spec §5.4, every feature binder grows from 5-arg `(config, tracer, logger, bus, queue)` to 7-arg `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. Body does NOT yet use `realtime` / `realtimeRegistry` — Phase 8 generators inject usage. Accept-and-forward, mirror the ADR-015 Phase 6 pattern. + +- [ ] **Step 1: Add `@repo/core-realtime` to `packages/auth/package.json` dependencies** + +```json +"dependencies": { + "@repo/core-events": "workspace:*", + "@repo/core-realtime": "workspace:*", + "@repo/core-shared": "workspace:*", + ... +} +``` + +Run: `pnpm install` + +- [ ] **Step 2: Modify `bind-production.ts`** + +Add imports: + +```ts +import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime"; +``` + +Extend signature: + +```ts +export function bindProductionAuth( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, + bus: IEventBus, + queue: IJobQueue, + realtime: IRealtimeBroadcaster, + realtimeRegistry: IRealtimeHandlerRegistry, +): void { + // existing body unchanged + // before the closing brace, just below the existing void bus/queue lines: + void realtime; + void realtimeRegistry; + // + // + // +} +``` + +- [ ] **Step 3: Modify `bind-dev-seed.ts` similarly** + +Same imports + signature extension. Same `void` no-ops. + +- [ ] **Step 4: Update `bind-dev-seed.test.ts`** + +Import `RecordingRealtimeBroadcaster` and `RealtimeHandlerRegistry`, pass them through: + +```ts +import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation"; +import { RealtimeHandlerRegistry } from "@repo/core-realtime"; + +// inside each test: +await bindDevSeedAuth( + noop.tracer, + noop.logger, + new RecordingEventBus(), + new RecordingJobQueue(), + new RecordingRealtimeBroadcaster(), + new RealtimeHandlerRegistry(), +); +``` + +- [ ] **Step 5: Verify** + +Run: `pnpm --filter @repo/auth typecheck lint test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/auth/ pnpm-lock.yaml +git commit -m "feat(auth): bind binders accept (realtime, realtimeRegistry) params" +``` + +### Task 26: Extend `blog` binder signatures + +Repeat Task 25 for `packages/blog/`. + +Commit: `feat(blog): bind binders accept (realtime, realtimeRegistry) params` + +### Task 27: Extend `media` binder signatures + +Repeat Task 25 for `packages/media/`. + +Commit: `feat(media): bind binders accept (realtime, realtimeRegistry) params` + +### Task 28: Extend `marketing-pages` binder signatures + +Repeat Task 25 for `packages/marketing-pages/`. + +Commit: `feat(marketing-pages): bind binders accept (realtime, realtimeRegistry) params` + +### Task 29: Extend `navigation` binder signatures + +Repeat Task 25 for `packages/navigation/`. + +Commit: `feat(navigation): bind binders accept (realtime, realtimeRegistry) params` + +### Task 30: Update default DI module fallbacks + +**Files:** +- Modify: `packages//src/di/module.ts` for each feature whose `module.ts` resolves use cases via `.toDynamicValue()`. + +ADR-015 added `new InMemoryEventBus()` to `signUpUseCase`'s default fallback in `packages/auth/src/di/module.ts` for the same reason. Repeat for any use case that now takes `realtime` as a factory dep — but in v1, **no use case takes `realtime` yet** (Phase 8 generators inject it). Skip this task in v1; it will land alongside the first feature that uses `realtime` directly. Confirm by running `grep -rn 'signUpUseCase\|getArticlesUseCase' packages/*/src/di/module.ts` and verifying no factory call has more than 5 arguments. If any do, add `new InMemoryRealtimeBroadcaster()` per-resolution. + +This task may be a no-op; that's fine. Mark it done after the verification step. + +- [ ] **Verify and confirm no-op (or fix if needed)** + +Run: `grep -rn 'UseCase(' packages/*/src/di/module.ts | grep -v test` +Expected: every factory call has ≤ 5 args. If any has 6+ that include `realtime`, bind a fresh `InMemoryRealtimeBroadcaster` from `@repo/core-realtime` per resolution. + +- [ ] **Commit (only if any module.ts changed)** + +```bash +git add packages/*/src/di/module.ts +git commit -m "feat(auth,blog,...): default DI module supplies in-memory realtime broadcaster" +``` + +--- + +## Phase 7 — Custom Node server + bindAll wiring + +### Task 31: Replace `next dev` / `next start` with a custom server in apps/web-next + +**Files:** +- Create: `apps/web-next/server.ts` +- Modify: `apps/web-next/package.json` (scripts, dependencies) + +- [ ] **Step 1: Add socket.io to apps/web-next dependencies** + +```json +"dependencies": { + "@repo/auth": "workspace:*", + "@repo/blog": "workspace:*", + "@repo/core-api": "workspace:*", + "@repo/core-cms": "workspace:*", + "@repo/core-events": "workspace:*", + "@repo/core-realtime": "workspace:*", + "@repo/core-shared": "workspace:*", + ... + "socket.io": "^4.7.0" +} +``` + +Update scripts: + +```json +"scripts": { + "build": "echo 'Next.js build requires full environment — use pnpm dev or docker'", + "dev": "tsx server.ts", + "start": "node --import tsx server.ts", + "lint": "eslint .", + ... +} +``` + +Add `tsx` to devDependencies if not present. + +Run: `pnpm install` + +- [ ] **Step 2: Write `server.ts`** + +```ts +// apps/web-next/server.ts +// SERVER-ONLY entry. Boots Next.js + Socket.IO on the same Node http server. +import "reflect-metadata"; +import { createServer } from "node:http"; +import next from "next"; +import { Server as IOServer } from "socket.io"; +import { + RealtimeHandlerRegistry, + SocketIORealtimeBroadcaster, + SocketIORealtimeServer, + type IRealtimeAuthenticator, +} from "@repo/core-realtime"; +import { SESSION_COOKIE } from "@repo/auth"; +import { authContainer } from "@repo/auth/di/container"; +import { AUTH_SYMBOLS } from "@repo/auth/di/symbols"; +import type { IAuthenticationService } from "@repo/auth"; +import { bindAll } from "./src/server/bind-production"; + +const dev = process.env.NODE_ENV !== "production"; +const port = Number(process.env.PORT ?? 3000); + +const app = next({ dev }); +const handle = app.getRequestHandler(); + +await app.prepare(); + +const httpServer = createServer((req, res) => handle(req, res)); +const io = new IOServer(httpServer); + +const broadcaster = new SocketIORealtimeBroadcaster(io); +const registry = new RealtimeHandlerRegistry(); + +// Wire features. bindAll passes broadcaster + registry into every per-feature binder. +await bindAll({ realtime: broadcaster, realtimeRegistry: registry }); + +const authenticator: IRealtimeAuthenticator = { + authenticate: async ({ cookies }) => { + const sessionId = cookies[SESSION_COOKIE]; + if (!sessionId) return null; + const authService = authContainer.get( + AUTH_SYMBOLS.IAuthenticationService, + ); + const session = await authService.validateSession(sessionId); + return session + ? { userId: session.userId, roles: (session as { roles?: string[] }).roles ?? [] } + : null; + }, +}; + +const realtimeServer = new SocketIORealtimeServer({ + httpServer, + io, + authenticator, + registry, +}); +await realtimeServer.start(); + +httpServer.listen(port, () => { + console.log(`> Ready on http://localhost:${port}`); +}); +``` + +> **Note:** the `IAuthenticationService.validateSession` signature today returns a session with `userId`. The cast `(session as { roles?: string[] }).roles ?? []` defensively reads roles if the session shape grows them later (per spec §14). When the DB-backed roles ship, replace this with the typed accessor. + +- [ ] **Step 3: Verify the server boots** + +Run: `pnpm --filter @repo/web-next dev` +Expected: `> Ready on http://localhost:3000`. `curl http://localhost:3000` returns the Next.js home page. Stop with Ctrl+C. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web-next/server.ts apps/web-next/package.json pnpm-lock.yaml +git commit -m "feat(web-next): custom Node server hosting Next.js + Socket.IO on port 3000" +``` + +### Task 32: Extend `bindAll()` in apps/web-next + +**Files:** +- Modify: `apps/web-next/src/server/bind-production.ts` +- Modify: `apps/web-next/src/server/bind-production.test.ts` + +`bindAll` currently takes no args. The custom server constructs the broadcaster + registry first (because they need the http server), then calls `bindAll(deps)`. This is a signature change. + +- [ ] **Step 1: Modify the function signatures + thread realtime through every feature binder** + +```ts +// apps/web-next/src/server/bind-production.ts (excerpt) +import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime"; + +type BindAllDeps = { + realtime: IRealtimeBroadcaster; + realtimeRegistry: IRealtimeHandlerRegistry; +}; + +let bound = false; + +export async function bindAllProduction(deps: BindAllDeps): Promise { + if (bound) return; + bound = true; + const { tracer, logger } = resolveInstrumentation(); + const { bus, queue } = await resolveEventsAndJobsProduction(); + const resolvedConfig = await config; + const { realtime, realtimeRegistry } = deps; + + bindProductionAuth(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); + bindProductionBlog(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); + bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); + bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); + bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); + + bindRealtimeBridge(bus, realtime); // empty allowlist in v1, see § next task +} + +export async function bindAllDevSeed(deps: BindAllDeps): Promise { + if (bound) return; + bound = true; + const { tracer, logger } = resolveInstrumentation(); + const { bus, queue } = resolveEventsAndJobsDevSeed(); + const { realtime, realtimeRegistry } = deps; + + await bindDevSeedAuth(tracer, logger, bus, queue, realtime, realtimeRegistry); + await bindDevSeedBlog(tracer, logger, bus, queue, realtime, realtimeRegistry); + await bindDevSeedMarketingPages(tracer, logger, bus, queue, realtime, realtimeRegistry); + await bindDevSeedNavigation(tracer, logger, bus, queue, realtime, realtimeRegistry); + await bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry); + + bindRealtimeBridge(bus, realtime); +} + +export async function bindAll(deps: BindAllDeps): Promise { + if (process.env.USE_DEV_SEED === "true") { + await bindAllDevSeed(deps); + return; + } + if (process.env.NODE_ENV === "production") { + await bindAllProduction(deps); + return; + } + await bindAllDevSeed(deps); +} +``` + +- [ ] **Step 2: Add the bridge stub (empty allowlist in v1)** + +```ts +// apps/web-next/src/server/bind-production.ts (continued) +function bindRealtimeBridge(_bus: IEventBus, _broadcaster: IRealtimeBroadcaster): void { + // v1 ships with an empty allowlist. The dashboard PR adds the first entries here. + // Example shape (commented out so v1 doesn't try to use it): + // bus.subscribe(userSignedUpEvent, "realtime-bridge", async (payload) => + // broadcaster.broadcast(adminEventStreamChannel, { kind: "user.signed-up", payload }), + // ); +} +``` + +- [ ] **Step 3: Update the test file** + +The existing tests already mock the per-feature binders. Update them to: +- Pass `{ realtime, realtimeRegistry }` to `bindAll`/`bindAllProduction`/`bindAllDevSeed` calls +- Use `RecordingRealtimeBroadcaster` + `RealtimeHandlerRegistry` for the recording argument + +Add new test cases asserting `realtime` and `realtimeRegistry` are forwarded as the 6th + 7th positional args to each per-feature binder. + +- [ ] **Step 4: Verify** + +Run: `pnpm --filter @repo/web-next typecheck lint test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web-next/src/server/ +git commit -m "feat(web-next): bindAll wires (realtime, realtimeRegistry) through every feature" +``` + +--- + +## Phase 8 — Generators + +### Task 33: Generator templates for `gen realtime channel` + +**Files:** +- Create: `turbo/generators/templates/realtime/channel/channel.ts.hbs` +- Create: `turbo/generators/templates/realtime/channel/channel.test.ts.hbs` + +- [ ] **Step 1: Write `channel.ts.hbs`** + +```hbs +// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.ts +import { z } from "zod"; +import { defineRealtimeChannel } from "@repo/core-realtime"; + +export const {{camelCase channelSlug}}Schema = z.object({}).strict(); + +export type {{pascalCase channelSlug}}Payload = z.infer; + +export const {{camelCase channelSlug}}Channel = defineRealtimeChannel( + "{{kebabCase feature}}.{{kebabCase channelSlug}}", + {{camelCase channelSlug}}Schema, + { scope: {{{scopeLiteral}}} }, +); +``` + +- [ ] **Step 2: Write `channel.test.ts.hbs`** + +```hbs +// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.test.ts +import { describe, it, expect } from "vitest"; +import { + {{camelCase channelSlug}}Channel, + {{camelCase channelSlug}}Schema, +} from "@/realtime/{{kebabCase channelSlug}}.channel"; + +describe("{{camelCase channelSlug}}Channel", () => { + it("has the expected wire name", () => { + expect({{camelCase channelSlug}}Channel.name).toBe( + "{{kebabCase feature}}.{{kebabCase channelSlug}}", + ); + }); + + it("validates an empty payload (stub schema)", () => { + expect(() => {{camelCase channelSlug}}Schema.parse({})).not.toThrow(); + }); +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add turbo/generators/templates/realtime/channel/ +git commit -m "feat(turbo-gen): templates for gen realtime channel" +``` + +### Task 34: Generator templates for `gen realtime handler` + +**Files:** +- Create: `turbo/generators/templates/realtime/handler/handler.ts.hbs` +- Create: `turbo/generators/templates/realtime/handler/handler.test.ts.hbs` + +- [ ] **Step 1: Write `handler.ts.hbs`** + +```hbs +// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.ts +import type { {{pascalCase channelSlug}}Payload } from "../{{kebabCase channelSlug}}.channel"; +import type { RealtimeContext } from "@repo/core-realtime"; + +export type IOn{{pascalCase channelSlug}}Handler = ReturnType; + +export const on{{pascalCase channelSlug}}Handler = + () => + async (_input: {{pascalCase channelSlug}}Payload, _ctx: RealtimeContext): Promise => { + // TODO: implement the reaction. Inject deps via the factory's constructor + // and use them here. The handler is wrapped in span+capture at bind time, + // so just throwing on failure is the right shape. + }; +``` + +- [ ] **Step 2: Write `handler.test.ts.hbs`** + +```hbs +// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.test.ts +import { describe, it, expect } from "vitest"; +import { on{{pascalCase channelSlug}}Handler } from "@/realtime/handlers/on-{{kebabCase channelSlug}}.handler"; + +describe("on{{pascalCase channelSlug}}Handler", () => { + it("returns a function (factory shape)", () => { + const handler = on{{pascalCase channelSlug}}Handler(); + expect(typeof handler).toBe("function"); + }); + + it("does not throw on a valid stub input", async () => { + const handler = on{{pascalCase channelSlug}}Handler(); + await expect( + handler({} as never, { userId: "u1", roles: [] }), + ).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add turbo/generators/templates/realtime/handler/ +git commit -m "feat(turbo-gen): templates for gen realtime handler" +``` + +### Task 35: Wire `gen realtime` into `turbo/generators/config.ts` + +**Files:** +- Modify: `turbo/generators/config.ts` + +Read the existing event/job generator code in `config.ts` for the pattern. The realtime generator follows the same shape: a single `realtime` generator with a `mode` prompt that branches between `channel` and `handler`. + +- [ ] **Step 1: Add the `realtime` generator definition** + +Inside the existing `generator(plop)` function, after `plop.setGenerator("job", ...)`: + +```ts +plop.setGenerator("realtime", { + description: "Scaffold a realtime channel descriptor or inbound handler", + prompts: [ + { + type: "input", + name: "mode", + message: "Mode: channel | handler", + validate(input: string) { + if (!["channel", "handler"].includes(input)) return "Must be 'channel' or 'handler'"; + return true; + }, + }, + { + type: "input", + name: "feature", + message: "Feature (kebab-case, must exist):", + validate(input: string) { + if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; + if (!existsSync(join(process.cwd(), "packages", input, "src"))) { + return `packages/${input}/src does not exist`; + } + return true; + }, + }, + { + type: "input", + name: "channelSlug", + message: "Channel slug (kebab-case, e.g. 'presence-ping'):", + validate(input: string) { + if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case"; + return true; + }, + }, + { + type: "input", + name: "scope", + message: "Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):", + validate(input: string, answers: { mode: string }) { + if (answers.mode === "handler") return true; + if (input === "public" || input === "authenticated" || input === "user-scoped") return true; + if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true; + return "Must be public | authenticated | role:NAME | user-scoped"; + }, + }, + ], + actions(answers) { + const a = answers as { mode: string; feature: string; channelSlug: string; scope: string }; + if (a.mode === "channel") return realtimeChannelActions(a); + if (a.mode === "handler") return realtimeHandlerActions(a); + throw new Error(`Unknown mode: ${a.mode}`); + }, +}); +``` + +- [ ] **Step 2: Implement `realtimeChannelActions`** + +```ts +function realtimeChannelActions(a: { feature: string; channelSlug: string; scope: string }): PlopTypes.ActionType[] { + const indexPath = `packages/${a.feature}/src/index.ts`; + const scopeLiteral = renderScopeLiteral(a.scope); + return [ + () => { + assertAnchors(process.cwd(), indexPath, ["// "]); + return `Anchors verified in ${indexPath}`; + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`, + templateFile: "templates/realtime/channel/channel.ts.hbs", + data: { ...a, scopeLiteral }, + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.test.ts`, + templateFile: "templates/realtime/channel/channel.test.ts.hbs", + data: { ...a, scopeLiteral }, + }, + { + type: "modify", + path: indexPath, + pattern: /\/\/ /, + template: `// \nexport {\n {{camelCase channelSlug}}Channel,\n {{camelCase channelSlug}}Schema,\n type {{pascalCase channelSlug}}Payload,\n} from "./realtime/{{kebabCase channelSlug}}.channel";`, + data: a, + }, + () => printChannelNextSteps(a), + ]; +} + +function renderScopeLiteral(scope: string): string { + if (scope === "public") return `"public"`; + if (scope === "authenticated") return `"authenticated"`; + if (scope.startsWith("role:")) return `{ role: "${scope.slice("role:".length)}" }`; + if (scope === "user-scoped") return `{ userScoped: true, template: "TODO_TEMPLATE" }`; + throw new Error(`unknown scope: ${scope}`); +} + +function printChannelNextSteps(a: { feature: string; channelSlug: string }): string { + return [ + "─────────────────────────────────────────────────────────────", + `Realtime channel ${a.feature}.${a.channelSlug} scaffolded.`, + "", + "Next steps (manual):", + ` 1. Fill in the Zod schema in packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`, + ` 2. To broadcast: inject 'realtime: IRealtimeBroadcaster' into a use case factory and call`, + ` realtime.broadcast(${a.channelSlug}Channel, payload)`, + ` 3. To receive client-emitted messages on this channel: pnpm turbo gen realtime`, + ` and pick "handler" mode with the same channel slug.`, + ` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} +``` + +- [ ] **Step 3: Implement `realtimeHandlerActions`** + +```ts +function realtimeHandlerActions(a: { feature: string; channelSlug: string }): PlopTypes.ActionType[] { + const symbolFile = `packages/${a.feature}/src/di/symbols.ts`; + const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`; + const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`; + return [ + () => { + assertAnchors(process.cwd(), symbolFile, ["// "]); + assertAnchors(process.cwd(), bindProdFile, ["// "]); + assertAnchors(process.cwd(), bindDevFile, ["// "]); + return "All required anchors present"; + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`, + templateFile: "templates/realtime/handler/handler.ts.hbs", + data: a, + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.test.ts`, + templateFile: "templates/realtime/handler/handler.test.ts.hbs", + data: a, + }, + { + type: "modify", + path: symbolFile, + pattern: /\/\/ /, + template: `// \n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`, + }, + { + type: "modify", + path: bindProdFile, + pattern: /\/\/ /, + template: realtimeHandlerBindBlock(a), + }, + { + type: "modify", + path: bindDevFile, + pattern: /\/\/ /, + template: realtimeHandlerBindBlock(a), + }, + () => printHandlerNextSteps(a), + ]; +} + +function realtimeHandlerBindBlock(a: { feature: string; channelSlug: string }): string { + const handlerFn = `on${pascalCase(a.channelSlug)}Handler`; + const channelConst = `${camel(a.channelSlug)}Channel`; + return `// + // ${handlerFn} — generated by gen realtime handler. Edit the handler file (not this block). + const wrapped${pascalCase(a.channelSlug)} = withSpan( + tracer, + { name: "${a.feature}.${handlerFn}", op: "realtime-handler" }, + withCapture( + logger, + { feature: "${a.feature}", layer: "realtime-handler", name: "${a.feature}.${handlerFn}" }, + ${handlerFn}(), + ), + ); + realtimeRegistry.register({ descriptor: ${channelConst}, handler: wrapped${pascalCase(a.channelSlug)} });`; +} + +function printHandlerNextSteps(a: { feature: string; channelSlug: string }): string { + return [ + "─────────────────────────────────────────────────────────────", + `Realtime handler on-${a.channelSlug} scaffolded in ${a.feature}.`, + "", + "Next steps (manual):", + ` 1. Implement the handler body in packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`, + ` 2. Add the imports to bind-production.ts AND bind-dev-seed.ts:`, + ` import { ${camel(a.channelSlug)}Channel } from "../realtime/${a.channelSlug}.channel";`, + ` import { on${pascalCase(a.channelSlug)}Handler } from "../realtime/handlers/on-${a.channelSlug}.handler";`, + ` 3. If the handler needs deps, extend the factory signature and pass them in the bind block.`, + ` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} +``` + +- [ ] **Step 4: Smoke test channel mode** + +Run from project root: + +```bash +pnpm turbo gen realtime --args channel auth ping authenticated +``` + +Verify: +- `packages/auth/src/realtime/ping.channel.ts` exists +- `packages/auth/src/realtime/ping.channel.test.ts` exists +- `packages/auth/src/index.ts` re-exports the channel at the anchor +- `pnpm --filter @repo/auth typecheck lint test` PASSES + +Then revert the smoke output: + +```bash +rm -rf packages/auth/src/realtime/ +git restore packages/auth/src/index.ts +``` + +- [ ] **Step 5: Smoke test handler mode** + +Re-create the channel + run handler: + +```bash +pnpm turbo gen realtime --args channel auth ping authenticated +pnpm turbo gen realtime --args handler auth ping +``` + +Verify (and add the manual imports to bind files as per the printed instructions, then revert). + +- [ ] **Step 6: Commit (after both smoke tests pass + revert)** + +```bash +git add turbo/generators/config.ts +git commit -m "feat(turbo-gen): wire gen realtime (channel + handler modes)" +``` + +--- + +## Phase 9 — Proof-of-life: realtime-ping + +### Task 36: Implement `realtime-ping` channel pair inside core-realtime + +**Files:** +- Create: `packages/core-realtime/src/realtime-ping.ts` + +The proof-of-life lives inside core-realtime itself, not in a feature, so the integration test in apps/web-next can drive it without polluting any feature's surface. + +- [ ] **Step 1: Implement** + +```ts +// packages/core-realtime/src/realtime-ping.ts +import { z } from "zod"; +import { defineRealtimeChannel } from "./realtime-channel"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface"; + +const pingSchema = z.object({ at: z.string().datetime() }).strict(); +const pongSchema = z.object({ at: z.string().datetime(), echo: z.string() }).strict(); + +export type PingPayload = z.infer; +export type PongPayload = z.infer; + +export const realtimePingChannel = defineRealtimeChannel( + "realtime.ping", + pingSchema, + { scope: "authenticated" }, +); + +export const realtimePongChannel = defineRealtimeChannel( + "realtime.pong", + pongSchema, + { scope: "authenticated" }, +); + +export function realtimePingInboundDescriptor( + broadcaster: IRealtimeBroadcaster, +): IInboundDescriptor { + return { + descriptor: realtimePingChannel, + handler: async (input: PingPayload, ctx: RealtimeContext): Promise => { + await broadcaster.broadcast(realtimePongChannel, { + at: input.at, + echo: ctx.userId ?? "anonymous", + }); + }, + }; +} +``` + +- [ ] **Step 2: Add to the public barrel** + +```ts +// packages/core-realtime/src/index.ts (append) +export { + realtimePingChannel, + realtimePongChannel, + realtimePingInboundDescriptor, + type PingPayload, + type PongPayload, +} from "./realtime-ping"; +``` + +- [ ] **Step 3: Verify typecheck** + +Run: `pnpm --filter @repo/core-realtime typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-realtime/src/realtime-ping.ts packages/core-realtime/src/index.ts +git commit -m "feat(core-realtime): realtime-ping proof-of-life channel pair" +``` + +### Task 37: Register `realtime-ping` in apps/web-next bindAll + +**Files:** +- Modify: `apps/web-next/src/server/bind-production.ts` (the bindAll variants) + +- [ ] **Step 1: Register the inbound descriptor with the realtime registry** + +In both `bindAllProduction` and `bindAllDevSeed`, after the per-feature binders run: + +```ts +import { realtimePingInboundDescriptor } from "@repo/core-realtime"; + +// inside bindAllProduction and bindAllDevSeed, after feature binders, before bridge: +realtimeRegistry.register(realtimePingInboundDescriptor(realtime)); +``` + +- [ ] **Step 2: Add an env-gate for production** + +```ts +if (process.env.REALTIME_PING_DISABLED !== "true") { + realtimeRegistry.register(realtimePingInboundDescriptor(realtime)); +} +``` + +This lets ops disable the smoke channel post-merge if they want a leaner production surface. + +- [ ] **Step 3: Verify** + +Run: `pnpm --filter @repo/web-next typecheck lint test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web-next/src/server/bind-production.ts +git commit -m "feat(web-next): register realtime-ping inbound (env-gateable)" +``` + +### Task 38: Integration test: realtime-ping over real Socket.IO + +**Files:** +- Create: `apps/web-next/src/__tests__/realtime-ping.test.ts` + +This is the v1 proof-of-life — equivalent to ADR-015's `sign-up-welcome-email.test.ts`. + +- [ ] **Step 1: Add `socket.io-client` to apps/web-next devDependencies** + +```json +"devDependencies": { + ... + "socket.io-client": "^4.7.0", +} +``` + +Run: `pnpm install` + +- [ ] **Step 2: Write the test** + +```ts +// apps/web-next/src/__tests__/realtime-ping.test.ts +// e2e proof-of-life: connect → subscribe → emit ping → receive pong via the +// production-shaped binder + Socket.IO server, with cookie-session auth +// against a seeded test session. +import "reflect-metadata"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createServer, type Server as HttpServer } from "node:http"; +import { Server as IOServer } from "socket.io"; +import { io as ioClient } from "socket.io-client"; +import type { AddressInfo } from "node:net"; +import { + RealtimeHandlerRegistry, + SocketIORealtimeBroadcaster, + SocketIORealtimeServer, + realtimePingInboundDescriptor, + type IRealtimeAuthenticator, +} from "@repo/core-realtime"; + +describe("e2e: realtime-ping exercises all four checkpoints", () => { + let httpServer: HttpServer; + let realtimeServer: SocketIORealtimeServer; + let port: number; + + beforeEach(async () => { + httpServer = createServer(); + const io = new IOServer(httpServer); + const broadcaster = new SocketIORealtimeBroadcaster(io); + const registry = new RealtimeHandlerRegistry(); + registry.register(realtimePingInboundDescriptor(broadcaster)); + + const authenticator: IRealtimeAuthenticator = { + authenticate: async ({ cookies }) => + cookies.session === "valid-session" + ? { userId: "user_test", roles: [] } + : null, + }; + + realtimeServer = new SocketIORealtimeServer({ + httpServer, + io, + authenticator, + registry, + }); + await realtimeServer.start(); + + await new Promise((r) => httpServer.listen(0, r)); + port = (httpServer.address() as AddressInfo).port; + }); + + afterEach(async () => { + await realtimeServer.stop(); + await new Promise((r) => httpServer.close(() => r())); + }); + + it("authenticated client gets pong after ping", async () => { + const client = ioClient(`http://localhost:${port}`, { + extraHeaders: { Cookie: "session=valid-session" }, + }); + await new Promise((r) => client.on("connect", () => r())); + + const subAck = await new Promise<{ ok: boolean }>((r) => + client.emit("subscribe", "realtime.pong", r), + ); + expect(subAck.ok).toBe(true); + + const pongs: { at: string; echo: string }[] = []; + client.on("realtime.pong", (p) => pongs.push(p)); + + const sentAt = "2026-05-08T12:00:00.000Z"; + const pingAck = await new Promise<{ ok: boolean }>((r) => + client.emit("realtime.ping", { at: sentAt }, r), + ); + expect(pingAck.ok).toBe(true); + + // Pong arrives synchronously after handler runs. + await new Promise((r) => setImmediate(r)); + + expect(pongs).toHaveLength(1); + expect(pongs[0]).toEqual({ at: sentAt, echo: "user_test" }); + + client.disconnect(); + }); + + it("anonymous client cannot subscribe to pong (authenticated scope)", async () => { + const client = ioClient(`http://localhost:${port}`); + await new Promise((r) => client.on("connect", () => r())); + + const subAck = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("subscribe", "realtime.pong", r), + ); + + expect(subAck.ok).toBe(false); + expect(subAck.error).toBe("forbidden"); + client.disconnect(); + }); +}); +``` + +- [ ] **Step 3: Run; expect PASS** + +Run: `pnpm --filter @repo/web-next test realtime-ping` +Expected: PASS — both cases. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web-next/src/__tests__/realtime-ping.test.ts apps/web-next/package.json pnpm-lock.yaml +git commit -m "test(web-next): e2e realtime-ping (4 checkpoints)" +``` + +--- + +## Phase 10 — Documentation + +### Task 39: Write `docs/decisions/adr-016-realtime-layer.md` + +**Files:** +- Create: `docs/decisions/adr-016-realtime-layer.md` + +- [ ] **Step 1: Read the format of recent ADRs** + +Open `docs/decisions/adr-015-events-and-jobs.md`. Same Status / Context / Decision / Alternatives / Consequences / Notes-from-execution / Out-of-scope / Related sections. + +- [ ] **Step 2: Write ADR-016** + +Distill the spec's §1, §2 (rules), §3 (package), §6 (topology), §7 (auth), §8 (hybrid), §13 (v1 scope). Mirror the prose density of ADR-015. Each rule (R0/R1/R2) gets one paragraph. Cross-link the spec at `docs/superpowers/specs/2026-05-08-realtime-design.md`. + +- [ ] **Step 3: Commit** + +```bash +git add docs/decisions/adr-016-realtime-layer.md +git commit -m "docs(adr-016): realtime layer (Socket.IO)" +``` + +### Task 40: Write `docs/guides/realtime.md` + +**Files:** +- Create: `docs/guides/realtime.md` + +- [ ] **Step 1: Read the events-and-jobs guide for the format** + +Open `docs/guides/events-and-jobs.md`. The realtime guide follows the same shape: three sections (declare a channel, broadcast from a use case, receive client messages with a handler), each with full code samples. + +- [ ] **Step 2: Write the guide** + +Cover: +- "Declare a channel" — runs `gen realtime channel`, fills the schema, picks scope +- "Broadcast from a use case" — adds `realtime: IRealtimeBroadcaster` to the factory, calls `realtime.broadcast(channel, payload)` +- "Receive client messages" — runs `gen realtime handler`, fills handler body, adds imports to bind files +- Cross-reference: ADR-016, the design spec, and `docs/decisions/adr-015-events-and-jobs.md` for the bridge pattern + +- [ ] **Step 3: Commit** + +```bash +git add docs/guides/realtime.md +git commit -m "docs(guide): realtime walkthrough" +``` + +### Task 41: Update `AGENTS.md` + +**Files:** +- Modify: `AGENTS.md` + +- [ ] **Step 1: Add a new section under Per-Package Conventions** + +Insert after "Cross-feature events and background jobs (Plan 10, ADR-015)" — same shape, three-rule summary: + +```markdown +### Realtime layer (ADR-016) + +Three rules: + +- **R0:** Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures. +- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-* files (ESLint-enforced via `no-realtime-handler-reexport`). +- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this. + +`@repo/core-realtime` provides `IRealtimeBroadcaster` (server → client), `IRealtimeHandlerRegistry` (client → server), and the `SocketIORealtimeServer` adapter. `apps/web-next/server.ts` replaces `next start`/`next dev` with a custom Node http server hosting both Next.js and Socket.IO on port 3000. + +Use the generators: `pnpm turbo gen realtime channel`, `pnpm turbo gen realtime handler`. They insert at three fixed `// ` anchor comments per feature. + +See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`. +``` + +- [ ] **Step 2: Add a row to the Specification & Guides list** + +```markdown +- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive +``` + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md +git commit -m "docs(agents): realtime layer section + guide reference" +``` + +### Task 42: Update `CLAUDE.md` + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update Quick Start** + +Add to the command block: + +```bash +pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler +``` + +- [ ] **Step 2: Update Read First** + +Add: `- docs/guides/realtime.md — Socket.IO channels, broadcasts, handlers` + +- [ ] **Step 3: Update Key Conventions** + +Add three short bullets matching R0/R1/R2 from the AGENTS.md section above. + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs(claude): realtime generator + R0/R1/R2 conventions" +``` + +### Task 43: Update `docs/guides/scaffolding-a-feature.md` + +**Files:** +- Modify: `docs/guides/scaffolding-a-feature.md` + +- [ ] **Step 1: Extend the "Adding events and jobs" section to cover realtime** + +Append `pnpm turbo gen realtime channel` and `pnpm turbo gen realtime handler` to the generator list. Note that newly-generated features include the three new realtime anchors. + +- [ ] **Step 2: Commit** + +```bash +git add docs/guides/scaffolding-a-feature.md +git commit -m "docs(scaffolding): realtime generator reference" +``` + +### Task 44: Update `docs/architecture/dependency-flow.md` and `docs/architecture/vertical-feature-spec.md` + +**Files:** +- Modify: `docs/architecture/dependency-flow.md` +- Modify: `docs/architecture/vertical-feature-spec.md` + +- [ ] **Step 1: Update the bindAll diagram** + +Add a `resolveRealtime` step + extend the binder signature line in `dependency-flow.md` to `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. + +- [ ] **Step 2: Update vertical-feature-spec.md § 4 (optional folders)** + +Add `realtime/` and `realtime/handlers/` to the optional-folder list, with a cross-reference to ADR-016. + +- [ ] **Step 3: Commit** + +```bash +git add docs/architecture/ +git commit -m "docs(architecture): realtime layer in dependency-flow + vertical-feature-spec" +``` + +--- + +## Phase 11 — Final verification + +### Task 45: Whole-monorepo green check + +- [ ] **Step 1: Full lint** + +Run: `pnpm lint` +Expected: PASS — including the two new ESLint rules, the anchor-presence guard, and all rule tests. Pre-existing warnings (NEXT_RUNTIME, etc.) unchanged. + +- [ ] **Step 2: Full typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Full test** + +Run: `pnpm test` +Expected: PASS — including `core-realtime`'s ~20 test cases, the recording-realtime-broadcaster tests, the anchor-presence guard, and the realtime-ping integration test. + +- [ ] **Step 4: Boundary check** + +Run: `pnpm turbo boundaries` +Expected: PASS — `core-realtime` properly tagged, no new violations. + +- [ ] **Step 5: Smoke run of generators** + +```bash +cp -r ~/Documents/Projects/template-vertical /tmp/realtime-final-test && cd /tmp/realtime-final-test && pnpm install +pnpm turbo gen realtime --args channel blog test-channel public +pnpm turbo gen realtime --args handler blog test-channel +# manually add the imports printed by the handler generator +pnpm --filter @repo/blog typecheck test lint +``` + +Expected: PASS. + +```bash +rm -rf /tmp/realtime-final-test +``` + +- [ ] **Step 6: Manual dev-server smoke** + +```bash +pnpm --filter @repo/web-next dev +# In another terminal: +curl -i http://localhost:3000 +# Stop the dev server. +``` + +Expected: HTTP 200 from Next.js, no Socket.IO errors in the dev-server log. + +- [ ] **Step 7: Final commit (only if any fixes were needed)** + +If steps 1–6 surfaced a fix, commit it. Otherwise no-op — the plan is done. + +--- + +## Known follow-ups — out of v1 plan scope + +Tracked here so they aren't forgotten: + +1. **Observability dashboard.** The first concrete consumer of the bridge. Adds an `admin-realtime` feature package with a `{ role: "admin" }` channel that streams every bus event to connected admin tabs. Includes a Next.js admin route + filter UI. Adds the first allowlist entry to `bindRealtimeBridge`. + +2. **DB-backed roles + permissions in the auth feature.** Per spec § 14. Additive: `IRealtimeAuthenticator.authenticate()` widens its return type, scope union grows new kinds (`{ permission }`, `{ permissions, mode }`, `{ check }`), no changes to `core-realtime`. + +3. **Generic core-package generator.** User-flagged on 2026-05-08. A `turbo gen core-package` with template variants (interface-and-adapters / utility / policy-only) so future core packages don't need to be hand-built. Saved as project memory; brainstorm separately. + +4. **Production-mode e2e against real Postgres-backed Payload session.** § 10.3's test runs against a stub authenticator. A parallel test that boots Payload and uses a real `validateSession` would prove the full cookie-auth chain. + +5. **Multi-instance fanout.** When `apps/web-next` scales horizontally, broadcasts in one process don't reach sockets connected to another. Solved by Socket.IO's Redis adapter (sticky sessions + Redis pub/sub). + +6. **Custom Node server for `cms` and `web-tanstack`.** v1 only converts `web-next`. Lands when those apps actually need realtime. + +7. **Generator for outbound broadcasts.** Currently a manual edit (add `realtime: IRealtimeBroadcaster` to a use case factory). If the pattern proliferates, a `gen realtime broadcast ` could automate it. + +--- + +## Self-review check + +This plan was self-reviewed against the spec on the date of writing. Key points verified: + +- Every spec section maps to one or more tasks (§ 3 → Phase 1 + 2, § 5 → Phase 6 + 8, § 6 → Phase 7, § 7 → Tasks 7+8+13, § 8 → Task 32 (bridge stub), § 9 → Phase 4 (ESLint), § 10 → Phase 3 + 9, § 11 → Phase 5 + 8, § 12 → Phase 9, § 13 → covered by per-task scope, § 14 → ADR-016 only, deferred otherwise). +- All file paths are absolute or repo-relative; no "TBD". +- Tests precede implementation in every behavior-adding task (R-style TDD per ADR-011). +- Commit cadence: one commit per task, ~45 commits total. +- Anchor protocol identical to ADR-015 (3 anchors per feature, same kind of CI guard extension). +- Span+capture sandwich tags (`op: "realtime-handler"`, `layer: "realtime-handler"`) appear consistently in Task 35's generator output. +- `RecordingRealtimeBroadcaster` uses the local-type-alias pattern (lessons from ADR-015 Task 56 codified into Task 15). +- v1 scope (out of plan items) explicitly excludes the dashboard, DB-backed roles, multi-instance fanout, and the generic core-package generator. +- Type consistency: `RealtimeContext` shape matches across handler factory definition, the gate-3 dispatch site in `SocketIORealtimeServer`, and the generator template.