diff --git a/docs/guides/realtime.md b/docs/guides/realtime.md new file mode 100644 index 0000000..f4d199a --- /dev/null +++ b/docs/guides/realtime.md @@ -0,0 +1,291 @@ +# Realtime + +Walkthrough for adding Socket.IO realtime channels, broadcasts, and inbound handlers to a feature. For the architectural rationale, see [ADR-016](../decisions/adr-016-realtime-layer.md) and [the full spec](../superpowers/specs/2026-05-08-realtime-design.md). + +The three rules to keep in mind: + +- **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 (never re-exported, 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"`. Only `packages/core-realtime/src/socket-io-*.ts` and `apps/*/server.ts` are allowed. + +Two generators do the boilerplate. Each inserts at fixed `// ` anchor comments that are present in every feature. + +```bash +pnpm turbo gen realtime channel # channel descriptor +pnpm turbo gen realtime handler # inbound realtime handler +``` + +The bus-bridge pattern from [ADR-015](../decisions/adr-015-events-and-jobs.md) is also available: `bindRealtimeBridge(bus, broadcaster, allowlist)` in `apps/web-next/src/server/bind-production.ts` forwards allowlisted bus events onto realtime channels. The allowlist ships empty in v1 — see the spec §8 for the full hybrid pattern. + +--- + +## 1. Declare a channel + +Run from the repo root: + +```bash +pnpm turbo gen realtime --args channel blog article-feed public +``` + +The fourth argument is the channel scope. Valid values: + +| Scope arg | What it means | +|---|---| +| `public` | Any connected socket may subscribe (no auth required) | +| `authenticated` | Socket must have a valid session (gate 1 cleared) | +| `role:` | Socket must have the named role in `roles[]` | +| `user-scoped` | Socket must match `params.userId === socket.data.user.userId` (template channel) | + +This scaffolds: + +- `packages/blog/src/realtime/article-feed.channel.ts` (the descriptor — `defineRealtimeChannel` + Zod schema) +- `packages/blog/src/realtime/article-feed.channel.test.ts` +- A re-export at the `// ` anchor in `packages/blog/src/index.ts` + +Then **fill in the schema** with the fields the channel's payload carries: + +```ts +// packages/blog/src/realtime/article-feed.channel.ts +import { z } from "zod"; +import { defineRealtimeChannel } from "@repo/core-realtime"; + +export const articleFeedSchema = z + .object({ + id: z.string(), + slug: z.string(), + title: z.string(), + publishedAt: z.string().datetime(), + }) + .strict(); + +export type ArticleFeedPayload = z.infer; + +export const articleFeedChannel = defineRealtimeChannel( + "blog.article.feed", + articleFeedSchema, + { scope: "public" }, +); +``` + +The generator wires the re-export automatically — verify: + +```bash +pnpm --filter @repo/blog lint typecheck +``` + +### User-scoped channels + +For per-user delivery, use `user-scoped` scope and a template channel name: + +```ts +export const userNotificationsChannel = defineRealtimeChannel( + "notifications.user.{userId}", + notificationsSchema, + { scope: { userScoped: true, template: "notifications.user.{userId}" } }, +); +``` + +Clients subscribe to `"notifications.user.user_42"`. The server matches the template, extracts `params = { userId: "user_42" }`, and `authorize` checks `params.userId === socket.data.user.userId`. Only the matching user's socket clears gate 2. + +--- + +## 2. Broadcast from a use case + +Direct broadcast is the primary path — a use case adds `realtime: IRealtimeBroadcaster` to its factory signature and calls `realtime.broadcast(channel, payload)` after the success path. + +```ts +// packages/blog/src/application/use-cases/publish-article.use-case.ts +import type { IRealtimeBroadcaster } from "@repo/core-realtime"; +import { articleFeedChannel } from "../../realtime/article-feed.channel"; + +export const publishArticleUseCase = + ( + articles: IArticlesRepository, + realtime: IRealtimeBroadcaster, + ) => + async (input: PublishArticleInput): Promise => { + const article = await articles.publish(input.id); + await realtime.broadcast(articleFeedChannel, { + id: article.id, + slug: article.slug, + title: article.title, + publishedAt: article.publishedAt, + }); + return publishArticleOutputSchema.parse(article); + }; +``` + +`realtime.broadcast` is type-safe — TypeScript infers the payload type from `articleFeedChannel.schema` and rejects a mismatched object at compile time. + +**Update DI.** Both `bind-production.ts` and `bind-dev-seed.ts` receive `realtime` as a parameter. Thread it into the factory call: + +```ts +// packages/blog/src/di/bind-production.ts +export function bindProductionBlog( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, + bus: IEventBus, + queue: IJobQueue, + realtime: IRealtimeBroadcaster, + realtimeRegistry: IRealtimeHandlerRegistry, +): void { + // ... existing bindings ... + + const wrappedPublishArticle = withSpan( + tracer, + { name: "blog.publishArticle", op: "use-case" }, + withCapture( + logger, + { feature: "blog", layer: "use-case", name: "blog.publishArticle" }, + publishArticleUseCase(articlesRepo, realtime), + ), + ); + blogContainer.bind(BLOG_SYMBOLS.IPublishArticleUseCase) + .toConstantValue(wrappedPublishArticle); +} +``` + +**Testing.** Inject `RecordingRealtimeBroadcaster` from `@repo/core-testing/instrumentation` and assert against its `broadcasts` array: + +```ts +import { RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation"; + +it("broadcasts the article after publish", async () => { + const articles = new MockArticlesRepository([mockArticle]); + const realtime = new RecordingRealtimeBroadcaster(); + + const useCase = publishArticleUseCase(articles, realtime); + await useCase({ id: "article-1" }); + + expect(realtime.broadcasts).toHaveLength(1); + expect(realtime.broadcasts[0]).toMatchObject({ + channel: "blog.article.feed", + payload: { id: "article-1" }, + }); +}); +``` + +**Verify:** + +```bash +pnpm --filter @repo/blog lint typecheck test +``` + +--- + +## 3. Receive client messages + +Use an inbound handler when a connected client emits a message that the server should react to — presence pings, cursor positions, votes, ephemeral state that shouldn't go through tRPC. + +Run from the repo root: + +```bash +pnpm turbo gen realtime --args handler blog article-feed +``` + +This scaffolds: + +- `packages/blog/src/realtime/handlers/on-article-feed.handler.ts` + test +- A symbol at `// ` in `packages/blog/src/di/symbols.ts` +- A wrapped registration block at `// ` in both `bind-production.ts` and `bind-dev-seed.ts` + +The generator prints two manual steps. + +**1. Add the imports** to the top of both bind files (the modify-block can't add imports): + +```ts +import { articleFeedChannel } from "../realtime/article-feed.channel"; +import { onArticleFeedHandler } from "../realtime/handlers/on-article-feed.handler"; +``` + +**2. Fill in the handler body.** The generated handler factory is `(deps) => async (input, ctx) => Promise`. Inject what the handler needs and implement the body: + +```ts +// packages/blog/src/realtime/handlers/on-article-feed.handler.ts +import type { ArticleFeedPayload } from "../article-feed.channel"; +import type { IPresenceService } from "../../application/services/presence.service.interface"; +import type { RealtimeContext } from "@repo/core-realtime"; + +export type IOnArticleFeedHandler = ReturnType; + +export const onArticleFeedHandler = + (presence: IPresenceService) => + async (input: ArticleFeedPayload, ctx: RealtimeContext): Promise => { + if (!ctx.userId) return; + await presence.markViewing(ctx.userId, input.id); + }; +``` + +**3. Wire the dependency.** The generated bind-block emits `onArticleFeedHandler()` with no args. Edit it to pass the service: + +```ts +// packages/blog/src/di/bind-production.ts (excerpt at ) +const wrappedOnArticleFeed = withSpan( + tracer, + { name: "blog.onArticleFeed", op: "realtime-handler" }, + withCapture( + logger, + { feature: "blog", layer: "realtime-handler", name: "blog.onArticleFeed" }, + onArticleFeedHandler(presenceService), + ), +); +realtimeRegistry.register({ + descriptor: articleFeedChannel, + handler: wrappedOnArticleFeed, +}); +``` + +The same block is generated in `bind-dev-seed.ts`. Edit both. + +**Testing.** Unit-test the handler factory by injecting mocks directly — no server, no sockets: + +```ts +it("marks the user as viewing the article", async () => { + const presence = new MockPresenceService(); + const handler = onArticleFeedHandler(presence); + + await handler( + { id: "article-1", slug: "hello", title: "Hello", publishedAt: "2026-05-08T00:00:00.000Z" }, + { userId: "user_1", roles: [] }, + ); + + expect(presence.markViewingCalls).toHaveLength(1); + expect(presence.markViewingCalls[0]).toMatchObject({ userId: "user_1", articleId: "article-1" }); +}); +``` + +Handlers MUST NOT be re-exported from the feature's public surface (rule R1 — enforced by `core-eslint`'s `no-realtime-handler-reexport` rule). The bind files wire handlers internally; `src/index.ts` must never re-export from `realtime/handlers/`. + +**Verify:** + +```bash +pnpm --filter @repo/blog lint typecheck test +``` + +--- + +## Anchor protocol + +Three fixed anchor comments live in every feature for realtime: + +| File | Anchor | Used by | +|---|---|---| +| `src/index.ts` | `// ` | `gen realtime channel` | +| `src/di/symbols.ts` | `// ` | `gen realtime handler` | +| `src/di/bind-production.ts` | `// ` | `gen realtime handler` | +| `src/di/bind-dev-seed.ts` | `// ` | `gen realtime handler` | + +The CI guard at `packages/core-eslint/anchors.test.js` asserts these stay present in every feature. Remove one and CI fails — restore it and the test goes green. + +The six ADR-015 anchors (``, ``, ``, `` in both binders, `` in both binders) continue to exist alongside them. Each anchor is independent. + +## Integration test reference + +`apps/web-next/src/__tests__/realtime-ping.test.ts` exercises the full chain: `bindAllDevSeed()` → start Socket.IO server in-process → connect with a seeded session cookie → subscribe → emit ping → receive pong. It validates all four auth checkpoints in a real Socket.IO round-trip. Use it as a template when adding cross-feature realtime flows. + +## Related + +- [ADR-016](../decisions/adr-016-realtime-layer.md) — design decision record +- [ADR-015](../decisions/adr-015-events-and-jobs.md) — cross-feature events and background jobs (bus-bridge pattern) +- [Spec](../superpowers/specs/2026-05-08-realtime-design.md) — full design including topology, auth gates, and v1 scope