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