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>
11 KiB
Realtime
Prerequisite: This guide assumes
@repo/core-realtimeis present. If you started from the slim template, runpnpm turbo gen core-package realtimefirst.
Walkthrough for adding Socket.IO realtime channels, broadcasts, and inbound handlers to a feature. For the architectural rationale, see ADR-016.
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.iolives in one package only. Feature packages MUST NOTimport "socket.io"orimport "socket.io-client". Onlypackages/core-realtime/src/socket-io-*.tsandapps/*/server.tsare allowed.
Two generators do the boilerplate. Each inserts at fixed // <gen:*> anchor comments that are present in every feature.
pnpm turbo gen realtime channel # channel descriptor
pnpm turbo gen realtime handler # inbound realtime handler
The bus-bridge pattern from ADR-015 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:
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 inpackages/blog/src/index.ts
Then fill in the schema with the fields the channel's payload carries:
// 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:
pnpm --filter @repo/blog lint typecheck
User-scoped channels
For per-user delivery, use user-scoped scope and a template channel name:
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.
// 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:
// 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:
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:
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:
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>inpackages/blog/src/di/symbols.ts - A wrapped registration block at
// <gen:realtime-handlers>in bothbind-production.tsandbind-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):
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:
// 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:
// 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:
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:
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.