From 4032e5a7228f37b5ce031bfc5505edd0b208d418 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 22:51:39 +0200 Subject: [PATCH] feat(web-next): bindAll wires (realtime, realtimeRegistry) through every feature Extends bindAll/bindAllProduction/bindAllDevSeed to accept BindAllDeps and thread realtime + realtimeRegistry as the 6th and 7th positional args into each per-feature binder. Adds bindRealtimeBridge stub (empty allowlist in v1). bindAll accepts optional deps with InMemoryRealtimeBroadcaster fallback so existing page-level callers stay type-safe. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/sign-up-welcome-email.test.ts | 7 +- .../src/server/bind-production.test.ts | 72 +++++++++++++++---- apps/web-next/src/server/bind-production.ts | 63 +++++++++++----- 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/apps/web-next/src/__tests__/sign-up-welcome-email.test.ts b/apps/web-next/src/__tests__/sign-up-welcome-email.test.ts index 530d51d..b7b1d89 100644 --- a/apps/web-next/src/__tests__/sign-up-welcome-email.test.ts +++ b/apps/web-next/src/__tests__/sign-up-welcome-email.test.ts @@ -7,6 +7,8 @@ import "reflect-metadata"; import { describe, it, expect, beforeEach } from "vitest"; import { bindAllDevSeed, __resetBindStateForTests } from "@/server/bind-production"; +import { RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation"; +import { RealtimeHandlerRegistry } from "@repo/core-realtime"; import { authContainer } from "@repo/auth/di/container"; import { AUTH_SYMBOLS } from "@repo/auth/di/symbols"; import type { ISignUpController } from "@repo/auth"; @@ -20,7 +22,10 @@ describe("e2e: sign-up triggers welcome email via cross-feature event", () => { }); it("delivers a welcome email after a successful sign-up", async () => { - await bindAllDevSeed(); + await bindAllDevSeed({ + realtime: new RecordingRealtimeBroadcaster(), + realtimeRegistry: new RealtimeHandlerRegistry(), + }); const mailer = marketingPagesContainer.get( MARKETING_PAGES_SYMBOLS.IMailerService, diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index 838f0e1..c6112c6 100644 --- a/apps/web-next/src/server/bind-production.test.ts +++ b/apps/web-next/src/server/bind-production.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation"; +import { RealtimeHandlerRegistry } from "@repo/core-realtime"; vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) })); vi.mock("payload", () => ({ getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })) })); @@ -21,6 +23,13 @@ vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => { }; }); +function makeDeps() { + return { + realtime: new RecordingRealtimeBroadcaster(), + realtimeRegistry: new RealtimeHandlerRegistry(), + }; +} + describe("bindAllProduction", () => { beforeEach(() => { vi.resetModules(); @@ -35,7 +44,7 @@ describe("bindAllProduction", () => { const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production"); const { bindProductionMedia } = await import("@repo/media/di/bind-production"); - await bindAllProduction(); + await bindAllProduction(makeDeps()); expect(bindProductionBlog).toHaveBeenCalledOnce(); expect(bindProductionAuth).toHaveBeenCalledOnce(); @@ -47,8 +56,9 @@ describe("bindAllProduction", () => { it("is idempotent — second call does not re-bind", async () => { const { bindAllProduction } = await import("./bind-production"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - await bindAllProduction(); - await bindAllProduction(); + const deps = makeDeps(); + await bindAllProduction(deps); + await bindAllProduction(deps); expect(bindProductionBlog).toHaveBeenCalledOnce(); }); @@ -58,13 +68,29 @@ describe("bindAllProduction", () => { const { PayloadJobsEventBus } = await import("@repo/core-events"); const { PayloadJobQueue } = await import("@repo/core-shared/jobs"); - await bindAllProduction(); + await bindAllProduction(makeDeps()); const args = vi.mocked(bindProductionAuth).mock.calls[0]!; - // Args: (config, tracer, logger, bus, queue) + // Args: (config, tracer, logger, bus, queue, realtime, realtimeRegistry) expect(args[3]).toBeInstanceOf(PayloadJobsEventBus); expect(args[4]).toBeInstanceOf(PayloadJobQueue); }); + + it("forwards realtime and realtimeRegistry as 6th + 7th args to each production binder", async () => { + const { RecordingRealtimeBroadcaster } = await import("@repo/core-testing/instrumentation"); + const { RealtimeHandlerRegistry } = await import("@repo/core-realtime"); + const { bindAllProduction } = await import("./bind-production"); + const { bindProductionAuth } = await import("@repo/auth/di/bind-production"); + + const realtime = new RecordingRealtimeBroadcaster(); + const realtimeRegistry = new RealtimeHandlerRegistry(); + await bindAllProduction({ realtime, realtimeRegistry }); + + const args = vi.mocked(bindProductionAuth).mock.calls[0]!; + // Args: (config, tracer, logger, bus, queue, realtime, realtimeRegistry) + expect(args[5]).toBe(realtime); + expect(args[6]).toBe(realtimeRegistry); + }); }); describe("bindAllDevSeed", () => { @@ -79,13 +105,29 @@ describe("bindAllDevSeed", () => { const { InMemoryEventBus } = await import("@repo/core-events"); const { InMemoryJobQueue } = await import("@repo/core-shared/jobs"); - await bindAllDevSeed(); + await bindAllDevSeed(makeDeps()); const args = vi.mocked(bindDevSeedAuth).mock.calls[0]!; - // Args: (tracer, logger, bus, queue) + // Args: (tracer, logger, bus, queue, realtime, realtimeRegistry) expect(args[2]).toBeInstanceOf(InMemoryEventBus); expect(args[3]).toBeInstanceOf(InMemoryJobQueue); }); + + it("forwards realtime and realtimeRegistry as 5th + 6th args to each dev-seed binder", async () => { + const { RecordingRealtimeBroadcaster } = await import("@repo/core-testing/instrumentation"); + const { RealtimeHandlerRegistry } = await import("@repo/core-realtime"); + const { bindAllDevSeed } = await import("./bind-production"); + const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed"); + + const realtime = new RecordingRealtimeBroadcaster(); + const realtimeRegistry = new RealtimeHandlerRegistry(); + await bindAllDevSeed({ realtime, realtimeRegistry }); + + const args = vi.mocked(bindDevSeedAuth).mock.calls[0]!; + // Args: (tracer, logger, bus, queue, realtime, realtimeRegistry) + expect(args[4]).toBe(realtime); + expect(args[5]).toBe(realtimeRegistry); + }); }); describe("bindAll dispatcher", () => { @@ -106,7 +148,7 @@ describe("bindAll dispatcher", () => { const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - await bindAll(); + await bindAll(makeDeps()); expect(bindDevSeedBlog).toHaveBeenCalledOnce(); expect(bindProductionBlog).not.toHaveBeenCalled(); @@ -118,7 +160,7 @@ describe("bindAll dispatcher", () => { const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); - await bindAll(); + await bindAll(makeDeps()); expect(bindProductionBlog).toHaveBeenCalledOnce(); expect(bindDevSeedBlog).not.toHaveBeenCalled(); @@ -130,7 +172,7 @@ describe("bindAll dispatcher", () => { const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - await bindAll(); + await bindAll(makeDeps()); expect(bindDevSeedBlog).toHaveBeenCalledOnce(); expect(bindProductionBlog).not.toHaveBeenCalled(); @@ -143,7 +185,7 @@ describe("bindAll dispatcher", () => { const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); - await bindAll(); + await bindAll(makeDeps()); expect(bindProductionBlog).toHaveBeenCalledOnce(); expect(bindDevSeedBlog).not.toHaveBeenCalled(); @@ -169,7 +211,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { "@repo/core-shared/instrumentation" ); - await bindAll(); + await bindAll(makeDeps()); expect(bindNoopInstrumentation).toHaveBeenCalledOnce(); expect(bindSentryInstrumentation).not.toHaveBeenCalled(); @@ -183,7 +225,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { "@repo/core-shared/instrumentation" ); - await bindAll(); + await bindAll(makeDeps()); expect(bindSentryInstrumentation).toHaveBeenCalledOnce(); expect(bindNoopInstrumentation).not.toHaveBeenCalled(); @@ -196,7 +238,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { const { bindSentryInstrumentation } = await import("@repo/core-shared/instrumentation"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); - await bindAll(); + await bindAll(makeDeps()); expect(bindSentryInstrumentation).toHaveBeenCalledOnce(); expect(bindDevSeedBlog).toHaveBeenCalledOnce(); @@ -209,7 +251,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { const { bindNoopInstrumentation } = await import("@repo/core-shared/instrumentation"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - await bindAll(); + await bindAll(makeDeps()); expect(bindNoopInstrumentation).toHaveBeenCalledOnce(); expect(bindProductionBlog).toHaveBeenCalledOnce(); diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index 7808de5..fcd9a44 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -20,6 +20,12 @@ import { PayloadJobQueue, type IJobQueue, } from "@repo/core-shared/jobs"; +import { + InMemoryRealtimeBroadcaster, + RealtimeHandlerRegistry, + 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"; @@ -31,6 +37,11 @@ import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-see 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 @@ -97,17 +108,19 @@ function resolveEventsAndJobsDevSeed(): { bus: IEventBus; queue: IJobQueue } { * 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(): Promise { +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; - bindProductionAuth(resolvedConfig, tracer, logger, bus, queue); // Phase E task 19 - bindProductionBlog(resolvedConfig, tracer, logger, bus, queue); // Phase E task 18 - bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue); // Phase E task 20 - bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue); // Phase E task 21 - bindProductionMedia(resolvedConfig, tracer, logger, bus, queue); // Phase E task 22 + 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 + bindRealtimeBridge(bus, realtime); } /** @@ -115,16 +128,18 @@ export async function bindAllProduction(): Promise { * with realistic seed data so the running app shows non-empty UI without * Payload booted. Mutually exclusive with `bindAllProduction()`. */ -export async function bindAllDevSeed(): Promise { +export async function bindAllDevSeed(deps: BindAllDeps): Promise { if (bound) return; bound = true; const { tracer, logger } = resolveInstrumentation(); // Rule 0 const { bus, queue } = resolveEventsAndJobsDevSeed(); - await bindDevSeedAuth(tracer, logger, bus, queue); // Phase E task 19 - await bindDevSeedBlog(tracer, logger, bus, queue); // Phase E task 18 - await bindDevSeedMarketingPages(tracer, logger, bus, queue); // Phase E task 20 - await bindDevSeedNavigation(tracer, logger, bus, queue); // Phase E task 21 - await bindDevSeedMedia(tracer, logger, bus, queue); // Phase E task 22 + 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 + bindRealtimeBridge(bus, realtime); } /** @@ -138,17 +153,33 @@ export async function bindAllDevSeed(): Promise { * 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(): Promise { +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(); + await bindAllDevSeed(resolvedDeps); return; } if (process.env.NODE_ENV === "production") { - await bindAllProduction(); + await bindAllProduction(resolvedDeps); return; } - await bindAllDevSeed(); + await bindAllDevSeed(resolvedDeps); +} + +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. */