Files
agentic-dev/apps/web-next/src/server/bind-production.ts
Danijel Martinek f8013451de 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>
2026-05-09 10:16:46 +02:00

230 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 1922).
*/
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 (R41R44), 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 };
}