97 KiB
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/<feature>/src/ ← all 5 features
├── index.ts ← + // <gen:realtime-channels>
├── di/
│ ├── symbols.ts ← + // <gen:realtime-handler-symbols>
│ ├── bind-production.ts ← signature ext + // <gen:realtime-handlers>
│ └── 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
{
"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
{
"extends": "@repo/core-typescript/tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*", "*.config.ts"]
}
- Step 3: Write
vitest.config.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
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;
- Step 5: Write
turbo.json(boundary tag)
{
"$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)
# @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)
// 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
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 viagrep -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-realtimeascore
Add to the appropriate section:
{
type: "core",
pattern: "packages/core-realtime",
mode: "folder",
}
- Step 3: Verify
Run: pnpm turbo boundaries
Expected: PASS — no new violations.
- Step 4: Commit
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
// 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
// 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<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
readonly scope: ChannelScope;
};
export function defineRealtimeChannel<TName extends string, TSchema extends z.ZodType>(
name: TName,
schema: TSchema,
options: { scope: ChannelScope },
): RealtimeChannelDescriptor<TName, TSchema> {
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
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
// packages/core-realtime/src/realtime-broadcaster.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export interface IRealtimeBroadcaster {
broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
}
- Step 2: Write the symbols registry
// 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
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
// 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<T> = (input: T, ctx: RealtimeContext) => Promise<void>;
export type IInboundDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly descriptor: RealtimeChannelDescriptor<TName, TSchema>;
readonly handler: IRealtimeHandler<z.infer<TSchema>>;
};
- Step 2: Verify typecheck
Run: pnpm --filter @repo/core-realtime typecheck
Expected: PASS.
- Step 3: Commit
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
// packages/core-realtime/src/realtime-authenticator.interface.ts
export interface IRealtimeAuthenticator {
authenticate(handshake: {
cookies: Record<string, string>;
headers: Record<string, string>;
}): Promise<{ userId: string; roles: string[] } | null>;
}
- Step 2: Verify typecheck
Run: pnpm --filter @repo/core-realtime typecheck
Expected: PASS.
- Step 3: Commit
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
// 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
// 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<string, string> } | 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<string, string> = {};
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
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
// 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
// packages/core-realtime/src/authorize.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export async function authorize(
descriptor: RealtimeChannelDescriptor<string, z.ZodType>,
params: Record<string, string>,
user: { userId: string; roles: string[] } | null,
): Promise<boolean> {
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
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
// 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
// packages/core-realtime/src/realtime-handler-registry.ts
import type { z } from "zod";
import type { IInboundDescriptor } from "./realtime-handler.interface";
export interface IRealtimeHandlerRegistry {
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
list(): IInboundDescriptor<string, z.ZodType>[];
}
export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
}
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
return this.entries.get(channelName) ?? null;
}
list(): IInboundDescriptor<string, z.ZodType>[] {
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
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
// 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
// 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<T> = (payload: T) => Promise<void> | void;
export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster {
private readonly listeners = new Map<string, Listener<unknown>[]>();
async broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
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<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
listener: Listener<T>,
): void {
const arr = this.listeners.get(descriptor.name) ?? [];
arr.push(listener as Listener<unknown>);
this.listeners.set(descriptor.name, arr);
}
}
- Step 4: Run; expect PASS
Run: pnpm --filter @repo/core-realtime test
Expected: PASS.
- Step 5: Commit
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
// 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<void>;
stop(): Promise<void>;
}
- Step 2: Verify typecheck
Run: pnpm --filter @repo/core-realtime typecheck
Expected: PASS.
- Step 3: Commit
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)
// 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
// 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<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
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
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-clientto devDependencies
Edit packages/core-realtime/package.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
// 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<void>((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<void>((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<void>((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<void>((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<void>((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
// 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<string, string> {
const out: Record<string, string> = {};
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<void> {
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<string, string>,
});
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<string, string> } | 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<void> {
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
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
// 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
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
// 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)
// 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<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
interface IRealtimeBroadcaster {
broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
}
export class RecordingRealtimeBroadcaster implements IRealtimeBroadcaster {
readonly broadcasts: { channel: string; payload: unknown }[] = [];
async broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
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:
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
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
// 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
// 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
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
// 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-reexportrule)
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
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
// <gen:realtime-channels>tosrc/index.ts
Place at the end of the file (mirror where <gen:events> sits — should be the last line).
- Step 2: Add
// <gen:realtime-handler-symbols>tosrc/di/symbols.ts
Place inside the AUTH_SYMBOLS object next to the existing <gen:event-handler-symbols> and <gen:job-symbols> lines.
- Step 3: Add
// <gen:realtime-handlers>to bothbind-production.tsandbind-dev-seed.ts
Place at the end of the function body, just before the closing } and after the existing <gen:event-handlers> and <gen:jobs> anchor comments.
- Step 4: Verify
Run: pnpm --filter @repo/auth typecheck lint test
Expected: PASS — anchors are comments, no behavior change.
- Step 5: Commit
git add packages/auth/
git commit -m "chore(auth): add // <gen:realtime-*> anchor comments"
Task 19: Retrofit anchors into blog
Repeat Task 18 for packages/blog/.
Commit: chore(blog): add // <gen:realtime-*> anchor comments
Task 20: Retrofit anchors into media
Repeat Task 18 for packages/media/.
Commit: chore(media): add // <gen:realtime-*> anchor comments
Task 21: Retrofit anchors into marketing-pages
Repeat Task 18 for packages/marketing-pages/.
Commit: chore(marketing-pages): add // <gen:realtime-*> anchor comments
Task 22: Retrofit anchors into navigation
Repeat Task 18 for packages/navigation/.
Commit: chore(navigation): add // <gen:realtime-*> 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:
"src/index.ts": [
"// <gen:events>",
"// <gen:realtime-channels>", // NEW
],
"src/di/symbols.ts": [
"// <gen:event-handler-symbols>",
"// <gen:job-symbols>",
"// <gen:realtime-handler-symbols>", // NEW
],
"src/di/bind-production.ts": [
"// <gen:event-handlers>",
"// <gen:jobs>",
"// <gen:realtime-handlers>", // NEW
],
"src/di/bind-dev-seed.ts": [
"// <gen:event-handlers>",
"// <gen:jobs>",
"// <gen:realtime-handlers>", // NEW
],
- Step 2: Run the guard
Run: pnpm --filter @repo/core-eslint test
Expected: PASS — every feature has every anchor.
- Step 3: Commit
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
git add turbo/generators/templates/feature/
git commit -m "chore(generators): add // <gen:realtime-*> 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-realtimetopackages/auth/package.jsondependencies
"dependencies": {
"@repo/core-events": "workspace:*",
"@repo/core-realtime": "workspace:*",
"@repo/core-shared": "workspace:*",
...
}
Run: pnpm install
- Step 2: Modify
bind-production.ts
Add imports:
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";
Extend signature:
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;
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
}
- Step 3: Modify
bind-dev-seed.tssimilarly
Same imports + signature extension. Same void no-ops.
- Step 4: Update
bind-dev-seed.test.ts
Import RecordingRealtimeBroadcaster and RealtimeHandlerRegistry, pass them through:
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
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/<feature>/src/di/module.tsfor each feature whosemodule.tsresolves 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)
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
"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:
"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
// 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<IAuthenticationService>(
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.validateSessionsignature today returns a session withuserId. 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
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
// 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<void> {
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<void> {
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<void> {
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)
// 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 }tobindAll/bindAllProduction/bindAllDevSeedcalls - Use
RecordingRealtimeBroadcaster+RealtimeHandlerRegistryfor 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
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
// 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<typeof {{camelCase channelSlug}}Schema>;
export const {{camelCase channelSlug}}Channel = defineRealtimeChannel(
"{{kebabCase feature}}.{{kebabCase channelSlug}}",
{{camelCase channelSlug}}Schema,
{ scope: {{{scopeLiteral}}} },
);
- Step 2: Write
channel.test.ts.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
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
// 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<typeof on{{pascalCase channelSlug}}Handler>;
export const on{{pascalCase channelSlug}}Handler =
() =>
async (_input: {{pascalCase channelSlug}}Payload, _ctx: RealtimeContext): Promise<void> => {
// 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
// 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
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
realtimegenerator definition
Inside the existing generator(plop) function, after plop.setGenerator("job", ...):
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
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, ["// <gen:realtime-channels>"]);
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: /\/\/ <gen:realtime-channels>/,
template: `// <gen:realtime-channels>\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
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, ["// <gen:realtime-handler-symbols>"]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
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: /\/\/ <gen:realtime-handler-symbols>/,
template: `// <gen:realtime-handler-symbols>\n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`,
},
{
type: "modify",
path: bindProdFile,
pattern: /\/\/ <gen:realtime-handlers>/,
template: realtimeHandlerBindBlock(a),
},
{
type: "modify",
path: bindDevFile,
pattern: /\/\/ <gen:realtime-handlers>/,
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 `// <gen:realtime-handlers>
// ${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:
pnpm turbo gen realtime --args channel auth ping authenticated
Verify:
packages/auth/src/realtime/ping.channel.tsexistspackages/auth/src/realtime/ping.channel.test.tsexistspackages/auth/src/index.tsre-exports the channel at the anchorpnpm --filter @repo/auth typecheck lint testPASSES
Then revert the smoke output:
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:
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)
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
// 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<typeof pingSchema>;
export type PongPayload = z.infer<typeof pongSchema>;
export const realtimePingChannel = defineRealtimeChannel(
"realtime.ping",
pingSchema,
{ scope: "authenticated" },
);
export const realtimePongChannel = defineRealtimeChannel(
"realtime.pong",
pongSchema,
{ scope: "authenticated" },
);
export function realtimePingInboundDescriptor(
broadcaster: IRealtimeBroadcaster,
): IInboundDescriptor<typeof realtimePingChannel.name, typeof pingSchema> {
return {
descriptor: realtimePingChannel,
handler: async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
await broadcaster.broadcast(realtimePongChannel, {
at: input.at,
echo: ctx.userId ?? "anonymous",
});
},
};
}
- Step 2: Add to the public barrel
// 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
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:
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
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
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-clientto apps/web-next devDependencies
"devDependencies": {
...
"socket.io-client": "^4.7.0",
}
Run: pnpm install
- Step 2: Write the test
// 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<void>((r) => httpServer.listen(0, r));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await realtimeServer.stop();
await new Promise<void>((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<void>((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<void>((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<void>((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
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
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: IRealtimeBroadcasterto the factory, callsrealtime.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.mdfor the bridge pattern -
Step 3: Commit
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:
### 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/<name>.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 `// <gen:realtime-*>` 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
- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive
- Step 3: Commit
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:
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
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
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
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
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.
rm -rf /tmp/realtime-final-test
- Step 6: Manual dev-server smoke
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:
-
Observability dashboard. The first concrete consumer of the bridge. Adds an
admin-realtimefeature 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 tobindRealtimeBridge. -
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 tocore-realtime. -
Generic core-package generator. User-flagged on 2026-05-08. A
turbo gen core-packagewith 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. -
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
validateSessionwould prove the full cookie-auth chain. -
Multi-instance fanout. When
apps/web-nextscales horizontally, broadcasts in one process don't reach sockets connected to another. Solved by Socket.IO's Redis adapter (sticky sessions + Redis pub/sub). -
Custom Node server for
cmsandweb-tanstack. v1 only convertsweb-next. Lands when those apps actually need realtime. -
Generator for outbound broadcasts. Currently a manual edit (add
realtime: IRealtimeBroadcasterto a use case factory). If the pattern proliferates, agen realtime broadcast <feature> <channel-slug>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. RecordingRealtimeBroadcasteruses 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:
RealtimeContextshape matches across handler factory definition, the gate-3 dispatch site inSocketIORealtimeServer, and the generator template.