refactor: remove core-realtime from main (scaffoldable via gen core-package realtime)

This commit is contained in:
2026-05-09 13:45:52 +02:00
parent e28fe847fd
commit 57b2ff5191
55 changed files with 80 additions and 1777 deletions

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,6 @@ const nextConfig = {
"@repo/core-api",
"@repo/core-cms",
"@repo/core-events",
"@repo/core-realtime",
"@repo/core-shared",
"@repo/core-trpc",
"@repo/core-ui",

View File

@@ -19,7 +19,6 @@
"@repo/core-api": "workspace:*",
"@repo/core-cms": "workspace:*",
"@repo/core-events": "workspace:*",
"@repo/core-realtime": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:*",
"@repo/core-ui": "workspace:*",
@@ -35,12 +34,10 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"reflect-metadata": "^0.2.2",
"socket.io": "^4.7.0",
"superjson": "^2.2.1"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"socket.io-client": "^4.7.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",

View File

@@ -1,27 +1,12 @@
// apps/web-next/server.ts
// SERVER-ONLY entry. Boots Next.js + Socket.IO on the same Node http server.
// SERVER-ONLY entry. Custom Next.js server for local development.
// When @repo/core-realtime is scaffolded, this file is extended to boot
// Socket.IO alongside Next (see pnpm turbo gen core-package realtime).
import "reflect-metadata";
import { createServer } from "node:http";
import next from "next";
import { Server as IOServer } from "socket.io";
import {
RealtimeHandlerRegistry,
SocketIORealtimeBroadcaster,
SocketIORealtimeServer,
type IRealtimeAuthenticator,
} from "@repo/core-realtime";
import { SESSION_COOKIE } from "@repo/auth";
import { authContainer } from "@repo/auth/di/container";
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
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 port = Number(process.env.PORT ?? 3000);
@@ -30,40 +15,9 @@ const handle = app.getRequestHandler();
await app.prepare();
await bindAll();
const httpServer = createServer((req, res) => handle(req, res));
const io = new IOServer(httpServer);
const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();
await bindAll({ realtime: broadcaster, realtimeRegistry: registry });
const authenticator: IRealtimeAuthenticator = {
authenticate: async ({ cookies }) => {
const sessionId = cookies[SESSION_COOKIE];
if (!sessionId) return null;
const authService = authContainer.get<AuthService>(AUTH_SYMBOLS.IAuthenticationService);
try {
const { user } = await authService.validateSession(sessionId);
// Roles are not yet in the session shape; extend here when DB-backed roles ship.
return { userId: user.id, roles: (user as { roles?: string[] }).roles ?? [] };
} catch {
// Invalid/expired session → reject the connection. Real auth-service errors
// (DB outages etc.) intentionally collapse to "unauthenticated" here too,
// which is the conservative choice for a public-facing socket.
return null;
}
},
};
const realtimeServer = new SocketIORealtimeServer({
httpServer,
io,
authenticator,
registry,
});
await realtimeServer.start();
httpServer.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});

View File

@@ -1,97 +0,0 @@
// e2e proof-of-life: connect → subscribe → emit ping → receive pong via the
// production-shaped binder + Socket.IO server, with cookie-session auth
// against a seeded test session.
import "reflect-metadata";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient } from "socket.io-client";
import type { AddressInfo } from "node:net";
import {
RealtimeHandlerRegistry,
SocketIORealtimeBroadcaster,
SocketIORealtimeServer,
realtimePingInboundDescriptor,
realtimePongChannel,
type IRealtimeAuthenticator,
} from "@repo/core-realtime";
describe("e2e: realtime-ping exercises all four checkpoints", () => {
let httpServer: HttpServer;
let realtimeServer: SocketIORealtimeServer;
let port: number;
beforeEach(async () => {
httpServer = createServer();
const io = new IOServer(httpServer);
const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();
registry.register(realtimePingInboundDescriptor(broadcaster));
registry.registerChannel(realtimePongChannel);
const authenticator: IRealtimeAuthenticator = {
authenticate: async ({ cookies }) =>
cookies.session === "valid-session"
? { userId: "user_test", roles: [] }
: null,
};
realtimeServer = new SocketIORealtimeServer({
httpServer,
io,
authenticator,
registry,
});
await realtimeServer.start();
await new Promise<void>((r) => httpServer.listen(0, r));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await realtimeServer.stop();
await new Promise<void>((r) => httpServer.close(() => r()));
});
it("authenticated client gets pong after ping", async () => {
const client = ioClient(`http://localhost:${port}`, {
extraHeaders: { Cookie: "session=valid-session" },
});
await new Promise<void>((r) => client.on("connect", () => r()));
const subAck = await new Promise<{ ok: boolean }>((r) =>
client.emit("subscribe", "realtime.pong", r),
);
expect(subAck.ok).toBe(true);
const pongs: { at: string; echo: string }[] = [];
client.on("realtime.pong", (p) => pongs.push(p));
const sentAt = "2026-05-08T12:00:00.000Z";
const pingAck = await new Promise<{ ok: boolean }>((r) =>
client.emit("realtime.ping", { at: sentAt }, r),
);
expect(pingAck.ok).toBe(true);
// Pong arrives synchronously after handler runs.
await new Promise<void>((r) => setImmediate(r));
expect(pongs).toHaveLength(1);
expect(pongs[0]).toEqual({ at: sentAt, echo: "user_test" });
client.disconnect();
});
it("anonymous client cannot subscribe to pong (authenticated scope)", async () => {
const client = ioClient(`http://localhost:${port}`);
await new Promise<void>((r) => client.on("connect", () => r()));
const subAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
client.emit("subscribe", "realtime.pong", r),
);
expect(subAck.ok).toBe(false);
expect(subAck.error).toBe("forbidden");
client.disconnect();
});
});

View File

@@ -7,8 +7,6 @@
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";
@@ -22,10 +20,7 @@ describe("e2e: sign-up triggers welcome email via cross-feature event", () => {
});
it("delivers a welcome email after a successful sign-up", async () => {
await bindAllDevSeed({
realtime: new RecordingRealtimeBroadcaster(),
realtimeRegistry: new RealtimeHandlerRegistry(),
});
await bindAllDevSeed();
const mailer = marketingPagesContainer.get<RecordingMailerService>(
MARKETING_PAGES_SYMBOLS.IMailerService,

View File

@@ -1,6 +1,4 @@
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() } })) }));
@@ -23,13 +21,6 @@ vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
};
});
function makeDeps() {
return {
realtime: new RecordingRealtimeBroadcaster(),
realtimeRegistry: new RealtimeHandlerRegistry(),
};
}
describe("bindAllProduction", () => {
beforeEach(() => {
vi.resetModules();
@@ -44,7 +35,7 @@ describe("bindAllProduction", () => {
const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production");
const { bindProductionMedia } = await import("@repo/media/di/bind-production");
await bindAllProduction(makeDeps());
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindProductionAuth).toHaveBeenCalledOnce();
@@ -56,9 +47,8 @@ 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");
const deps = makeDeps();
await bindAllProduction(deps);
await bindAllProduction(deps);
await bindAllProduction();
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
});
@@ -68,27 +58,12 @@ describe("bindAllProduction", () => {
const { PayloadJobsEventBus } = await import("@repo/core-events");
const { PayloadJobQueue } = await import("@repo/core-shared/jobs");
await bindAllProduction(makeDeps());
await bindAllProduction();
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
expect(ctx.bus).toBeInstanceOf(PayloadJobsEventBus);
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
});
it("forwards realtime and realtimeRegistry as ctx.realtime + ctx.realtimeRegistry 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 ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
expect(ctx.realtime).toBe(realtime);
expect(ctx.realtimeRegistry).toBe(realtimeRegistry);
});
});
describe("bindAllDevSeed", () => {
@@ -103,27 +78,12 @@ describe("bindAllDevSeed", () => {
const { InMemoryEventBus } = await import("@repo/core-events");
const { InMemoryJobQueue } = await import("@repo/core-shared/jobs");
await bindAllDevSeed(makeDeps());
await bindAllDevSeed();
const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
expect(ctx.bus).toBeInstanceOf(InMemoryEventBus);
expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue);
});
it("forwards realtime and realtimeRegistry as ctx.realtime + ctx.realtimeRegistry 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 ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
expect(ctx.realtime).toBe(realtime);
expect(ctx.realtimeRegistry).toBe(realtimeRegistry);
});
});
describe("bindAll dispatcher", () => {
@@ -144,7 +104,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(makeDeps());
await bindAll();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
expect(bindProductionBlog).not.toHaveBeenCalled();
@@ -156,7 +116,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(makeDeps());
await bindAll();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).not.toHaveBeenCalled();
@@ -168,7 +128,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(makeDeps());
await bindAll();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
expect(bindProductionBlog).not.toHaveBeenCalled();
@@ -181,55 +141,13 @@ describe("bindAll dispatcher", () => {
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
await bindAll(makeDeps());
await bindAll();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).not.toHaveBeenCalled();
});
});
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)", () => {
beforeEach(() => {
vi.resetModules();
@@ -249,7 +167,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
"@repo/core-shared/instrumentation"
);
await bindAll(makeDeps());
await bindAll();
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
expect(bindSentryInstrumentation).not.toHaveBeenCalled();
@@ -263,7 +181,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
"@repo/core-shared/instrumentation"
);
await bindAll(makeDeps());
await bindAll();
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
expect(bindNoopInstrumentation).not.toHaveBeenCalled();
@@ -276,7 +194,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(makeDeps());
await bindAll();
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
@@ -289,7 +207,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(makeDeps());
await bindAll();
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
expect(bindProductionBlog).toHaveBeenCalledOnce();

View File

@@ -7,8 +7,6 @@ import config from "@repo/core-cms";
import {
bindNoopInstrumentation,
bindSentryInstrumentation,
withCapture,
withSpan,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
@@ -23,13 +21,6 @@ import {
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";
@@ -41,11 +32,6 @@ 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
@@ -112,22 +98,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 1922).
*/
export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
export async function bindAllProduction(): 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;
const ctx: BindProductionContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
const ctx: BindProductionContext<IEventBus> = {
config: resolvedConfig,
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
};
bindProductionAuth(ctx); // Phase E task 19
@@ -135,8 +118,6 @@ export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
bindProductionMarketingPages(ctx); // Phase E task 20
bindProductionNavigation(ctx); // Phase E task 21
bindProductionMedia(ctx); // Phase E task 22
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
bindRealtimeBridge(bus, realtime);
}
/**
@@ -144,20 +125,17 @@ export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
* 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> {
export async function bindAllDevSeed(): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { bus, queue } = resolveEventsAndJobsDevSeed();
const { realtime, realtimeRegistry } = deps;
const ctx: BindContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
const ctx: BindContext<IEventBus> = {
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
};
await bindDevSeedAuth(ctx); // Phase E task 19
@@ -165,8 +143,6 @@ export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
await bindDevSeedMarketingPages(ctx); // Phase E task 20
await bindDevSeedNavigation(ctx); // Phase E task 21
await bindDevSeedMedia(ctx); // Phase E task 22
maybeRegisterRealtimePing(realtimeRegistry, realtime, tracer, logger);
bindRealtimeBridge(bus, realtime);
}
/**
@@ -181,56 +157,20 @@ export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
* 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.
* When @repo/core-realtime is scaffolded, extend this function to accept
* realtime deps (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass
* them through to bindAllProduction / bindAllDevSeed.
*/
export async function bindAll(deps?: Partial<BindAllDeps>): Promise<void> {
const resolvedDeps: BindAllDeps = {
realtime: deps?.realtime ?? new InMemoryRealtimeBroadcaster(),
realtimeRegistry: deps?.realtimeRegistry ?? new RealtimeHandlerRegistry(),
};
export async function bindAll(): Promise<void> {
if (process.env.USE_DEV_SEED === "true") {
await bindAllDevSeed(resolvedDeps);
await bindAllDevSeed();
return;
}
if (process.env.NODE_ENV === "production") {
await bindAllProduction(resolvedDeps);
await bindAllProduction();
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 }),
// );
await bindAllDevSeed();
}
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */

File diff suppressed because one or more lines are too long