Files
agentic-dev/docs/guides/realtime.md
Danijel Martinek 89d47cce5c docs: strip dead docs/superpowers/ refs across ADRs + guides + glossary
The docs/superpowers/{specs,plans}/ directory was archived to .archive/
in an earlier session (and .archive/ is gitignored). Every md link
into that path is now a broken reference for anyone consuming the
template fresh.

Stripped:
  - ADR-011: **Spec:** header line
  - ADR-015: **Spec:** + **Plan:** header lines
  - ADR-016: **Spec:** + **Plan:** header lines + footer "Spec —"
    bullet (the design rationale is captured in the ADR body itself)
  - ADR-017: **Spec:** + **Plan:** header lines
  - ADR-018: **Spec:** + **Plan:** header lines
  - guides/realtime.md: inline "the full spec" link + footer
    [Spec] entry (folded its description into the ADR-016 entry)
  - guides/events-and-jobs.md: inline "the full spec" link
  - architecture/vertical-feature-spec.md: stale "Deleted" subsection
    referencing docs/superpowers/plans/*

Updated:
  - glossary.md "PRD" entry: clarified status flow now matches the
    shipped pnpm work prd-ship lifecycle (draft -> in-review ->
    approved -> shipped); removed the parenthetical pointing at
    docs/superpowers/specs/ as a definition of "spec"
  - glossary.md "spec" flagged-ambiguity: rewritten to reflect that
    durable design lives in ADRs (docs/decisions/adr-NNN-*.md) and
    implementation seeds live in PRDs (docs/work/prds/*.prd.md) —
    "spec" should be avoided in this template

Preserved (legitimate refs to the SuperPowers plugin, not the dir):
  - agent-first-workflow-and-conformance.md mentions of
    `superpowers:brainstorming` — these reference the external
    plugin skill, not a file in the repo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:00:11 +02:00

299 lines
11 KiB
Markdown

# Realtime
> **Prerequisite:** This guide assumes `@repo/core-realtime` is present. If you started from the slim template, run `pnpm turbo gen core-package realtime` first.
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).
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 `// <gen:*>` 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:<name>` | 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 `// <gen:realtime-channels>` 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<typeof articleFeedSchema>;
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<PublishArticleOutput> => {
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<IPublishArticleUseCase>(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 `// <gen:realtime-handler-symbols>` in `packages/blog/src/di/symbols.ts`
- A wrapped registration block at `// <gen:realtime-handlers>` 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<void>`. 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<typeof onArticleFeedHandler>;
export const onArticleFeedHandler =
(presence: IPresenceService) =>
async (input: ArticleFeedPayload, ctx: RealtimeContext): Promise<void> => {
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 <gen:realtime-handlers>)
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 (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-channels>` | `gen realtime channel` |
| `src/di/symbols.ts` | `// <gen:realtime-handler-symbols>` | `gen realtime handler` |
| `src/di/bind-production.ts` | `// <gen:realtime-handlers>` | `gen realtime handler` |
| `src/di/bind-dev-seed.ts` | `// <gen:realtime-handlers>` | `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 (`<gen:events>`, `<gen:event-handler-symbols>`, `<gen:job-symbols>`, `<gen:event-handlers>` in both binders, `<gen:jobs>` 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 gates 1 + 2 over a real Socket.IO connection: build broadcaster + registry + stub authenticator → start Socket.IO server in-process → connect with a stub session cookie → subscribe → emit ping → receive pong. Gates 3 + 4 are covered in `packages/core-realtime/src/socket-io-realtime-server.test.ts` (inbound rejection of unknown channels and forbidden scope). Use this test as a template when adding cross-feature realtime flows.
## Related
- [ADR-016](../decisions/adr-016-realtime-layer.md) — design decision record (full design including topology, auth gates, and v1 scope)
- [ADR-015](../decisions/adr-015-events-and-jobs.md) — cross-feature events and background jobs (bus-bridge pattern)