2945 lines
97 KiB
Markdown
2945 lines
97 KiB
Markdown
# 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`**
|
||
|
||
```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<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**
|
||
|
||
```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<T>(
|
||
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
|
||
payload: T,
|
||
): Promise<void>;
|
||
}
|
||
```
|
||
|
||
- [ ] **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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<void>;
|
||
stop(): Promise<void>;
|
||
}
|
||
```
|
||
|
||
- [ ] **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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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`:
|
||
|
||
```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 `// <gen:realtime-channels>` to `src/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>` to `src/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 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 `<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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```js
|
||
"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**
|
||
|
||
```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 // <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-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;
|
||
// <gen:event-handlers>
|
||
// <gen:jobs>
|
||
// <gen:realtime-handlers>
|
||
}
|
||
```
|
||
|
||
- [ ] **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/<feature>/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<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.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<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)**
|
||
|
||
```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<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`**
|
||
|
||
```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<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`**
|
||
|
||
```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, ["// <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`**
|
||
|
||
```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, ["// <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:
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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/<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**
|
||
|
||
```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 <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.
|
||
- `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.
|