// 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...` 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 { 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 { 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): Promise { 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 }; }