fix(realtime): post-review polish from final branch review
Five fixes surfaced by the branch-wide code review on the realtime layer:
- server.ts: replace dynamic `import("@repo/auth/di/container")` with a
static top-of-file import. The dynamic-import workaround from 6a0ac63 is
no longer needed once the root tsconfig + TSX_TSCONFIG_PATH expose
decorator metadata to tsx; verified by booting `pnpm dev` clean.
- server.ts: correct the inline structural type for `validateSession` to
match the real `IAuthenticationService` contract (non-nullable, throws
on invalid session) and wrap the call in try/catch so unauthenticated
bubbles to a `null` return instead of dead-code `result ? ... : null`.
- bind-production.ts: extract `maybeRegisterRealtimePing()` that wraps the
built-in ping inbound handler in the same `withSpan(withCapture(...))`
sandwich the realtime-handler generator emits (R41–R44), so the
proof-of-life channel models the convention rather than registering raw.
- bind-production.test.ts: add 4 tests for the `REALTIME_PING_DISABLED`
env-gate (registered when unset in both binders, not registered when
"true", treated as enabled when "1").
- docs/guides/realtime.md: correct the integration-test reference at
line 285 — the test does not call `bindAllDevSeed()`; it builds the
Socket.IO server inline and exercises gates 1+2 only (gates 3+4 live in
socket-io-realtime-server.test.ts).
- adr-016: add a "Known follow-ups" section recording 6 lower-priority
refinements deferred from this branch (bridge stub test scaffolding,
registry register/registerChannel precedence, channel-template dot
constraint, server bare catch{}, BindAllDeps Partial widening, AGENTS.md
anchor count phrasing).
CI gates: lint 0 errors / 4 warnings (pre-existing turbo.json warnings),
typecheck clean, 24 web-next tests pass (was 20; 4 new env-gate tests),
boundaries 0 issues across 504 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,17 @@ import {
|
|||||||
type IRealtimeAuthenticator,
|
type IRealtimeAuthenticator,
|
||||||
} from "@repo/core-realtime";
|
} from "@repo/core-realtime";
|
||||||
import { SESSION_COOKIE } from "@repo/auth";
|
import { SESSION_COOKIE } from "@repo/auth";
|
||||||
|
import { authContainer } from "@repo/auth/di/container";
|
||||||
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
|
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
|
||||||
import { bindAll } from "./src/server/bind-production.js";
|
import { bindAll } from "./src/server/bind-production.js";
|
||||||
|
|
||||||
|
// Real shape of IAuthenticationService.validateSession: returns non-nullable
|
||||||
|
// on success and throws UnauthenticatedError on missing/invalid sessions.
|
||||||
|
// Kept as an inline structural type to avoid leaking auth's internal interface.
|
||||||
|
type AuthService = {
|
||||||
|
validateSession: (id: string) => Promise<{ user: { id: string }; session: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
||||||
@@ -34,22 +42,17 @@ const authenticator: IRealtimeAuthenticator = {
|
|||||||
authenticate: async ({ cookies }) => {
|
authenticate: async ({ cookies }) => {
|
||||||
const sessionId = cookies[SESSION_COOKIE];
|
const sessionId = cookies[SESSION_COOKIE];
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
// Lazy-import the auth container after bindAll() has already populated it.
|
const authService = authContainer.get<AuthService>(AUTH_SYMBOLS.IAuthenticationService);
|
||||||
// Dynamic import defers tsx's module transformation to runtime, which lets
|
try {
|
||||||
// reflect-metadata and the DI decorators resolve correctly in this server
|
const { user } = await authService.validateSession(sessionId);
|
||||||
// entry point.
|
// Roles are not yet in the session shape; extend here when DB-backed roles ship.
|
||||||
const { authContainer } = await import("@repo/auth/di/container");
|
return { userId: user.id, roles: (user as { roles?: string[] }).roles ?? [] };
|
||||||
const authService = authContainer.get<{
|
} catch {
|
||||||
validateSession: (id: string) => Promise<{ user: { id: string }; session: unknown } | null>;
|
// Invalid/expired session → reject the connection. Real auth-service errors
|
||||||
}>(AUTH_SYMBOLS.IAuthenticationService);
|
// (DB outages etc.) intentionally collapse to "unauthenticated" here too,
|
||||||
const result = await authService.validateSession(sessionId);
|
// which is the conservative choice for a public-facing socket.
|
||||||
return result
|
return null;
|
||||||
? {
|
}
|
||||||
userId: result.user.id,
|
|
||||||
// Roles are not yet in the session shape; extend here when DB-backed roles ship.
|
|
||||||
roles: (result as unknown as { roles?: string[] }).roles ?? [],
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,48 @@ describe("bindAll dispatcher", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("realtime-ping registration (REALTIME_PING_DISABLED env-gate)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers realtime.ping when REALTIME_PING_DISABLED is unset (production)", async () => {
|
||||||
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
const deps = makeDeps();
|
||||||
|
await bindAllProduction(deps);
|
||||||
|
expect(deps.realtimeRegistry.listChannels().map((d) => d.name)).toContain("realtime.ping");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers realtime.ping when REALTIME_PING_DISABLED is unset (dev seed)", async () => {
|
||||||
|
const { bindAllDevSeed } = await import("./bind-production");
|
||||||
|
const deps = makeDeps();
|
||||||
|
await bindAllDevSeed(deps);
|
||||||
|
expect(deps.realtimeRegistry.listChannels().map((d) => d.name)).toContain("realtime.ping");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT register realtime.ping when REALTIME_PING_DISABLED='true'", async () => {
|
||||||
|
vi.stubEnv("REALTIME_PING_DISABLED", "true");
|
||||||
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
const deps = makeDeps();
|
||||||
|
await bindAllProduction(deps);
|
||||||
|
expect(deps.realtimeRegistry.listChannels().map((d) => d.name)).not.toContain("realtime.ping");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats REALTIME_PING_DISABLED='1' as not-disabled (only literal 'true' disables)", async () => {
|
||||||
|
vi.stubEnv("REALTIME_PING_DISABLED", "1");
|
||||||
|
const { bindAllDevSeed } = await import("./bind-production");
|
||||||
|
const deps = makeDeps();
|
||||||
|
await bindAllDevSeed(deps);
|
||||||
|
expect(deps.realtimeRegistry.listChannels().map((d) => d.name)).toContain("realtime.ping");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import config from "@repo/core-cms";
|
|||||||
import {
|
import {
|
||||||
bindNoopInstrumentation,
|
bindNoopInstrumentation,
|
||||||
bindSentryInstrumentation,
|
bindSentryInstrumentation,
|
||||||
|
withCapture,
|
||||||
|
withSpan,
|
||||||
type ITracer,
|
type ITracer,
|
||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
@@ -121,9 +123,7 @@ export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
|
|||||||
bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 20
|
bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 20
|
||||||
bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 21
|
bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 21
|
||||||
bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 22
|
bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 22
|
||||||
if (process.env.REALTIME_PING_DISABLED !== "true") {
|
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
|
||||||
realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
|
|
||||||
}
|
|
||||||
bindRealtimeBridge(bus, realtime);
|
bindRealtimeBridge(bus, realtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +143,7 @@ export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
|
|||||||
await bindDevSeedMarketingPages(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 20
|
await bindDevSeedMarketingPages(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 20
|
||||||
await bindDevSeedNavigation(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 21
|
await bindDevSeedNavigation(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 21
|
||||||
await bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 22
|
await bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 22
|
||||||
if (process.env.REALTIME_PING_DISABLED !== "true") {
|
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
|
||||||
realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
|
|
||||||
}
|
|
||||||
bindRealtimeBridge(bus, realtime);
|
bindRealtimeBridge(bus, realtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +179,30 @@ export async function bindAll(deps?: Partial<BindAllDeps>): Promise<void> {
|
|||||||
await bindAllDevSeed(resolvedDeps);
|
await bindAllDevSeed(resolvedDeps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wraps the built-in realtime-ping inbound handler in the same span+capture
|
||||||
|
// sandwich the realtime-handler generator emits (R41–R44), so the
|
||||||
|
// proof-of-life channel models the convention rather than registering raw.
|
||||||
|
// Skipped entirely when REALTIME_PING_DISABLED === "true".
|
||||||
|
function maybeRegisterRealtimePing(
|
||||||
|
registry: IRealtimeHandlerRegistry,
|
||||||
|
realtime: IRealtimeBroadcaster,
|
||||||
|
tracer: ITracer,
|
||||||
|
logger: ILogger,
|
||||||
|
): void {
|
||||||
|
if (process.env.REALTIME_PING_DISABLED === "true") return;
|
||||||
|
const { descriptor, handler } = realtimePingInboundDescriptor(realtime);
|
||||||
|
const wrappedHandler = withSpan(
|
||||||
|
tracer,
|
||||||
|
{ name: "core-realtime.realtimePing", op: "realtime-handler" },
|
||||||
|
withCapture(
|
||||||
|
logger,
|
||||||
|
{ feature: "core-realtime", layer: "realtime-handler", name: "core-realtime.realtimePing" },
|
||||||
|
handler,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
registry.register({ descriptor, handler: wrappedHandler });
|
||||||
|
}
|
||||||
|
|
||||||
function bindRealtimeBridge(_bus: IEventBus, _broadcaster: IRealtimeBroadcaster): void {
|
function bindRealtimeBridge(_bus: IEventBus, _broadcaster: IRealtimeBroadcaster): void {
|
||||||
// v1 ships with an empty allowlist. The dashboard PR adds the first entries here.
|
// 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):
|
// Example shape (commented out so v1 doesn't try to use it):
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ Handlers are wrapped in the same `withSpan(tracer, { op: "realtime-handler" }, w
|
|||||||
4. **Custom Node server for `cms` and `web-tanstack`.** Both apps continue on their existing runtimes until they need realtime.
|
4. **Custom Node server for `cms` and `web-tanstack`.** Both apps continue on their existing runtimes until they need realtime.
|
||||||
5. **Production-mode e2e test.** The `realtime-ping` integration test exercises the four checkpoints in-process. A multi-socket test that verifies fan-out across N connected clients is deferred to v2.
|
5. **Production-mode e2e test.** The `realtime-ping` integration test exercises the four checkpoints in-process. A multi-socket test that verifies fan-out across N connected clients is deferred to v2.
|
||||||
|
|
||||||
|
## Known follow-ups (post-merge polish)
|
||||||
|
|
||||||
|
Items surfaced by the final branch review that were intentionally not landed in v1:
|
||||||
|
|
||||||
|
1. **`bindRealtimeBridge` stub has no test scaffolding.** v1 ships `apps/web-next/src/server/bind-production.ts:bindRealtimeBridge` as a no-op (`_`-prefixed args). The dashboard PR adds the first allowlist entries; a minimal contract-shaped test (e.g. accept `allowlist: BridgeEntry[]` and assert subscribe wiring) should land alongside the first entry, not before.
|
||||||
|
2. **`IRealtimeHandlerRegistry.register` + `registerChannel` precedence is implicit.** Calling both for the same channel name silently overwrites the channel-map descriptor while leaving the entry-map intact. Behaviour is correct for current callers; document the precedence in the interface JSDoc or reject conflicting re-registration once the second outbound channel ships.
|
||||||
|
3. **`matchChannelTemplate` placeholders cannot contain dots** (`packages/core-realtime/src/channel-template.ts:14-17` uses `([^.]+)`). Fine for UUID-style identifiers; document the constraint in `defineRealtimeChannel`'s JSDoc when the first non-UUID key shape arrives.
|
||||||
|
4. **`SocketIORealtimeServer` swallows handler errors with bare `catch {}`** (`packages/core-realtime/src/socket-io-realtime-server.ts:108-117`). Wrapped handlers (`withCapture`) already record the error; unwrapped handlers lose it. Adding a server-injected logger that records "handler_error for channel X" would help debug connection-level issues — defer until a debugging incident actually motivates it.
|
||||||
|
5. **`bindAll(deps?: Partial<BindAllDeps>)` permits a half-populated deps object** that mixes a real broadcaster with a fresh registry (or vice versa). In practice no caller does this, but the type doesn't enforce all-or-nothing semantics. Tighten to `deps?: BindAllDeps` (full or absent) when the next consumer lands.
|
||||||
|
6. **AGENTS.md anchor count phrasing.** AGENTS.md says "three fixed `// <gen:realtime-*>` anchor comments per feature." There are three *kinds* but four placements (the handlers anchor lives in both `bind-production.ts` and `bind-dev-seed.ts`). Tighten to "three anchor kinds across both bind files" when the AGENTS.md is next touched.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- ADR-008 — per-feature DI containers
|
- ADR-008 — per-feature DI containers
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ The six ADR-015 anchors (`<gen:events>`, `<gen:event-handler-symbols>`, `<gen:jo
|
|||||||
|
|
||||||
## Integration test reference
|
## 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.
|
`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
|
## Related
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user