Files
agentic-dev/docs/superpowers/plans/2026-05-08-realtime-layer.md

97 KiB
Raw Blame History

Realtime layer (Socket.IO) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship a vendor-isolated bidirectional realtime layer over Socket.IO, with cookie-session auth, four channel scope kinds, a hybrid bus-bridge / direct-broadcaster model, two scaffolding generators, and a realtime-ping integration smoke test. Defers the observability dashboard, DB-backed roles/permissions, and multi-instance fanout to follow-up PRs.

Architecture: Mirrors the ADR-015 pattern. A new @repo/core-realtime package holds vendor-neutral interfaces (IRealtimeBroadcaster, IRealtimeServer, IRealtimeAuthenticator, RealtimeChannelDescriptor) plus a SocketIORealtimeServer adapter. Feature packages use the interfaces only — socket.io is ESLint-blocked outside core-realtime and the apps' custom Node servers. Auth runs at four lifecycle gates (connect / subscribe / inbound / broadcast); broadcast skips the gate because subscribe already filters who's in the room. Bus events forward to realtime channels through an explicit per-app allowlist (empty in v1; populated when the dashboard PR ships).

Tech Stack: TypeScript, Node 22, socket.io ^4, socket.io-client ^4, Zod, Vitest, ESLint, Turborepo, Inversify, Next.js, Payload CMS.

Spec: docs/superpowers/specs/2026-05-08-realtime-design.md — read first, especially §3 (interfaces), §7 (auth), §13 (v1 scope).


Phase 0 — Read first

  • Step 1: Read the spec end-to-end

Open docs/superpowers/specs/2026-05-08-realtime-design.md in your editor. The plan below maps to its sections roughly 1:1. The four checkpoints in §7, the authorize function in §7.3, the file shapes in §5, and the v1 scope in §13 are load-bearing references.

  • Step 2: Read ADR-015 (events and jobs)

Open docs/decisions/adr-015-events-and-jobs.md. The realtime layer reuses the same patterns: vendor-isolated interface in core, recording test helper in core-testing, custom ESLint rule against direct vendor imports, anchor protocol for generators, span+capture sandwich at bind time. Familiarity halves the cognitive load.

  • Step 3: Skim the navigation feature for the canonical shape

Open packages/navigation/src/di/bind-production.ts and packages/navigation/src/di/bind-dev-seed.ts. They show the post-ADR-015 binder shape (config, tracer, logger, bus, queue). The realtime layer extends this signature to (config, tracer, logger, bus, queue, realtime, realtimeRegistry).


File map (summary)

packages/core-realtime/                          ← new package, tag: core
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── eslint.config.js
├── turbo.json
├── AGENTS.md
└── src/
    ├── index.ts
    ├── symbols.ts
    ├── realtime-channel.ts
    ├── realtime-broadcaster.interface.ts
    ├── realtime-handler.interface.ts
    ├── realtime-server.interface.ts
    ├── realtime-authenticator.interface.ts
    ├── realtime-handler-registry.ts
    ├── channel-template.ts
    ├── authorize.ts
    ├── in-memory-realtime-broadcaster.ts
    ├── socket-io-realtime-broadcaster.ts
    ├── socket-io-realtime-server.ts
    └── realtime-ping.ts                          ← proof-of-life

packages/core-testing/src/instrumentation/
└── recording-realtime-broadcaster.ts             ← test helper

packages/core-eslint/
├── rules/
│   ├── no-direct-socket-io.js                    ← new rule
│   └── no-realtime-handler-reexport.js           ← new rule
└── anchors.test.js                                ← extend with new anchors

packages/<feature>/src/                            ← all 5 features
├── index.ts                                       ← + // <gen:realtime-channels>
├── di/
│   ├── symbols.ts                                 ← + // <gen:realtime-handler-symbols>
│   ├── bind-production.ts                         ← signature ext + // <gen:realtime-handlers>
│   └── bind-dev-seed.ts                           ← same
└── (eventually) realtime/                         ← optional, generator-driven

apps/web-next/
├── server.ts                                      ← NEW: replaces `next start` / `next dev`
├── package.json                                   ← gain socket.io + scripts change
└── src/server/bind-production.ts                  ← + resolveRealtime() + bindRealtimeBridge()

turbo/generators/
├── config.ts                                      ← + realtime generator
├── lib/anchor-validate.ts                         ← already exists from ADR-015
└── templates/realtime/
    ├── channel/
    │   ├── channel.ts.hbs
    │   └── channel.test.ts.hbs
    └── handler/
        ├── handler.ts.hbs
        └── handler.test.ts.hbs

docs/
├── decisions/adr-016-realtime-layer.md           ← NEW
├── guides/realtime.md                             ← NEW
├── architecture/vertical-feature-spec.md          ← § 13 update
├── architecture/dependency-flow.md                ← bindAll diagram update
└── superpowers/specs/2026-05-08-realtime-design.md  ← already exists

Phase 1 — Scaffold @repo/core-realtime package

Task 1: Create the package skeleton

Files:

  • Create: packages/core-realtime/package.json

  • Create: packages/core-realtime/tsconfig.json

  • Create: packages/core-realtime/vitest.config.ts

  • Create: packages/core-realtime/eslint.config.js

  • Create: packages/core-realtime/turbo.json

  • Create: packages/core-realtime/AGENTS.md

  • Create: packages/core-realtime/src/index.ts (empty for now; populated in Phase 2)

  • Step 1: Write package.json

{
  "name": "@repo/core-realtime",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "build": "tsc --noEmit",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "socket.io": "^4.7.0",
    "zod": "^3.23.0"
  },
  "peerDependencies": {
    "payload": "^3.0.0"
  },
  "peerDependenciesMeta": {
    "payload": { "optional": true }
  },
  "devDependencies": {
    "@repo/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/node": "^22.0.0",
    "typescript": "^5.8.0",
    "vitest": "^3.0.0"
  }
}

(No core-testing devDep — same lesson from ADR-015 Task 56. The setup file resolves via the workspace symlinks; we don't need it in deps.)

  • Step 2: Write tsconfig.json
{
  "extends": "@repo/core-typescript/tsconfig.base.json",
  "compilerOptions": {
    "rootDir": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src/**/*", "*.config.ts"]
}
  • Step 3: Write vitest.config.ts
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";

export default mergeConfig(nodeVitestConfig, {
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
});
  • Step 4: Write eslint.config.js
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;
  • Step 5: Write turbo.json (boundary tag)
{
  "$schema": "https://turborepo.dev/schema.json",
  "extends": ["//"],
  "tags": ["core"]
}
  • Step 6: Write a stub AGENTS.md (will be filled in once all the components land — keep this lean for now)
# @repo/core-realtime

Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.

See `docs/superpowers/specs/2026-05-08-realtime-design.md` for the full design and `docs/decisions/adr-016-realtime-layer.md` for the rationale (added during implementation).

## Public exports

- `IRealtimeBroadcaster` — server → client broadcasts
- `IRealtimeServer` — lifecycle, used at app boot only
- `IRealtimeAuthenticator` — connect-time identity attachment (cookie / header → user)
- `IRealtimeHandlerRegistry` + `RealtimeHandlerRegistry` — inbound handler registration
- `defineRealtimeChannel`, `RealtimeChannelDescriptor`, `ChannelScope`
- `InMemoryRealtimeBroadcaster` (test/dev), `SocketIORealtimeBroadcaster`, `SocketIORealtimeServer` (production)
- `CORE_REALTIME_SYMBOLS`

## Boundary

Tagged `core`. The only place in the repo where `import "socket.io"` is allowed is `src/socket-io-*.ts` here, plus `apps/*/server.ts`. Enforced by the ESLint rule `core-eslint/no-direct-socket-io`.
  • Step 7: Write src/index.ts (empty barrel — populated in Phase 2)
// Public barrel — populated as components land in Phase 2.
export {};
  • Step 8: Verify the package installs and lints

Run: pnpm install && pnpm --filter @repo/core-realtime lint typecheck Expected: PASS (zero TS errors, zero ESLint errors).

  • Step 9: Commit
git add packages/core-realtime/
git commit -m "chore(core-realtime): scaffold package"

Task 2: Tag @repo/core-realtime in the boundary map

Files:

  • Modify: packages/core-eslint/base.js (or wherever the boundaries plugin tag map lives — check 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:

{
  type: "core",
  pattern: "packages/core-realtime",
  mode: "folder",
}
  • Step 3: Verify

Run: pnpm turbo boundaries Expected: PASS — no new violations.

  • Step 4: Commit
git add packages/core-eslint/
git commit -m "chore(core-eslint,turbo): tag @repo/core-realtime as core"

Phase 2 — Interfaces + descriptors (TDD foundation)

Task 3: Define RealtimeChannelDescriptor + defineRealtimeChannel

Files:

  • Create: packages/core-realtime/src/realtime-channel.ts

  • Create: packages/core-realtime/src/realtime-channel.test.ts

  • Step 1: Write the failing test

// packages/core-realtime/src/realtime-channel.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineRealtimeChannel } from "@/realtime-channel";

describe("defineRealtimeChannel", () => {
  it("returns a descriptor with name, schema, and scope", () => {
    const ch = defineRealtimeChannel(
      "test.channel",
      z.object({ id: z.string() }).strict(),
      { scope: "public" },
    );
    expect(ch.name).toBe("test.channel");
    expect(ch.scope).toBe("public");
    expect(() => ch.schema.parse({ id: "x" })).not.toThrow();
  });

  it("preserves all four scope shapes", () => {
    expect(defineRealtimeChannel("a", z.object({}), { scope: "public" }).scope).toBe("public");
    expect(defineRealtimeChannel("a", z.object({}), { scope: "authenticated" }).scope).toBe("authenticated");
    expect(defineRealtimeChannel("a", z.object({}), { scope: { role: "admin" } }).scope).toEqual({ role: "admin" });
    expect(
      defineRealtimeChannel("a", z.object({}), { scope: { userScoped: true, template: "x.{id}" } }).scope,
    ).toEqual({ userScoped: true, template: "x.{id}" });
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test Expected: FAIL — module not found.

  • Step 3: Implement
// packages/core-realtime/src/realtime-channel.ts
import type { z } from "zod";

export type ChannelScope =
  | "public"
  | "authenticated"
  | { role: string }
  | { userScoped: true; template: string };

export type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
  readonly name: TName;
  readonly schema: TSchema;
  readonly scope: ChannelScope;
};

export function defineRealtimeChannel<TName extends string, TSchema extends z.ZodType>(
  name: TName,
  schema: TSchema,
  options: { scope: ChannelScope },
): RealtimeChannelDescriptor<TName, TSchema> {
  return { name, schema, scope: options.scope };
}
  • Step 4: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test Expected: PASS — both tests green.

  • Step 5: Commit
git add packages/core-realtime/src/realtime-channel.ts packages/core-realtime/src/realtime-channel.test.ts
git commit -m "feat(core-realtime): RealtimeChannelDescriptor + defineRealtimeChannel"

Task 4: Define IRealtimeBroadcaster interface + symbol

Files:

  • Create: packages/core-realtime/src/realtime-broadcaster.interface.ts

  • Create: packages/core-realtime/src/symbols.ts

  • Step 1: Write the interface

// packages/core-realtime/src/realtime-broadcaster.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export interface IRealtimeBroadcaster {
  broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void>;
}
  • Step 2: Write the symbols registry
// packages/core-realtime/src/symbols.ts
export const CORE_REALTIME_SYMBOLS = {
  IRealtimeBroadcaster: Symbol.for("core-realtime:IRealtimeBroadcaster"),
  IRealtimeServer: Symbol.for("core-realtime:IRealtimeServer"),
  IRealtimeAuthenticator: Symbol.for("core-realtime:IRealtimeAuthenticator"),
  IRealtimeHandlerRegistry: Symbol.for("core-realtime:IRealtimeHandlerRegistry"),
} as const;
  • Step 3: Verify typecheck

Run: pnpm --filter @repo/core-realtime typecheck Expected: PASS.

  • Step 4: Commit
git add packages/core-realtime/src/realtime-broadcaster.interface.ts packages/core-realtime/src/symbols.ts
git commit -m "feat(core-realtime): IRealtimeBroadcaster interface + symbol registry"

Task 5: Define IRealtimeHandler + IInboundDescriptor types

Files:

  • Create: packages/core-realtime/src/realtime-handler.interface.ts

  • Step 1: Write the types

// packages/core-realtime/src/realtime-handler.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export type RealtimeContext = {
  userId: string | null;
  roles: string[];
};

export type IRealtimeHandler<T> = (input: T, ctx: RealtimeContext) => Promise<void>;

export type IInboundDescriptor<TName extends string, TSchema extends z.ZodType> = {
  readonly descriptor: RealtimeChannelDescriptor<TName, TSchema>;
  readonly handler: IRealtimeHandler<z.infer<TSchema>>;
};
  • Step 2: Verify typecheck

Run: pnpm --filter @repo/core-realtime typecheck Expected: PASS.

  • Step 3: Commit
git add packages/core-realtime/src/realtime-handler.interface.ts
git commit -m "feat(core-realtime): IRealtimeHandler + IInboundDescriptor types"

Task 6: Define IRealtimeAuthenticator interface

Files:

  • Create: packages/core-realtime/src/realtime-authenticator.interface.ts

  • Step 1: Write the interface

// packages/core-realtime/src/realtime-authenticator.interface.ts
export interface IRealtimeAuthenticator {
  authenticate(handshake: {
    cookies: Record<string, string>;
    headers: Record<string, string>;
  }): Promise<{ userId: string; roles: string[] } | null>;
}
  • Step 2: Verify typecheck

Run: pnpm --filter @repo/core-realtime typecheck Expected: PASS.

  • Step 3: Commit
git add packages/core-realtime/src/realtime-authenticator.interface.ts
git commit -m "feat(core-realtime): IRealtimeAuthenticator interface"

Task 7: Implement channel-template matcher

Files:

  • Create: packages/core-realtime/src/channel-template.ts

  • Create: packages/core-realtime/src/channel-template.test.ts

  • Step 1: Write the failing test

// packages/core-realtime/src/channel-template.test.ts
import { describe, it, expect } from "vitest";
import { matchChannelTemplate } from "@/channel-template";

describe("matchChannelTemplate", () => {
  it("matches a plain channel name exactly", () => {
    expect(matchChannelTemplate("blog.feed", "blog.feed")).toEqual({ params: {} });
    expect(matchChannelTemplate("blog.feed", "blog.other")).toBeNull();
  });

  it("matches a templated channel and extracts params", () => {
    expect(
      matchChannelTemplate("notifications.user.{userId}", "notifications.user.user_42"),
    ).toEqual({ params: { userId: "user_42" } });
  });

  it("returns null when a templated channel doesn't match the shape", () => {
    expect(
      matchChannelTemplate("notifications.user.{userId}", "notifications.user"),
    ).toBeNull();
    expect(
      matchChannelTemplate("notifications.user.{userId}", "blog.feed"),
    ).toBeNull();
  });

  it("supports multiple placeholders", () => {
    expect(
      matchChannelTemplate(
        "rooms.{roomId}.user.{userId}",
        "rooms.r1.user.u1",
      ),
    ).toEqual({ params: { roomId: "r1", userId: "u1" } });
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test Expected: FAIL.

  • Step 3: Implement
// packages/core-realtime/src/channel-template.ts
const PLACEHOLDER = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;

export function matchChannelTemplate(
  template: string,
  candidate: string,
): { params: Record<string, string> } | null {
  // No placeholders: exact match.
  if (!template.includes("{")) {
    return template === candidate ? { params: {} } : null;
  }

  // Build a regex from the template, replacing {name} with named groups.
  const names: string[] = [];
  const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); // escape regex specials
  // The above escapes `{` and `}` too — restore them around placeholders.
  const pattern = escaped.replace(/\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/g, (_m, name) => {
    names.push(name);
    return `([^.]+)`;
  });
  const re = new RegExp(`^${pattern}$`);
  const match = candidate.match(re);
  if (!match) return null;
  const params: Record<string, string> = {};
  names.forEach((name, i) => {
    params[name] = match[i + 1]!;
  });
  return { params };
}
  • Step 4: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test Expected: PASS — all four cases.

  • Step 5: Commit
git add packages/core-realtime/src/channel-template.ts packages/core-realtime/src/channel-template.test.ts
git commit -m "feat(core-realtime): channel-template matcher (placeholder extraction)"

Task 8: Implement the authorize function

Files:

  • Create: packages/core-realtime/src/authorize.ts

  • Create: packages/core-realtime/src/authorize.test.ts

  • Step 1: Write the failing test

// packages/core-realtime/src/authorize.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { authorize } from "@/authorize";
import { defineRealtimeChannel } from "@/realtime-channel";

const schema = z.object({}).strict();

describe("authorize", () => {
  describe("public", () => {
    const ch = defineRealtimeChannel("a", schema, { scope: "public" });
    it("allows anonymous", async () => {
      expect(await authorize(ch, {}, null)).toBe(true);
    });
    it("allows authenticated", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
    });
  });

  describe("authenticated", () => {
    const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" });
    it("rejects anonymous", async () => {
      expect(await authorize(ch, {}, null)).toBe(false);
    });
    it("allows any user", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
    });
  });

  describe("{ role }", () => {
    const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } });
    it("rejects anonymous", async () => {
      expect(await authorize(ch, {}, null)).toBe(false);
    });
    it("rejects user without role", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe(false);
    });
    it("allows user with role", async () => {
      expect(await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] })).toBe(true);
    });
  });

  describe("{ userScoped }", () => {
    const ch = defineRealtimeChannel("a", schema, {
      scope: { userScoped: true, template: "notifications.user.{userId}" },
    });
    it("rejects anonymous", async () => {
      expect(await authorize(ch, { userId: "u1" }, null)).toBe(false);
    });
    it("rejects user requesting someone else's channel", async () => {
      expect(
        await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }),
      ).toBe(false);
    });
    it("allows user requesting own channel", async () => {
      expect(
        await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }),
      ).toBe(true);
    });
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test Expected: FAIL.

  • Step 3: Implement
// packages/core-realtime/src/authorize.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export async function authorize(
  descriptor: RealtimeChannelDescriptor<string, z.ZodType>,
  params: Record<string, string>,
  user: { userId: string; roles: string[] } | null,
): Promise<boolean> {
  const scope = descriptor.scope;

  if (scope === "public") return true;
  if (scope === "authenticated") return user !== null;

  if (typeof scope === "object" && "role" in scope) {
    return user !== null && user.roles.includes(scope.role);
  }
  if (typeof scope === "object" && "userScoped" in scope) {
    return user !== null && params.userId === user.userId;
  }

  return false;
}
  • Step 4: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test Expected: PASS — all 9 cases.

  • Step 5: Commit
git add packages/core-realtime/src/authorize.ts packages/core-realtime/src/authorize.test.ts
git commit -m "feat(core-realtime): authorize function (4 scope kinds)"

Task 9: Implement RealtimeHandlerRegistry

Files:

  • Create: packages/core-realtime/src/realtime-handler-registry.ts

  • Create: packages/core-realtime/src/realtime-handler-registry.test.ts

  • Step 1: Write the failing test

// packages/core-realtime/src/realtime-handler-registry.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
import { defineRealtimeChannel } from "@/realtime-channel";

const ch = defineRealtimeChannel(
  "test.ch",
  z.object({ x: z.number() }).strict(),
  { scope: "authenticated" },
);

describe("RealtimeHandlerRegistry", () => {
  it("registers and retrieves a handler by channel name", () => {
    const reg = new RealtimeHandlerRegistry();
    const handler = vi.fn();
    reg.register({ descriptor: ch, handler });
    const got = reg.getInboundDescriptor("test.ch");
    expect(got).not.toBeNull();
    expect(got!.descriptor.name).toBe("test.ch");
    expect(got!.handler).toBe(handler);
  });

  it("returns null for unknown channel name", () => {
    const reg = new RealtimeHandlerRegistry();
    expect(reg.getInboundDescriptor("unknown")).toBeNull();
  });

  it("list() returns all registered descriptors", () => {
    const reg = new RealtimeHandlerRegistry();
    reg.register({ descriptor: ch, handler: vi.fn() });
    expect(reg.list()).toHaveLength(1);
    expect(reg.list()[0]!.descriptor.name).toBe("test.ch");
  });

  it("re-registering the same channel replaces the previous entry", () => {
    const reg = new RealtimeHandlerRegistry();
    const h1 = vi.fn();
    const h2 = vi.fn();
    reg.register({ descriptor: ch, handler: h1 });
    reg.register({ descriptor: ch, handler: h2 });
    expect(reg.getInboundDescriptor("test.ch")!.handler).toBe(h2);
    expect(reg.list()).toHaveLength(1);
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test Expected: FAIL.

  • Step 3: Implement
// packages/core-realtime/src/realtime-handler-registry.ts
import type { z } from "zod";
import type { IInboundDescriptor } from "./realtime-handler.interface";

export interface IRealtimeHandlerRegistry {
  register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
  getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
  list(): IInboundDescriptor<string, z.ZodType>[];
}

export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
  private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();

  register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
    this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
  }

  getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
    return this.entries.get(channelName) ?? null;
  }

  list(): IInboundDescriptor<string, z.ZodType>[] {
    return Array.from(this.entries.values());
  }
}
  • Step 4: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test Expected: PASS — all 4 cases.

  • Step 5: Commit
git add packages/core-realtime/src/realtime-handler-registry.ts packages/core-realtime/src/realtime-handler-registry.test.ts
git commit -m "feat(core-realtime): RealtimeHandlerRegistry"

Task 10: Implement InMemoryRealtimeBroadcaster (test/dev impl)

Files:

  • Create: packages/core-realtime/src/in-memory-realtime-broadcaster.ts

  • Create: packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts

  • Step 1: Write the failing test

// packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster";
import { defineRealtimeChannel } from "@/realtime-channel";

const ch = defineRealtimeChannel(
  "a.b",
  z.object({ x: z.number() }).strict(),
  { scope: "public" },
);

describe("InMemoryRealtimeBroadcaster", () => {
  it("validates payload via the descriptor schema", async () => {
    const b = new InMemoryRealtimeBroadcaster();
    await expect(
      b.broadcast(ch, { x: "not a number" } as never),
    ).rejects.toThrow();
  });

  it("delivers to subscribers in order", async () => {
    const b = new InMemoryRealtimeBroadcaster();
    const got: number[] = [];
    b.subscribe(ch, async (p) => { got.push(p.x); });
    await b.broadcast(ch, { x: 1 });
    await b.broadcast(ch, { x: 2 });
    expect(got).toEqual([1, 2]);
  });

  it("does nothing when no subscribers", async () => {
    const b = new InMemoryRealtimeBroadcaster();
    await b.broadcast(ch, { x: 1 });
    // does not throw
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test Expected: FAIL.

  • Step 3: Implement
// packages/core-realtime/src/in-memory-realtime-broadcaster.ts
import type { z } from "zod";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

type Listener<T> = (payload: T) => Promise<void> | void;

export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster {
  private readonly listeners = new Map<string, Listener<unknown>[]>();

  async broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    const arr = this.listeners.get(descriptor.name) ?? [];
    for (const l of arr) await l(payload);
  }

  // Test-friendly: lets unit tests subscribe directly without a Socket.IO server.
  subscribe<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    listener: Listener<T>,
  ): void {
    const arr = this.listeners.get(descriptor.name) ?? [];
    arr.push(listener as Listener<unknown>);
    this.listeners.set(descriptor.name, arr);
  }
}
  • Step 4: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test Expected: PASS.

  • Step 5: Commit
git add packages/core-realtime/src/in-memory-realtime-broadcaster.ts packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts
git commit -m "feat(core-realtime): InMemoryRealtimeBroadcaster (test/dev impl)"

Task 11: Define IRealtimeServer interface

Files:

  • Create: packages/core-realtime/src/realtime-server.interface.ts

  • Step 1: Write the interface

// packages/core-realtime/src/realtime-server.interface.ts
import type { Server as HttpServer } from "node:http";
import type { Server as IOServer } from "socket.io";
import type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
import type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";

export type IRealtimeServerOptions = {
  httpServer: HttpServer;
  io: IOServer;
  authenticator: IRealtimeAuthenticator;
  registry: IRealtimeHandlerRegistry;
};

export interface IRealtimeServer {
  start(): Promise<void>;
  stop(): Promise<void>;
}
  • Step 2: Verify typecheck

Run: pnpm --filter @repo/core-realtime typecheck Expected: PASS.

  • Step 3: Commit
git add packages/core-realtime/src/realtime-server.interface.ts
git commit -m "feat(core-realtime): IRealtimeServer interface"

Task 12: Implement SocketIORealtimeBroadcaster

Files:

  • Create: packages/core-realtime/src/socket-io-realtime-broadcaster.ts

  • Create: packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts

  • Step 1: Write the failing test (with a stub Socket.IO server)

// packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { SocketIORealtimeBroadcaster } from "@/socket-io-realtime-broadcaster";
import { defineRealtimeChannel } from "@/realtime-channel";

const ch = defineRealtimeChannel(
  "a.b",
  z.object({ x: z.number() }).strict(),
  { scope: "public" },
);

describe("SocketIORealtimeBroadcaster", () => {
  it("emits to the channel's room with the channel name as event", async () => {
    const emit = vi.fn();
    const to = vi.fn(() => ({ emit }));
    const io = { to } as never;
    const b = new SocketIORealtimeBroadcaster(io);
    await b.broadcast(ch, { x: 1 });
    expect(to).toHaveBeenCalledWith("ch:a.b");
    expect(emit).toHaveBeenCalledWith("a.b", { x: 1 });
  });

  it("validates payload before emitting", async () => {
    const emit = vi.fn();
    const to = vi.fn(() => ({ emit }));
    const io = { to } as never;
    const b = new SocketIORealtimeBroadcaster(io);
    await expect(
      b.broadcast(ch, { x: "not a number" } as never),
    ).rejects.toThrow();
    expect(emit).not.toHaveBeenCalled();
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test Expected: FAIL.

  • Step 3: Implement
// packages/core-realtime/src/socket-io-realtime-broadcaster.ts
import type { Server as IOServer } from "socket.io";
import type { z } from "zod";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { RealtimeChannelDescriptor } from "./realtime-channel";

export class SocketIORealtimeBroadcaster implements IRealtimeBroadcaster {
  constructor(private readonly io: IOServer) {}

  async broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    this.io.to(`ch:${descriptor.name}`).emit(descriptor.name, payload);
  }
}
  • Step 4: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test Expected: PASS.

  • Step 5: Commit
git add packages/core-realtime/src/socket-io-realtime-broadcaster.ts packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts
git commit -m "feat(core-realtime): SocketIORealtimeBroadcaster"

Task 13: Implement SocketIORealtimeServer

Files:

  • Create: packages/core-realtime/src/socket-io-realtime-server.ts
  • Create: packages/core-realtime/src/socket-io-realtime-server.test.ts

This is the largest single component. The test exercises the full lifecycle (connect → subscribe → inbound) against a real socket.io-server + socket.io-client pair on a transient port.

  • Step 1: Add socket.io-client to devDependencies

Edit packages/core-realtime/package.json:

"devDependencies": {
  "@repo/core-eslint": "workspace:*",
  "@repo/core-typescript": "workspace:*",
  "@types/node": "^22.0.0",
  "socket.io-client": "^4.7.0",
  "typescript": "^5.8.0",
  "vitest": "^3.0.0"
}

Run: pnpm install

  • Step 2: Write the failing integration test
// packages/core-realtime/src/socket-io-realtime-server.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient, type Socket as ClientSocket } from "socket.io-client";
import type { AddressInfo } from "node:net";
import { SocketIORealtimeServer } from "@/socket-io-realtime-server";
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
import { defineRealtimeChannel } from "@/realtime-channel";

const pingChannel = defineRealtimeChannel(
  "test.ping",
  z.object({ at: z.string() }).strict(),
  { scope: "authenticated" },
);

describe("SocketIORealtimeServer", () => {
  let httpServer: HttpServer;
  let io: IOServer;
  let server: SocketIORealtimeServer;
  let port: number;

  beforeEach(async () => {
    httpServer = createServer();
    io = new IOServer(httpServer);
    const registry = new RealtimeHandlerRegistry();

    let received: { input: unknown; ctx: unknown } | null = null;
    registry.register({
      descriptor: pingChannel,
      handler: async (input, ctx) => {
        received = { input, ctx };
      },
    });

    server = new SocketIORealtimeServer({
      httpServer,
      io,
      authenticator: {
        authenticate: async ({ cookies }) => {
          if (cookies.session === "valid") return { userId: "u1", roles: [] };
          return null;
        },
      },
      registry,
    });
    await server.start();

    await new Promise<void>((resolve) => httpServer.listen(0, resolve));
    port = (httpServer.address() as AddressInfo).port;

    // expose received via a closure for the assertions
    (server as unknown as { received: typeof received }).received = received;
  });

  afterEach(async () => {
    await server.stop();
    httpServer.close();
  });

  it("rejects subscribe to authenticated channel from anonymous socket", async () => {
    const client = ioClient(`http://localhost:${port}`);
    await new Promise<void>((r) => client.on("connect", () => r()));

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("forbidden");
    client.disconnect();
  });

  it("allows subscribe + invokes handler with ctx for authenticated socket", async () => {
    const client = ioClient(`http://localhost:${port}`, {
      extraHeaders: { Cookie: "session=valid" },
    });
    await new Promise<void>((r) => client.on("connect", () => r()));

    const subAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );
    expect(subAck.ok).toBe(true);

    const sentAt = new Date().toISOString();
    const ack = await new Promise<{ ok: boolean }>((r) =>
      client.emit("test.ping", { at: sentAt }, r),
    );

    expect(ack.ok).toBe(true);
    client.disconnect();
  });

  it("rejects unknown channel subscribe", async () => {
    const client = ioClient(`http://localhost:${port}`);
    await new Promise<void>((r) => client.on("connect", () => r()));

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "does.not.exist", r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("unknown_channel");
    client.disconnect();
  });

  it("rejects malformed inbound input", async () => {
    const client = ioClient(`http://localhost:${port}`, {
      extraHeaders: { Cookie: "session=valid" },
    });
    await new Promise<void>((r) => client.on("connect", () => r()));
    await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("test.ping", { at: 123 } as never, r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("invalid_input");
    client.disconnect();
  });
});
  • Step 3: Run; expect FAIL

Run: pnpm --filter @repo/core-realtime test socket-io-realtime-server Expected: FAIL.

  • Step 4: Implement
// packages/core-realtime/src/socket-io-realtime-server.ts
import type { Server as HttpServer } from "node:http";
import type { Server as IOServer, Socket } from "socket.io";
import { authorize } from "./authorize";
import { matchChannelTemplate } from "./channel-template";
import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";

function parseCookies(header: string): Record<string, string> {
  const out: Record<string, string> = {};
  for (const part of header.split(";")) {
    const [k, ...rest] = part.trim().split("=");
    if (k) out[k] = decodeURIComponent(rest.join("="));
  }
  return out;
}

export class SocketIORealtimeServer implements IRealtimeServer {
  private readonly httpServer: HttpServer;
  private readonly io: IOServer;
  private readonly opts: IRealtimeServerOptions;

  constructor(opts: IRealtimeServerOptions) {
    this.opts = opts;
    this.httpServer = opts.httpServer;
    this.io = opts.io;
  }

  async start(): Promise<void> {
    const { authenticator, registry } = this.opts;

    // Gate 1: connect — read cookie, authenticate, attach user.
    this.io.use(async (socket, next) => {
      const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
      socket.data.user = await authenticator.authenticate({
        cookies,
        headers: socket.handshake.headers as Record<string, string>,
      });
      next();
    });

    this.io.on("connection", (socket: Socket) => {
      // Gate 2: subscribe.
      socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
        // Find a registered descriptor whose name (or template) matches requestedName.
        let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null;
        for (const entry of registry.list()) {
          const m = matchChannelTemplate(entry.descriptor.name, requestedName);
          if (m) {
            matched = { descriptor: entry.descriptor, params: m.params };
            break;
          }
        }
        if (!matched) {
          ack?.({ ok: false, error: "unknown_channel" });
          return;
        }

        const allowed = await authorize(
          matched.descriptor as never,
          matched.params,
          socket.data.user ?? null,
        );
        if (!allowed) {
          ack?.({ ok: false, error: "forbidden" });
          return;
        }

        socket.join(`ch:${requestedName}`);
        ack?.({ ok: true });
      });

      // Gate 3: inbound — one listener per registered channel.
      for (const entry of registry.list()) {
        socket.on(entry.descriptor.name, async (payload: unknown, ack?: (r: unknown) => void) => {
          const parsed = entry.descriptor.schema.safeParse(payload);
          if (!parsed.success) {
            ack?.({ ok: false, error: "invalid_input" });
            return;
          }
          const allowed = await authorize(entry.descriptor, {}, socket.data.user ?? null);
          if (!allowed) {
            ack?.({ ok: false, error: "forbidden" });
            return;
          }
          try {
            await entry.handler(parsed.data, {
              userId: socket.data.user?.userId ?? null,
              roles: socket.data.user?.roles ?? [],
            });
            ack?.({ ok: true });
          } catch {
            ack?.({ ok: false, error: "handler_error" });
          }
        });
      }
    });
  }

  async stop(): Promise<void> {
    this.io.close();
  }
}
  • Step 5: Run; expect PASS

Run: pnpm --filter @repo/core-realtime test socket-io-realtime-server Expected: PASS — all 4 cases.

  • Step 6: Commit
git add packages/core-realtime/src/socket-io-realtime-server.ts packages/core-realtime/src/socket-io-realtime-server.test.ts packages/core-realtime/package.json pnpm-lock.yaml
git commit -m "feat(core-realtime): SocketIORealtimeServer (4 lifecycle gates)"

Task 14: Public barrel

Files:

  • Modify: packages/core-realtime/src/index.ts

  • Step 1: Replace the empty barrel with the full surface

// packages/core-realtime/src/index.ts
export type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel";
export { defineRealtimeChannel } from "./realtime-channel";
export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
export type { IRealtimeHandler, IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";
export type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
export type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
export type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";
export { RealtimeHandlerRegistry } from "./realtime-handler-registry";
export { CORE_REALTIME_SYMBOLS } from "./symbols";
export { InMemoryRealtimeBroadcaster } from "./in-memory-realtime-broadcaster";
export { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster";
export { SocketIORealtimeServer } from "./socket-io-realtime-server";
export { authorize } from "./authorize";
export { matchChannelTemplate } from "./channel-template";
  • Step 2: Verify

Run: pnpm --filter @repo/core-realtime typecheck test Expected: PASS.

  • Step 3: Commit
git add packages/core-realtime/src/index.ts
git commit -m "feat(core-realtime): public barrel"

Phase 3 — Recording test helper

Task 15: RecordingRealtimeBroadcaster in core-testing

Files:

  • Create: packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts
  • Create: packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts
  • Modify: packages/core-testing/src/instrumentation/index.ts

Critical: use the local-type-alias pattern from ADR-015 — do NOT import IRealtimeBroadcaster from @repo/core-realtime, that causes a build-graph cycle (core-testing is tooling-tagged, core-realtime is core-tagged, and turbo's typecheck graph would walk the dep both ways).

  • Step 1: Write the failing test
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { RecordingRealtimeBroadcaster } from "@/instrumentation/recording-realtime-broadcaster";

const ch = {
  name: "test.ch" as const,
  schema: z.object({ x: z.number() }).strict(),
  scope: "public" as const,
};

describe("RecordingRealtimeBroadcaster", () => {
  it("records every broadcast call after schema validation", async () => {
    const b = new RecordingRealtimeBroadcaster();
    await b.broadcast(ch, { x: 1 });
    await b.broadcast(ch, { x: 2 });
    expect(b.broadcasts).toEqual([
      { channel: "test.ch", payload: { x: 1 } },
      { channel: "test.ch", payload: { x: 2 } },
    ]);
  });

  it("rejects payloads that fail schema validation", async () => {
    const b = new RecordingRealtimeBroadcaster();
    await expect(b.broadcast(ch, { x: "wrong" } as never)).rejects.toThrow();
    expect(b.broadcasts).toHaveLength(0);
  });
});
  • Step 2: Run; expect FAIL

Run: pnpm --filter @repo/core-testing test recording-realtime Expected: FAIL.

  • Step 3: Implement (with local type aliases — DO NOT import from @repo/core-realtime)
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts
// Local type aliases mirroring @repo/core-realtime's contracts. Kept inline to
// avoid a build-graph cycle between core-testing (tooling) and core-realtime (core).
// Same pattern recording-event-bus + recording-job-queue use.
import type { z } from "zod";

type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
  readonly name: TName;
  readonly schema: TSchema;
};

interface IRealtimeBroadcaster {
  broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void>;
}

export class RecordingRealtimeBroadcaster implements IRealtimeBroadcaster {
  readonly broadcasts: { channel: string; payload: unknown }[] = [];

  async broadcast<T>(
    descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
    payload: T,
  ): Promise<void> {
    descriptor.schema.parse(payload);
    this.broadcasts.push({ channel: descriptor.name, payload });
  }
}
  • Step 4: Add to the instrumentation barrel

Modify packages/core-testing/src/instrumentation/index.ts:

export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger";
export { RecordingJobQueue } from "./recording-job-queue";
export { RecordingEventBus } from "./recording-event-bus";
export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster";
  • Step 5: Run; expect PASS

Run: pnpm --filter @repo/core-testing test Expected: PASS — every existing test plus the 2 new cases.

  • Step 6: Verify boundaries still pass

Run: pnpm turbo boundaries Expected: PASS — no new cycle (we're using local type aliases, no actual import of @repo/core-realtime).

  • Step 7: Commit
git add packages/core-testing/src/instrumentation/
git commit -m "feat(core-testing): RecordingRealtimeBroadcaster (local-type-alias pattern)"

Phase 4 — ESLint rules

Task 16: Write no-direct-socket-io rule

Files:

  • Create: packages/core-eslint/rules/no-direct-socket-io.js
  • Create: packages/core-eslint/rules/no-direct-socket-io.test.js

Mirrors no-direct-payload-jobs from ADR-015. Read that rule first for pattern: packages/core-eslint/rules/no-direct-payload-jobs.js.

  • Step 1: Write the failing test
// packages/core-eslint/rules/no-direct-socket-io.test.js
import { RuleTester } from "eslint";
import rule from "./no-direct-socket-io.js";

const tester = new RuleTester({
  languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});

tester.run("no-direct-socket-io", rule, {
  valid: [
    // Allowed inside core-realtime
    { code: 'import { Server } from "socket.io";', filename: "/repo/packages/core-realtime/src/socket-io-realtime-server.ts" },
    // Allowed in app servers
    { code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/server.ts" },
    // Allowed elsewhere when not importing socket.io
    { code: 'import { foo } from "bar";', filename: "/repo/packages/blog/src/foo.ts" },
  ],
  invalid: [
    {
      code: 'import { Server } from "socket.io";',
      filename: "/repo/packages/blog/src/foo.ts",
      errors: [{ messageId: "noDirectSocketIO" }],
    },
    {
      code: 'import { io } from "socket.io-client";',
      filename: "/repo/packages/blog/src/ui/Component.tsx",
      errors: [{ messageId: "noDirectSocketIOClient" }],
    },
  ],
});
  • Step 2: Implement
// packages/core-eslint/rules/no-direct-socket-io.js
const ALLOWED = [
  /\/packages\/core-realtime\/src\//,
  /\/apps\/[^/]+\/server\.ts$/,
];

export default {
  meta: {
    type: "problem",
    docs: { description: "Block direct socket.io imports outside core-realtime + app servers" },
    messages: {
      noDirectSocketIO: 'Import from "@repo/core-realtime" instead of "socket.io". Direct imports allowed only in packages/core-realtime/src/ and apps/*/server.ts.',
      noDirectSocketIOClient: 'Use the realtime helpers from "@repo/core-realtime" / "@repo/core-testing/instrumentation" instead of "socket.io-client".',
    },
    schema: [],
  },
  create(context) {
    const filename = context.filename ?? context.getFilename();
    const allowed = ALLOWED.some((re) => re.test(filename));
    if (allowed) return {};

    return {
      ImportDeclaration(node) {
        const source = node.source.value;
        if (source === "socket.io") {
          context.report({ node, messageId: "noDirectSocketIO" });
        } else if (source === "socket.io-client") {
          context.report({ node, messageId: "noDirectSocketIOClient" });
        }
      },
    };
  },
};
  • Step 3: Wire into the ESLint config

Find where no-direct-payload-jobs is registered (probably packages/core-eslint/base.js). Add the same registration for no-direct-socket-io. Apply the rule to all packages by default.

  • Step 4: Run rule's own tests

Run: pnpm --filter @repo/core-eslint test Expected: PASS.

  • Step 5: Run workspace lint to confirm no new violations

Run: pnpm lint Expected: PASS — no feature package imports socket.io yet.

  • Step 6: Commit
git add packages/core-eslint/
git commit -m "feat(core-eslint): rule no-direct-socket-io"

Task 17: Write no-realtime-handler-reexport rule

Files:

  • Create: packages/core-eslint/rules/no-realtime-handler-reexport.js
  • Create: packages/core-eslint/rules/no-realtime-handler-reexport.test.js

Parallel to ADR-015's no-handler-reexport. Read that rule first; this is the same pattern targeting realtime/handlers/ instead of events/handlers/.

  • Step 1: Write the failing test
// packages/core-eslint/rules/no-realtime-handler-reexport.test.js
import { RuleTester } from "eslint";
import rule from "./no-realtime-handler-reexport.js";

const tester = new RuleTester({
  languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});

tester.run("no-realtime-handler-reexport", rule, {
  valid: [
    // Importing a handler from inside a feature's bind-* file is allowed.
    {
      code: 'import { onPingHandler } from "../realtime/handlers/on-ping.handler";',
      filename: "/repo/packages/blog/src/di/bind-production.ts",
    },
    // Re-exporting a channel descriptor is allowed.
    {
      code: 'export { presenceChannel } from "./realtime/presence.channel";',
      filename: "/repo/packages/blog/src/index.ts",
    },
  ],
  invalid: [
    // Re-exporting a handler from any non-bind file is forbidden.
    {
      code: 'export { onPingHandler } from "./realtime/handlers/on-ping.handler";',
      filename: "/repo/packages/blog/src/index.ts",
      errors: [{ messageId: "noRealtimeHandlerReexport" }],
    },
    {
      code: 'export * from "./handlers/on-ping.handler";',
      filename: "/repo/packages/blog/src/realtime/index.ts",
      errors: [{ messageId: "noRealtimeHandlerReexport" }],
    },
  ],
});
  • Step 2: Implement (mirror the existing no-handler-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
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
git add packages/auth/
git commit -m "chore(auth): add // <gen:realtime-*> anchor comments"

Task 19: Retrofit anchors into blog

Repeat Task 18 for packages/blog/.

Commit: chore(blog): add // <gen:realtime-*> anchor comments

Task 20: Retrofit anchors into media

Repeat Task 18 for packages/media/.

Commit: chore(media): add // <gen:realtime-*> anchor comments

Task 21: Retrofit anchors into marketing-pages

Repeat Task 18 for packages/marketing-pages/.

Commit: chore(marketing-pages): add // <gen:realtime-*> anchor comments

Task 22: Retrofit anchors into navigation

Repeat Task 18 for packages/navigation/.

Commit: chore(navigation): add // <gen:realtime-*> anchor comments

Task 23: Extend the anchor-presence CI guard

Files:

  • Modify: packages/core-eslint/anchors.test.js

  • Step 1: Add new anchors to the assertion table

Append to the ANCHORS map in the test file:

"src/index.ts": [
  "// <gen:events>",
  "// <gen:realtime-channels>",        // NEW
],
"src/di/symbols.ts": [
  "// <gen:event-handler-symbols>",
  "// <gen:job-symbols>",
  "// <gen:realtime-handler-symbols>", // NEW
],
"src/di/bind-production.ts": [
  "// <gen:event-handlers>",
  "// <gen:jobs>",
  "// <gen:realtime-handlers>",        // NEW
],
"src/di/bind-dev-seed.ts": [
  "// <gen:event-handlers>",
  "// <gen:jobs>",
  "// <gen:realtime-handlers>",        // NEW
],
  • Step 2: Run the guard

Run: pnpm --filter @repo/core-eslint test Expected: PASS — every feature has every anchor.

  • Step 3: Commit
git add packages/core-eslint/anchors.test.js
git commit -m "test(core-eslint): extend anchor-presence guard for realtime anchors"

Task 24: Add anchors to the feature generator template

Files:

  • Modify: turbo/generators/templates/feature/src/index.ts.hbs
  • Modify: turbo/generators/templates/feature/src/di/symbols.ts.hbs
  • Modify: turbo/generators/templates/feature/src/di/bind-production.ts.hbs
  • Modify: turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs

Add the same three new anchors to the feature generator's templates so newly-scaffolded features ship with them. Same shape as Task 18 — exact placement, just in .hbs files.

  • Step 1: Edit each template

Add the same anchor comments at the same positions (end of file / inside symbols object / end of bind function body).

  • Step 2: Verify

Run: cd turbo/generators && npx tsc --noEmit Expected: PASS.

  • Step 3: Commit
git add turbo/generators/templates/feature/
git commit -m "chore(generators): add // <gen:realtime-*> anchors to feature template"

Phase 6 — Per-feature binder signature extension

Task 25: Extend bindProductionAuth and bindDevSeedAuth signatures

Files:

  • Modify: packages/auth/src/di/bind-production.ts
  • Modify: packages/auth/src/di/bind-dev-seed.ts
  • Modify: packages/auth/src/di/bind-dev-seed.test.ts
  • Modify: packages/auth/package.json

Per the spec §5.4, every feature binder grows from 5-arg (config, tracer, logger, bus, queue) to 7-arg (config, tracer, logger, bus, queue, realtime, realtimeRegistry). Body does NOT yet use realtime / realtimeRegistry — Phase 8 generators inject usage. Accept-and-forward, mirror the ADR-015 Phase 6 pattern.

  • Step 1: Add @repo/core-realtime to packages/auth/package.json dependencies
"dependencies": {
  "@repo/core-events": "workspace:*",
  "@repo/core-realtime": "workspace:*",
  "@repo/core-shared": "workspace:*",
  ...
}

Run: pnpm install

  • Step 2: Modify bind-production.ts

Add imports:

import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";

Extend signature:

export function bindProductionAuth(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
  realtime: IRealtimeBroadcaster,
  realtimeRegistry: IRealtimeHandlerRegistry,
): void {
  // existing body unchanged
  // before the closing brace, just below the existing void bus/queue lines:
  void realtime;
  void realtimeRegistry;
  // <gen:event-handlers>
  // <gen:jobs>
  // <gen:realtime-handlers>
}
  • Step 3: Modify bind-dev-seed.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:

import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
import { RealtimeHandlerRegistry } from "@repo/core-realtime";

// inside each test:
await bindDevSeedAuth(
  noop.tracer,
  noop.logger,
  new RecordingEventBus(),
  new RecordingJobQueue(),
  new RecordingRealtimeBroadcaster(),
  new RealtimeHandlerRegistry(),
);
  • Step 5: Verify

Run: pnpm --filter @repo/auth typecheck lint test Expected: PASS.

  • Step 6: Commit
git add packages/auth/ pnpm-lock.yaml
git commit -m "feat(auth): bind binders accept (realtime, realtimeRegistry) params"

Task 26: Extend blog binder signatures

Repeat Task 25 for packages/blog/.

Commit: feat(blog): bind binders accept (realtime, realtimeRegistry) params

Task 27: Extend media binder signatures

Repeat Task 25 for packages/media/.

Commit: feat(media): bind binders accept (realtime, realtimeRegistry) params

Task 28: Extend marketing-pages binder signatures

Repeat Task 25 for packages/marketing-pages/.

Commit: feat(marketing-pages): bind binders accept (realtime, realtimeRegistry) params

Task 29: Extend navigation binder signatures

Repeat Task 25 for packages/navigation/.

Commit: feat(navigation): bind binders accept (realtime, realtimeRegistry) params

Task 30: Update default DI module fallbacks

Files:

  • Modify: packages/<feature>/src/di/module.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)
git add packages/*/src/di/module.ts
git commit -m "feat(auth,blog,...): default DI module supplies in-memory realtime broadcaster"

Phase 7 — Custom Node server + bindAll wiring

Task 31: Replace next dev / next start with a custom server in apps/web-next

Files:

  • Create: apps/web-next/server.ts

  • Modify: apps/web-next/package.json (scripts, dependencies)

  • Step 1: Add socket.io to apps/web-next dependencies

"dependencies": {
  "@repo/auth": "workspace:*",
  "@repo/blog": "workspace:*",
  "@repo/core-api": "workspace:*",
  "@repo/core-cms": "workspace:*",
  "@repo/core-events": "workspace:*",
  "@repo/core-realtime": "workspace:*",
  "@repo/core-shared": "workspace:*",
  ...
  "socket.io": "^4.7.0"
}

Update scripts:

"scripts": {
  "build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
  "dev": "tsx server.ts",
  "start": "node --import tsx server.ts",
  "lint": "eslint .",
  ...
}

Add tsx to devDependencies if not present.

Run: pnpm install

  • Step 2: Write server.ts
// apps/web-next/server.ts
// SERVER-ONLY entry. Boots Next.js + Socket.IO on the same Node http server.
import "reflect-metadata";
import { createServer } from "node:http";
import next from "next";
import { Server as IOServer } from "socket.io";
import {
  RealtimeHandlerRegistry,
  SocketIORealtimeBroadcaster,
  SocketIORealtimeServer,
  type IRealtimeAuthenticator,
} from "@repo/core-realtime";
import { SESSION_COOKIE } from "@repo/auth";
import { authContainer } from "@repo/auth/di/container";
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
import type { IAuthenticationService } from "@repo/auth";
import { bindAll } from "./src/server/bind-production";

const dev = process.env.NODE_ENV !== "production";
const port = Number(process.env.PORT ?? 3000);

const app = next({ dev });
const handle = app.getRequestHandler();

await app.prepare();

const httpServer = createServer((req, res) => handle(req, res));
const io = new IOServer(httpServer);

const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();

// Wire features. bindAll passes broadcaster + registry into every per-feature binder.
await bindAll({ realtime: broadcaster, realtimeRegistry: registry });

const authenticator: IRealtimeAuthenticator = {
  authenticate: async ({ cookies }) => {
    const sessionId = cookies[SESSION_COOKIE];
    if (!sessionId) return null;
    const authService = authContainer.get<IAuthenticationService>(
      AUTH_SYMBOLS.IAuthenticationService,
    );
    const session = await authService.validateSession(sessionId);
    return session
      ? { userId: session.userId, roles: (session as { roles?: string[] }).roles ?? [] }
      : null;
  },
};

const realtimeServer = new SocketIORealtimeServer({
  httpServer,
  io,
  authenticator,
  registry,
});
await realtimeServer.start();

httpServer.listen(port, () => {
  console.log(`> Ready on http://localhost:${port}`);
});

Note: the IAuthenticationService.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
git add apps/web-next/server.ts apps/web-next/package.json pnpm-lock.yaml
git commit -m "feat(web-next): custom Node server hosting Next.js + Socket.IO on port 3000"

Task 32: Extend bindAll() in apps/web-next

Files:

  • Modify: apps/web-next/src/server/bind-production.ts
  • Modify: apps/web-next/src/server/bind-production.test.ts

bindAll currently takes no args. The custom server constructs the broadcaster + registry first (because they need the http server), then calls bindAll(deps). This is a signature change.

  • Step 1: Modify the function signatures + thread realtime through every feature binder
// apps/web-next/src/server/bind-production.ts (excerpt)
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";

type BindAllDeps = {
  realtime: IRealtimeBroadcaster;
  realtimeRegistry: IRealtimeHandlerRegistry;
};

let bound = false;

export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
  if (bound) return;
  bound = true;
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = await resolveEventsAndJobsProduction();
  const resolvedConfig = await config;
  const { realtime, realtimeRegistry } = deps;

  bindProductionAuth(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionBlog(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
  bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);

  bindRealtimeBridge(bus, realtime); // empty allowlist in v1, see § next task
}

export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
  if (bound) return;
  bound = true;
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = resolveEventsAndJobsDevSeed();
  const { realtime, realtimeRegistry } = deps;

  await bindDevSeedAuth(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedBlog(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedMarketingPages(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedNavigation(tracer, logger, bus, queue, realtime, realtimeRegistry);
  await bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry);

  bindRealtimeBridge(bus, realtime);
}

export async function bindAll(deps: BindAllDeps): Promise<void> {
  if (process.env.USE_DEV_SEED === "true") {
    await bindAllDevSeed(deps);
    return;
  }
  if (process.env.NODE_ENV === "production") {
    await bindAllProduction(deps);
    return;
  }
  await bindAllDevSeed(deps);
}
  • Step 2: Add the bridge stub (empty allowlist in v1)
// apps/web-next/src/server/bind-production.ts (continued)
function bindRealtimeBridge(_bus: IEventBus, _broadcaster: IRealtimeBroadcaster): void {
  // v1 ships with an empty allowlist. The dashboard PR adds the first entries here.
  // Example shape (commented out so v1 doesn't try to use it):
  // bus.subscribe(userSignedUpEvent, "realtime-bridge", async (payload) =>
  //   broadcaster.broadcast(adminEventStreamChannel, { kind: "user.signed-up", payload }),
  // );
}
  • Step 3: Update the test file

The existing tests already mock the per-feature binders. Update them to:

  • Pass { realtime, realtimeRegistry } 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
git add apps/web-next/src/server/
git commit -m "feat(web-next): bindAll wires (realtime, realtimeRegistry) through every feature"

Phase 8 — Generators

Task 33: Generator templates for gen realtime channel

Files:

  • Create: turbo/generators/templates/realtime/channel/channel.ts.hbs

  • Create: turbo/generators/templates/realtime/channel/channel.test.ts.hbs

  • Step 1: Write channel.ts.hbs

// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.ts
import { z } from "zod";
import { defineRealtimeChannel } from "@repo/core-realtime";

export const {{camelCase channelSlug}}Schema = z.object({}).strict();

export type {{pascalCase channelSlug}}Payload = z.infer<typeof {{camelCase channelSlug}}Schema>;

export const {{camelCase channelSlug}}Channel = defineRealtimeChannel(
  "{{kebabCase feature}}.{{kebabCase channelSlug}}",
  {{camelCase channelSlug}}Schema,
  { scope: {{{scopeLiteral}}} },
);
  • Step 2: Write channel.test.ts.hbs
// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.test.ts
import { describe, it, expect } from "vitest";
import {
  {{camelCase channelSlug}}Channel,
  {{camelCase channelSlug}}Schema,
} from "@/realtime/{{kebabCase channelSlug}}.channel";

describe("{{camelCase channelSlug}}Channel", () => {
  it("has the expected wire name", () => {
    expect({{camelCase channelSlug}}Channel.name).toBe(
      "{{kebabCase feature}}.{{kebabCase channelSlug}}",
    );
  });

  it("validates an empty payload (stub schema)", () => {
    expect(() => {{camelCase channelSlug}}Schema.parse({})).not.toThrow();
  });
});
  • Step 3: Commit
git add turbo/generators/templates/realtime/channel/
git commit -m "feat(turbo-gen): templates for gen realtime channel"

Task 34: Generator templates for gen realtime handler

Files:

  • Create: turbo/generators/templates/realtime/handler/handler.ts.hbs

  • Create: turbo/generators/templates/realtime/handler/handler.test.ts.hbs

  • Step 1: Write handler.ts.hbs

// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.ts
import type { {{pascalCase channelSlug}}Payload } from "../{{kebabCase channelSlug}}.channel";
import type { RealtimeContext } from "@repo/core-realtime";

export type IOn{{pascalCase channelSlug}}Handler = ReturnType<typeof on{{pascalCase channelSlug}}Handler>;

export const on{{pascalCase channelSlug}}Handler =
  () =>
  async (_input: {{pascalCase channelSlug}}Payload, _ctx: RealtimeContext): Promise<void> => {
    // TODO: implement the reaction. Inject deps via the factory's constructor
    // and use them here. The handler is wrapped in span+capture at bind time,
    // so just throwing on failure is the right shape.
  };
  • Step 2: Write handler.test.ts.hbs
// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.test.ts
import { describe, it, expect } from "vitest";
import { on{{pascalCase channelSlug}}Handler } from "@/realtime/handlers/on-{{kebabCase channelSlug}}.handler";

describe("on{{pascalCase channelSlug}}Handler", () => {
  it("returns a function (factory shape)", () => {
    const handler = on{{pascalCase channelSlug}}Handler();
    expect(typeof handler).toBe("function");
  });

  it("does not throw on a valid stub input", async () => {
    const handler = on{{pascalCase channelSlug}}Handler();
    await expect(
      handler({} as never, { userId: "u1", roles: [] }),
    ).resolves.toBeUndefined();
  });
});
  • Step 3: Commit
git add turbo/generators/templates/realtime/handler/
git commit -m "feat(turbo-gen): templates for gen realtime handler"

Task 35: Wire gen realtime into turbo/generators/config.ts

Files:

  • Modify: turbo/generators/config.ts

Read the existing event/job generator code in config.ts for the pattern. The realtime generator follows the same shape: a single realtime generator with a mode prompt that branches between channel and handler.

  • Step 1: Add the realtime generator definition

Inside the existing generator(plop) function, after plop.setGenerator("job", ...):

plop.setGenerator("realtime", {
  description: "Scaffold a realtime channel descriptor or inbound handler",
  prompts: [
    {
      type: "input",
      name: "mode",
      message: "Mode: channel | handler",
      validate(input: string) {
        if (!["channel", "handler"].includes(input)) return "Must be 'channel' or 'handler'";
        return true;
      },
    },
    {
      type: "input",
      name: "feature",
      message: "Feature (kebab-case, must exist):",
      validate(input: string) {
        if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
        if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
          return `packages/${input}/src does not exist`;
        }
        return true;
      },
    },
    {
      type: "input",
      name: "channelSlug",
      message: "Channel slug (kebab-case, e.g. 'presence-ping'):",
      validate(input: string) {
        if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
        return true;
      },
    },
    {
      type: "input",
      name: "scope",
      message: "Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):",
      validate(input: string, answers: { mode: string }) {
        if (answers.mode === "handler") return true;
        if (input === "public" || input === "authenticated" || input === "user-scoped") return true;
        if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true;
        return "Must be public | authenticated | role:NAME | user-scoped";
      },
    },
  ],
  actions(answers) {
    const a = answers as { mode: string; feature: string; channelSlug: string; scope: string };
    if (a.mode === "channel") return realtimeChannelActions(a);
    if (a.mode === "handler") return realtimeHandlerActions(a);
    throw new Error(`Unknown mode: ${a.mode}`);
  },
});
  • Step 2: Implement realtimeChannelActions
function realtimeChannelActions(a: { feature: string; channelSlug: string; scope: string }): PlopTypes.ActionType[] {
  const indexPath = `packages/${a.feature}/src/index.ts`;
  const scopeLiteral = renderScopeLiteral(a.scope);
  return [
    () => {
      assertAnchors(process.cwd(), indexPath, ["// <gen:realtime-channels>"]);
      return `Anchors verified in ${indexPath}`;
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
      templateFile: "templates/realtime/channel/channel.ts.hbs",
      data: { ...a, scopeLiteral },
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.test.ts`,
      templateFile: "templates/realtime/channel/channel.test.ts.hbs",
      data: { ...a, scopeLiteral },
    },
    {
      type: "modify",
      path: indexPath,
      pattern: /\/\/ <gen:realtime-channels>/,
      template: `// <gen:realtime-channels>\nexport {\n  {{camelCase channelSlug}}Channel,\n  {{camelCase channelSlug}}Schema,\n  type {{pascalCase channelSlug}}Payload,\n} from "./realtime/{{kebabCase channelSlug}}.channel";`,
      data: a,
    },
    () => printChannelNextSteps(a),
  ];
}

function renderScopeLiteral(scope: string): string {
  if (scope === "public") return `"public"`;
  if (scope === "authenticated") return `"authenticated"`;
  if (scope.startsWith("role:")) return `{ role: "${scope.slice("role:".length)}" }`;
  if (scope === "user-scoped") return `{ userScoped: true, template: "TODO_TEMPLATE" }`;
  throw new Error(`unknown scope: ${scope}`);
}

function printChannelNextSteps(a: { feature: string; channelSlug: string }): string {
  return [
    "─────────────────────────────────────────────────────────────",
    `Realtime channel ${a.feature}.${a.channelSlug} scaffolded.`,
    "",
    "Next steps (manual):",
    `  1. Fill in the Zod schema in packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
    `  2. To broadcast: inject 'realtime: IRealtimeBroadcaster' into a use case factory and call`,
    `       realtime.broadcast(${a.channelSlug}Channel, payload)`,
    `  3. To receive client-emitted messages on this channel: pnpm turbo gen realtime`,
    `     and pick "handler" mode with the same channel slug.`,
    `  4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
  ].join("\n");
}
  • Step 3: Implement realtimeHandlerActions
function realtimeHandlerActions(a: { feature: string; channelSlug: string }): PlopTypes.ActionType[] {
  const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
  const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
  const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
  return [
    () => {
      assertAnchors(process.cwd(), symbolFile, ["// <gen:realtime-handler-symbols>"]);
      assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
      assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
      return "All required anchors present";
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
      templateFile: "templates/realtime/handler/handler.ts.hbs",
      data: a,
    },
    {
      type: "add",
      path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.test.ts`,
      templateFile: "templates/realtime/handler/handler.test.ts.hbs",
      data: a,
    },
    {
      type: "modify",
      path: symbolFile,
      pattern: /\/\/ <gen:realtime-handler-symbols>/,
      template: `// <gen:realtime-handler-symbols>\n  IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`,
    },
    {
      type: "modify",
      path: bindProdFile,
      pattern: /\/\/ <gen:realtime-handlers>/,
      template: realtimeHandlerBindBlock(a),
    },
    {
      type: "modify",
      path: bindDevFile,
      pattern: /\/\/ <gen:realtime-handlers>/,
      template: realtimeHandlerBindBlock(a),
    },
    () => printHandlerNextSteps(a),
  ];
}

function realtimeHandlerBindBlock(a: { feature: string; channelSlug: string }): string {
  const handlerFn = `on${pascalCase(a.channelSlug)}Handler`;
  const channelConst = `${camel(a.channelSlug)}Channel`;
  return `// <gen:realtime-handlers>
  // ${handlerFn} — generated by gen realtime handler. Edit the handler file (not this block).
  const wrapped${pascalCase(a.channelSlug)} = withSpan(
    tracer,
    { name: "${a.feature}.${handlerFn}", op: "realtime-handler" },
    withCapture(
      logger,
      { feature: "${a.feature}", layer: "realtime-handler", name: "${a.feature}.${handlerFn}" },
      ${handlerFn}(),
    ),
  );
  realtimeRegistry.register({ descriptor: ${channelConst}, handler: wrapped${pascalCase(a.channelSlug)} });`;
}

function printHandlerNextSteps(a: { feature: string; channelSlug: string }): string {
  return [
    "─────────────────────────────────────────────────────────────",
    `Realtime handler on-${a.channelSlug} scaffolded in ${a.feature}.`,
    "",
    "Next steps (manual):",
    `  1. Implement the handler body in packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
    `  2. Add the imports to bind-production.ts AND bind-dev-seed.ts:`,
    `       import { ${camel(a.channelSlug)}Channel } from "../realtime/${a.channelSlug}.channel";`,
    `       import { on${pascalCase(a.channelSlug)}Handler } from "../realtime/handlers/on-${a.channelSlug}.handler";`,
    `  3. If the handler needs deps, extend the factory signature and pass them in the bind block.`,
    `  4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
    "─────────────────────────────────────────────────────────────",
  ].join("\n");
}
  • Step 4: Smoke test channel mode

Run from project root:

pnpm turbo gen realtime --args channel auth ping authenticated

Verify:

  • packages/auth/src/realtime/ping.channel.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:

rm -rf packages/auth/src/realtime/
git restore packages/auth/src/index.ts
  • Step 5: Smoke test handler mode

Re-create the channel + run handler:

pnpm turbo gen realtime --args channel auth ping authenticated
pnpm turbo gen realtime --args handler auth ping

Verify (and add the manual imports to bind files as per the printed instructions, then revert).

  • Step 6: Commit (after both smoke tests pass + revert)
git add turbo/generators/config.ts
git commit -m "feat(turbo-gen): wire gen realtime (channel + handler modes)"

Phase 9 — Proof-of-life: realtime-ping

Task 36: Implement realtime-ping channel pair inside core-realtime

Files:

  • Create: packages/core-realtime/src/realtime-ping.ts

The proof-of-life lives inside core-realtime itself, not in a feature, so the integration test in apps/web-next can drive it without polluting any feature's surface.

  • Step 1: Implement
// packages/core-realtime/src/realtime-ping.ts
import { z } from "zod";
import { defineRealtimeChannel } from "./realtime-channel";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";

const pingSchema = z.object({ at: z.string().datetime() }).strict();
const pongSchema = z.object({ at: z.string().datetime(), echo: z.string() }).strict();

export type PingPayload = z.infer<typeof pingSchema>;
export type PongPayload = z.infer<typeof pongSchema>;

export const realtimePingChannel = defineRealtimeChannel(
  "realtime.ping",
  pingSchema,
  { scope: "authenticated" },
);

export const realtimePongChannel = defineRealtimeChannel(
  "realtime.pong",
  pongSchema,
  { scope: "authenticated" },
);

export function realtimePingInboundDescriptor(
  broadcaster: IRealtimeBroadcaster,
): IInboundDescriptor<typeof realtimePingChannel.name, typeof pingSchema> {
  return {
    descriptor: realtimePingChannel,
    handler: async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
      await broadcaster.broadcast(realtimePongChannel, {
        at: input.at,
        echo: ctx.userId ?? "anonymous",
      });
    },
  };
}
  • Step 2: Add to the public barrel
// packages/core-realtime/src/index.ts (append)
export {
  realtimePingChannel,
  realtimePongChannel,
  realtimePingInboundDescriptor,
  type PingPayload,
  type PongPayload,
} from "./realtime-ping";
  • Step 3: Verify typecheck

Run: pnpm --filter @repo/core-realtime typecheck Expected: PASS.

  • Step 4: Commit
git add packages/core-realtime/src/realtime-ping.ts packages/core-realtime/src/index.ts
git commit -m "feat(core-realtime): realtime-ping proof-of-life channel pair"

Task 37: Register realtime-ping in apps/web-next bindAll

Files:

  • Modify: apps/web-next/src/server/bind-production.ts (the bindAll variants)

  • Step 1: Register the inbound descriptor with the realtime registry

In both bindAllProduction and bindAllDevSeed, after the per-feature binders run:

import { realtimePingInboundDescriptor } from "@repo/core-realtime";

// inside bindAllProduction and bindAllDevSeed, after feature binders, before bridge:
realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
  • Step 2: Add an env-gate for production
if (process.env.REALTIME_PING_DISABLED !== "true") {
  realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
}

This lets ops disable the smoke channel post-merge if they want a leaner production surface.

  • Step 3: Verify

Run: pnpm --filter @repo/web-next typecheck lint test Expected: PASS.

  • Step 4: Commit
git add apps/web-next/src/server/bind-production.ts
git commit -m "feat(web-next): register realtime-ping inbound (env-gateable)"

Task 38: Integration test: realtime-ping over real Socket.IO

Files:

  • Create: apps/web-next/src/__tests__/realtime-ping.test.ts

This is the v1 proof-of-life — equivalent to ADR-015's sign-up-welcome-email.test.ts.

  • Step 1: Add socket.io-client to apps/web-next devDependencies
"devDependencies": {
  ...
  "socket.io-client": "^4.7.0",
}

Run: pnpm install

  • Step 2: Write the test
// apps/web-next/src/__tests__/realtime-ping.test.ts
// e2e proof-of-life: connect → subscribe → emit ping → receive pong via the
// production-shaped binder + Socket.IO server, with cookie-session auth
// against a seeded test session.
import "reflect-metadata";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient } from "socket.io-client";
import type { AddressInfo } from "node:net";
import {
  RealtimeHandlerRegistry,
  SocketIORealtimeBroadcaster,
  SocketIORealtimeServer,
  realtimePingInboundDescriptor,
  type IRealtimeAuthenticator,
} from "@repo/core-realtime";

describe("e2e: realtime-ping exercises all four checkpoints", () => {
  let httpServer: HttpServer;
  let realtimeServer: SocketIORealtimeServer;
  let port: number;

  beforeEach(async () => {
    httpServer = createServer();
    const io = new IOServer(httpServer);
    const broadcaster = new SocketIORealtimeBroadcaster(io);
    const registry = new RealtimeHandlerRegistry();
    registry.register(realtimePingInboundDescriptor(broadcaster));

    const authenticator: IRealtimeAuthenticator = {
      authenticate: async ({ cookies }) =>
        cookies.session === "valid-session"
          ? { userId: "user_test", roles: [] }
          : null,
    };

    realtimeServer = new SocketIORealtimeServer({
      httpServer,
      io,
      authenticator,
      registry,
    });
    await realtimeServer.start();

    await new Promise<void>((r) => httpServer.listen(0, r));
    port = (httpServer.address() as AddressInfo).port;
  });

  afterEach(async () => {
    await realtimeServer.stop();
    await new Promise<void>((r) => httpServer.close(() => r()));
  });

  it("authenticated client gets pong after ping", async () => {
    const client = ioClient(`http://localhost:${port}`, {
      extraHeaders: { Cookie: "session=valid-session" },
    });
    await new Promise<void>((r) => client.on("connect", () => r()));

    const subAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "realtime.pong", r),
    );
    expect(subAck.ok).toBe(true);

    const pongs: { at: string; echo: string }[] = [];
    client.on("realtime.pong", (p) => pongs.push(p));

    const sentAt = "2026-05-08T12:00:00.000Z";
    const pingAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("realtime.ping", { at: sentAt }, r),
    );
    expect(pingAck.ok).toBe(true);

    // Pong arrives synchronously after handler runs.
    await new Promise<void>((r) => setImmediate(r));

    expect(pongs).toHaveLength(1);
    expect(pongs[0]).toEqual({ at: sentAt, echo: "user_test" });

    client.disconnect();
  });

  it("anonymous client cannot subscribe to pong (authenticated scope)", async () => {
    const client = ioClient(`http://localhost:${port}`);
    await new Promise<void>((r) => client.on("connect", () => r()));

    const subAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "realtime.pong", r),
    );

    expect(subAck.ok).toBe(false);
    expect(subAck.error).toBe("forbidden");
    client.disconnect();
  });
});
  • Step 3: Run; expect PASS

Run: pnpm --filter @repo/web-next test realtime-ping Expected: PASS — both cases.

  • Step 4: Commit
git add apps/web-next/src/__tests__/realtime-ping.test.ts apps/web-next/package.json pnpm-lock.yaml
git commit -m "test(web-next): e2e realtime-ping (4 checkpoints)"

Phase 10 — Documentation

Task 39: Write docs/decisions/adr-016-realtime-layer.md

Files:

  • Create: docs/decisions/adr-016-realtime-layer.md

  • Step 1: Read the format of recent ADRs

Open docs/decisions/adr-015-events-and-jobs.md. Same Status / Context / Decision / Alternatives / Consequences / Notes-from-execution / Out-of-scope / Related sections.

  • Step 2: Write ADR-016

Distill the spec's §1, §2 (rules), §3 (package), §6 (topology), §7 (auth), §8 (hybrid), §13 (v1 scope). Mirror the prose density of ADR-015. Each rule (R0/R1/R2) gets one paragraph. Cross-link the spec at docs/superpowers/specs/2026-05-08-realtime-design.md.

  • Step 3: Commit
git add docs/decisions/adr-016-realtime-layer.md
git commit -m "docs(adr-016): realtime layer (Socket.IO)"

Task 40: Write docs/guides/realtime.md

Files:

  • Create: docs/guides/realtime.md

  • Step 1: Read the events-and-jobs guide for the format

Open docs/guides/events-and-jobs.md. The realtime guide follows the same shape: three sections (declare a channel, broadcast from a use case, receive client messages with a handler), each with full code samples.

  • Step 2: Write the guide

Cover:

  • "Declare a channel" — runs gen realtime channel, fills the schema, picks scope

  • "Broadcast from a use case" — adds realtime: 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

git add docs/guides/realtime.md
git commit -m "docs(guide): realtime walkthrough"

Task 41: Update AGENTS.md

Files:

  • Modify: AGENTS.md

  • Step 1: Add a new section under Per-Package Conventions

Insert after "Cross-feature events and background jobs (Plan 10, ADR-015)" — same shape, three-rule summary:

### Realtime layer (ADR-016)

Three rules:

- **R0:** Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures.
- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-* files (ESLint-enforced via `no-realtime-handler-reexport`).
- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this.

`@repo/core-realtime` provides `IRealtimeBroadcaster` (server → client), `IRealtimeHandlerRegistry` (client → server), and the `SocketIORealtimeServer` adapter. `apps/web-next/server.ts` replaces `next start`/`next dev` with a custom Node http server hosting both Next.js and Socket.IO on port 3000.

Use the generators: `pnpm turbo gen realtime channel`, `pnpm turbo gen realtime handler`. They insert at three fixed `// <gen:realtime-*>` anchor comments per feature.

See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.
  • Step 2: Add a row to the Specification & Guides list
- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive
  • Step 3: Commit
git add AGENTS.md
git commit -m "docs(agents): realtime layer section + guide reference"

Task 42: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

  • Step 1: Update Quick Start

Add to the command block:

pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler
  • Step 2: Update Read First

Add: - docs/guides/realtime.md — Socket.IO channels, broadcasts, handlers

  • Step 3: Update Key Conventions

Add three short bullets matching R0/R1/R2 from the AGENTS.md section above.

  • Step 4: Commit
git add CLAUDE.md
git commit -m "docs(claude): realtime generator + R0/R1/R2 conventions"

Task 43: Update docs/guides/scaffolding-a-feature.md

Files:

  • Modify: docs/guides/scaffolding-a-feature.md

  • Step 1: Extend the "Adding events and jobs" section to cover realtime

Append pnpm turbo gen realtime channel and pnpm turbo gen realtime handler to the generator list. Note that newly-generated features include the three new realtime anchors.

  • Step 2: Commit
git add docs/guides/scaffolding-a-feature.md
git commit -m "docs(scaffolding): realtime generator reference"

Task 44: Update docs/architecture/dependency-flow.md and docs/architecture/vertical-feature-spec.md

Files:

  • Modify: docs/architecture/dependency-flow.md

  • Modify: docs/architecture/vertical-feature-spec.md

  • Step 1: Update the bindAll diagram

Add a resolveRealtime step + extend the binder signature line in dependency-flow.md to (config, tracer, logger, bus, queue, realtime, realtimeRegistry).

  • Step 2: Update vertical-feature-spec.md § 4 (optional folders)

Add realtime/ and realtime/handlers/ to the optional-folder list, with a cross-reference to ADR-016.

  • Step 3: Commit
git add docs/architecture/
git commit -m "docs(architecture): realtime layer in dependency-flow + vertical-feature-spec"

Phase 11 — Final verification

Task 45: Whole-monorepo green check

  • Step 1: Full lint

Run: pnpm lint Expected: PASS — including the two new ESLint rules, the anchor-presence guard, and all rule tests. Pre-existing warnings (NEXT_RUNTIME, etc.) unchanged.

  • Step 2: Full typecheck

Run: pnpm typecheck Expected: PASS.

  • Step 3: Full test

Run: pnpm test Expected: PASS — including core-realtime's ~20 test cases, the recording-realtime-broadcaster tests, the anchor-presence guard, and the realtime-ping integration test.

  • Step 4: Boundary check

Run: pnpm turbo boundaries Expected: PASS — core-realtime properly tagged, no new violations.

  • Step 5: Smoke run of generators
cp -r ~/Documents/Projects/template-vertical /tmp/realtime-final-test && cd /tmp/realtime-final-test && pnpm install
pnpm turbo gen realtime --args channel blog test-channel public
pnpm turbo gen realtime --args handler blog test-channel
# manually add the imports printed by the handler generator
pnpm --filter @repo/blog typecheck test lint

Expected: PASS.

rm -rf /tmp/realtime-final-test
  • Step 6: Manual dev-server smoke
pnpm --filter @repo/web-next dev
# In another terminal:
curl -i http://localhost:3000
# Stop the dev server.

Expected: HTTP 200 from Next.js, no Socket.IO errors in the dev-server log.

  • Step 7: Final commit (only if any fixes were needed)

If steps 16 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.