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>
230 lines
9.4 KiB
TypeScript
230 lines
9.4 KiB
TypeScript
// apps/web-next/src/server/bind-production.ts
|
||
// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser.
|
||
import "reflect-metadata";
|
||
import { Container } from "inversify";
|
||
import { getPayload } from "payload";
|
||
import config from "@repo/core-cms";
|
||
import {
|
||
bindNoopInstrumentation,
|
||
bindSentryInstrumentation,
|
||
withCapture,
|
||
withSpan,
|
||
type ITracer,
|
||
type ILogger,
|
||
} from "@repo/core-shared/instrumentation";
|
||
import {
|
||
InMemoryEventBus,
|
||
PayloadJobsEventBus,
|
||
type IEventBus,
|
||
} from "@repo/core-events";
|
||
import {
|
||
InMemoryJobQueue,
|
||
PayloadJobQueue,
|
||
type IJobQueue,
|
||
} from "@repo/core-shared/jobs";
|
||
import {
|
||
InMemoryRealtimeBroadcaster,
|
||
RealtimeHandlerRegistry,
|
||
realtimePingInboundDescriptor,
|
||
type IRealtimeBroadcaster,
|
||
type IRealtimeHandlerRegistry,
|
||
} from "@repo/core-realtime";
|
||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
|
||
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
|
||
import { bindProductionMedia } from "@repo/media/di/bind-production";
|
||
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
|
||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
|
||
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
||
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
||
|
||
type BindAllDeps = {
|
||
realtime: IRealtimeBroadcaster;
|
||
realtimeRegistry: IRealtimeHandlerRegistry;
|
||
};
|
||
|
||
let bound = false;
|
||
|
||
// Shared container holds TRACER + LOGGER bindings; per-feature containers
|
||
// receive references via parameter passing. This separates the instrumentation
|
||
// container (one) from feature containers (per-feature, ADR-008).
|
||
const sharedContainer = new Container();
|
||
|
||
let resolvedTracer: ITracer | null = null;
|
||
let resolvedLogger: ILogger | null = null;
|
||
let resolvedBus: IEventBus | null = null;
|
||
let resolvedQueue: IJobQueue | null = null;
|
||
|
||
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
|
||
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
|
||
if (resolvedTracer && resolvedLogger) {
|
||
return { tracer: resolvedTracer, logger: resolvedLogger };
|
||
}
|
||
const dsn = process.env.WEB_NEXT_SENTRY_DSN;
|
||
const result = dsn
|
||
? bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" })
|
||
: bindNoopInstrumentation(sharedContainer);
|
||
resolvedTracer = result.tracer;
|
||
resolvedLogger = result.logger;
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Production-mode event bus + job queue: backed by Payload's job system so
|
||
* events fan out via durable Payload tasks (`PayloadJobsEventBus` enqueues
|
||
* `__events.<publisher>.<event>.<consumer>` per subscribed handler) and ad-hoc
|
||
* jobs go through `PayloadJobQueue.enqueue`. Cached after first resolution.
|
||
*/
|
||
async function resolveEventsAndJobsProduction(): Promise<{
|
||
bus: IEventBus;
|
||
queue: IJobQueue;
|
||
}> {
|
||
if (resolvedBus && resolvedQueue) return { bus: resolvedBus, queue: resolvedQueue };
|
||
const resolvedConfig = await config;
|
||
const payload = await getPayload({ config: resolvedConfig });
|
||
const queue = new PayloadJobQueue(payload);
|
||
const bus = new PayloadJobsEventBus(queue);
|
||
resolvedBus = bus;
|
||
resolvedQueue = queue;
|
||
return { bus, queue };
|
||
}
|
||
|
||
/**
|
||
* Dev-seed mode: in-process bus + queue. Per-feature binders register their
|
||
* job handlers via `queue.register(slug, handler)` and subscribe their event
|
||
* handlers via `bus.subscribe(...)` at bind time, so dev/test exercises the
|
||
* full publish → handler → enqueue path without booting Payload.
|
||
*/
|
||
function resolveEventsAndJobsDevSeed(): { bus: IEventBus; queue: IJobQueue } {
|
||
if (resolvedBus && resolvedQueue) return { bus: resolvedBus, queue: resolvedQueue };
|
||
const queue = new InMemoryJobQueue();
|
||
const bus = new InMemoryEventBus();
|
||
resolvedBus = bus;
|
||
resolvedQueue = queue;
|
||
return { bus, queue };
|
||
}
|
||
|
||
/**
|
||
* Production path: swap each feature's mock repository binding for the real
|
||
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
||
* feature as Phase E feature wiring lands (blog: task 18; remaining: tasks 19–22).
|
||
*/
|
||
export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
|
||
if (bound) return;
|
||
bound = true;
|
||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||
const { bus, queue } = await resolveEventsAndJobsProduction();
|
||
const resolvedConfig = await config;
|
||
const { realtime, realtimeRegistry } = deps;
|
||
bindProductionAuth(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 19
|
||
bindProductionBlog(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 18
|
||
bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 20
|
||
bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 21
|
||
bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 22
|
||
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
|
||
bindRealtimeBridge(bus, realtime);
|
||
}
|
||
|
||
/**
|
||
* Dev-seed path: keep each feature's MockXRepository in place but populate it
|
||
* with realistic seed data so the running app shows non-empty UI without
|
||
* Payload booted. Mutually exclusive with `bindAllProduction()`.
|
||
*/
|
||
export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
|
||
if (bound) return;
|
||
bound = true;
|
||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||
const { bus, queue } = resolveEventsAndJobsDevSeed();
|
||
const { realtime, realtimeRegistry } = deps;
|
||
await bindDevSeedAuth(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 19
|
||
await bindDevSeedBlog(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 18
|
||
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 bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry); // Phase E task 22
|
||
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
|
||
bindRealtimeBridge(bus, realtime);
|
||
}
|
||
|
||
/**
|
||
* Boot dispatcher: pick the binder based on the environment.
|
||
*
|
||
* Resolution order (first match wins):
|
||
*
|
||
* Rule 0 (always): instrumentation (Noop vs Sentry) from WEB_NEXT_SENTRY_DSN
|
||
* presence — runs inside both bindAllProduction and
|
||
* bindAllDevSeed via resolveInstrumentation().
|
||
* Rule 1: USE_DEV_SEED === "true" → dev seed (explicit override)
|
||
* Rule 2: NODE_ENV === "production" → real Payload via bindAllProduction
|
||
* Rule 3: otherwise → dev seed (developer-friendly default)
|
||
*
|
||
* `deps` is optional; omitting it (page-level fallback, already-bound guard)
|
||
* uses in-memory noop implementations. In the custom-server flow, `server.ts`
|
||
* always provides Socket.IO-backed deps before any page request runs.
|
||
*/
|
||
export async function bindAll(deps?: Partial<BindAllDeps>): Promise<void> {
|
||
const resolvedDeps: BindAllDeps = {
|
||
realtime: deps?.realtime ?? new InMemoryRealtimeBroadcaster(),
|
||
realtimeRegistry: deps?.realtimeRegistry ?? new RealtimeHandlerRegistry(),
|
||
};
|
||
if (process.env.USE_DEV_SEED === "true") {
|
||
await bindAllDevSeed(resolvedDeps);
|
||
return;
|
||
}
|
||
if (process.env.NODE_ENV === "production") {
|
||
await bindAllProduction(resolvedDeps);
|
||
return;
|
||
}
|
||
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 {
|
||
// 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 }),
|
||
// );
|
||
}
|
||
|
||
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
|
||
export function __resetBindStateForTests(): void {
|
||
bound = false;
|
||
resolvedTracer = null;
|
||
resolvedLogger = null;
|
||
resolvedBus = null;
|
||
resolvedQueue = null;
|
||
}
|
||
|
||
/** Test-only accessor for resolved instrumentation. */
|
||
export function __getInstrumentationForTests(): {
|
||
tracer: ITracer | null;
|
||
logger: ILogger | null;
|
||
} {
|
||
return { tracer: resolvedTracer, logger: resolvedLogger };
|
||
}
|