refactor: remove core-realtime from main (scaffoldable via gen core-package realtime)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -8,7 +8,6 @@ const nextConfig = {
|
|||||||
"@repo/core-api",
|
"@repo/core-api",
|
||||||
"@repo/core-cms",
|
"@repo/core-cms",
|
||||||
"@repo/core-events",
|
"@repo/core-events",
|
||||||
"@repo/core-realtime",
|
|
||||||
"@repo/core-shared",
|
"@repo/core-shared",
|
||||||
"@repo/core-trpc",
|
"@repo/core-trpc",
|
||||||
"@repo/core-ui",
|
"@repo/core-ui",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"@repo/core-api": "workspace:*",
|
"@repo/core-api": "workspace:*",
|
||||||
"@repo/core-cms": "workspace:*",
|
"@repo/core-cms": "workspace:*",
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-realtime": "workspace:*",
|
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/core-trpc": "workspace:*",
|
"@repo/core-trpc": "workspace:*",
|
||||||
"@repo/core-ui": "workspace:*",
|
"@repo/core-ui": "workspace:*",
|
||||||
@@ -35,12 +34,10 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"socket.io": "^4.7.0",
|
|
||||||
"superjson": "^2.2.1"
|
"superjson": "^2.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.50.0",
|
"@playwright/test": "^1.50.0",
|
||||||
"socket.io-client": "^4.7.0",
|
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
// apps/web-next/server.ts
|
// 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 "reflect-metadata";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import next from "next";
|
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";
|
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 dev = process.env.NODE_ENV !== "production";
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
||||||
@@ -30,40 +15,9 @@ const handle = app.getRequestHandler();
|
|||||||
|
|
||||||
await app.prepare();
|
await app.prepare();
|
||||||
|
|
||||||
|
await bindAll();
|
||||||
|
|
||||||
const httpServer = createServer((req, res) => handle(req, res));
|
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, () => {
|
httpServer.listen(port, () => {
|
||||||
console.log(`> Ready on http://localhost:${port}`);
|
console.log(`> Ready on http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,8 +7,6 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { bindAllDevSeed, __resetBindStateForTests } from "@/server/bind-production";
|
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 { authContainer } from "@repo/auth/di/container";
|
||||||
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
|
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
|
||||||
import type { ISignUpController } from "@repo/auth";
|
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 () => {
|
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<RecordingMailerService>(
|
const mailer = marketingPagesContainer.get<RecordingMailerService>(
|
||||||
MARKETING_PAGES_SYMBOLS.IMailerService,
|
MARKETING_PAGES_SYMBOLS.IMailerService,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||||
vi.mock("payload", () => ({ getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })) }));
|
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", () => {
|
describe("bindAllProduction", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -44,7 +35,7 @@ describe("bindAllProduction", () => {
|
|||||||
const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production");
|
const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production");
|
||||||
const { bindProductionMedia } = await import("@repo/media/di/bind-production");
|
const { bindProductionMedia } = await import("@repo/media/di/bind-production");
|
||||||
|
|
||||||
await bindAllProduction(makeDeps());
|
await bindAllProduction();
|
||||||
|
|
||||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||||
@@ -56,9 +47,8 @@ describe("bindAllProduction", () => {
|
|||||||
it("is idempotent — second call does not re-bind", async () => {
|
it("is idempotent — second call does not re-bind", async () => {
|
||||||
const { bindAllProduction } = await import("./bind-production");
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
||||||
const deps = makeDeps();
|
await bindAllProduction();
|
||||||
await bindAllProduction(deps);
|
await bindAllProduction();
|
||||||
await bindAllProduction(deps);
|
|
||||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,27 +58,12 @@ describe("bindAllProduction", () => {
|
|||||||
const { PayloadJobsEventBus } = await import("@repo/core-events");
|
const { PayloadJobsEventBus } = await import("@repo/core-events");
|
||||||
const { PayloadJobQueue } = await import("@repo/core-shared/jobs");
|
const { PayloadJobQueue } = await import("@repo/core-shared/jobs");
|
||||||
|
|
||||||
await bindAllProduction(makeDeps());
|
await bindAllProduction();
|
||||||
|
|
||||||
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
|
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
|
||||||
expect(ctx.bus).toBeInstanceOf(PayloadJobsEventBus);
|
expect(ctx.bus).toBeInstanceOf(PayloadJobsEventBus);
|
||||||
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
|
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", () => {
|
describe("bindAllDevSeed", () => {
|
||||||
@@ -103,27 +78,12 @@ describe("bindAllDevSeed", () => {
|
|||||||
const { InMemoryEventBus } = await import("@repo/core-events");
|
const { InMemoryEventBus } = await import("@repo/core-events");
|
||||||
const { InMemoryJobQueue } = await import("@repo/core-shared/jobs");
|
const { InMemoryJobQueue } = await import("@repo/core-shared/jobs");
|
||||||
|
|
||||||
await bindAllDevSeed(makeDeps());
|
await bindAllDevSeed();
|
||||||
|
|
||||||
const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
|
const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
|
||||||
expect(ctx.bus).toBeInstanceOf(InMemoryEventBus);
|
expect(ctx.bus).toBeInstanceOf(InMemoryEventBus);
|
||||||
expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue);
|
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", () => {
|
describe("bindAll dispatcher", () => {
|
||||||
@@ -144,7 +104,7 @@ describe("bindAll dispatcher", () => {
|
|||||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||||
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
||||||
expect(bindProductionBlog).not.toHaveBeenCalled();
|
expect(bindProductionBlog).not.toHaveBeenCalled();
|
||||||
@@ -156,7 +116,7 @@ describe("bindAll dispatcher", () => {
|
|||||||
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
||||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||||
expect(bindDevSeedBlog).not.toHaveBeenCalled();
|
expect(bindDevSeedBlog).not.toHaveBeenCalled();
|
||||||
@@ -168,7 +128,7 @@ describe("bindAll dispatcher", () => {
|
|||||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||||
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
||||||
expect(bindProductionBlog).not.toHaveBeenCalled();
|
expect(bindProductionBlog).not.toHaveBeenCalled();
|
||||||
@@ -181,55 +141,13 @@ describe("bindAll dispatcher", () => {
|
|||||||
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
||||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||||
expect(bindDevSeedBlog).not.toHaveBeenCalled();
|
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)", () => {
|
describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -249,7 +167,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
|||||||
"@repo/core-shared/instrumentation"
|
"@repo/core-shared/instrumentation"
|
||||||
);
|
);
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
||||||
expect(bindSentryInstrumentation).not.toHaveBeenCalled();
|
expect(bindSentryInstrumentation).not.toHaveBeenCalled();
|
||||||
@@ -263,7 +181,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
|||||||
"@repo/core-shared/instrumentation"
|
"@repo/core-shared/instrumentation"
|
||||||
);
|
);
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
|
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
|
||||||
expect(bindNoopInstrumentation).not.toHaveBeenCalled();
|
expect(bindNoopInstrumentation).not.toHaveBeenCalled();
|
||||||
@@ -276,7 +194,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
|||||||
const { bindSentryInstrumentation } = await import("@repo/core-shared/instrumentation");
|
const { bindSentryInstrumentation } = await import("@repo/core-shared/instrumentation");
|
||||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
|
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
|
||||||
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
||||||
@@ -289,7 +207,7 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
|
|||||||
const { bindNoopInstrumentation } = await import("@repo/core-shared/instrumentation");
|
const { bindNoopInstrumentation } = await import("@repo/core-shared/instrumentation");
|
||||||
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
|
||||||
|
|
||||||
await bindAll(makeDeps());
|
await bindAll();
|
||||||
|
|
||||||
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
||||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import config from "@repo/core-cms";
|
|||||||
import {
|
import {
|
||||||
bindNoopInstrumentation,
|
bindNoopInstrumentation,
|
||||||
bindSentryInstrumentation,
|
bindSentryInstrumentation,
|
||||||
withCapture,
|
|
||||||
withSpan,
|
|
||||||
type ITracer,
|
type ITracer,
|
||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
@@ -23,13 +21,6 @@ import {
|
|||||||
PayloadJobQueue,
|
PayloadJobQueue,
|
||||||
type IJobQueue,
|
type IJobQueue,
|
||||||
} from "@repo/core-shared/jobs";
|
} 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 { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||||
import { bindProductionMarketingPages } from "@repo/marketing-pages/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 { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
||||||
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
||||||
|
|
||||||
type BindAllDeps = {
|
|
||||||
realtime: IRealtimeBroadcaster;
|
|
||||||
realtimeRegistry: IRealtimeHandlerRegistry;
|
|
||||||
};
|
|
||||||
|
|
||||||
let bound = false;
|
let bound = false;
|
||||||
|
|
||||||
// Shared container holds TRACER + LOGGER bindings; per-feature containers
|
// 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
|
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
||||||
* feature as Phase E feature wiring lands (blog: task 18; remaining: tasks 19–22).
|
* feature as Phase E feature wiring lands (blog: task 18; remaining: tasks 19–22).
|
||||||
*/
|
*/
|
||||||
export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
|
export async function bindAllProduction(): Promise<void> {
|
||||||
if (bound) return;
|
if (bound) return;
|
||||||
bound = true;
|
bound = true;
|
||||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||||
const { bus, queue } = await resolveEventsAndJobsProduction();
|
const { bus, queue } = await resolveEventsAndJobsProduction();
|
||||||
const resolvedConfig = await config;
|
const resolvedConfig = await config;
|
||||||
const { realtime, realtimeRegistry } = deps;
|
|
||||||
|
|
||||||
const ctx: BindProductionContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
|
const ctx: BindProductionContext<IEventBus> = {
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
bus,
|
bus,
|
||||||
queue,
|
queue,
|
||||||
realtime,
|
|
||||||
realtimeRegistry,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bindProductionAuth(ctx); // Phase E task 19
|
bindProductionAuth(ctx); // Phase E task 19
|
||||||
@@ -135,8 +118,6 @@ export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
|
|||||||
bindProductionMarketingPages(ctx); // Phase E task 20
|
bindProductionMarketingPages(ctx); // Phase E task 20
|
||||||
bindProductionNavigation(ctx); // Phase E task 21
|
bindProductionNavigation(ctx); // Phase E task 21
|
||||||
bindProductionMedia(ctx); // Phase E task 22
|
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
|
* with realistic seed data so the running app shows non-empty UI without
|
||||||
* Payload booted. Mutually exclusive with `bindAllProduction()`.
|
* Payload booted. Mutually exclusive with `bindAllProduction()`.
|
||||||
*/
|
*/
|
||||||
export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
|
export async function bindAllDevSeed(): Promise<void> {
|
||||||
if (bound) return;
|
if (bound) return;
|
||||||
bound = true;
|
bound = true;
|
||||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||||
const { bus, queue } = resolveEventsAndJobsDevSeed();
|
const { bus, queue } = resolveEventsAndJobsDevSeed();
|
||||||
const { realtime, realtimeRegistry } = deps;
|
|
||||||
|
|
||||||
const ctx: BindContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
|
const ctx: BindContext<IEventBus> = {
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
bus,
|
bus,
|
||||||
queue,
|
queue,
|
||||||
realtime,
|
|
||||||
realtimeRegistry,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await bindDevSeedAuth(ctx); // Phase E task 19
|
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 bindDevSeedMarketingPages(ctx); // Phase E task 20
|
||||||
await bindDevSeedNavigation(ctx); // Phase E task 21
|
await bindDevSeedNavigation(ctx); // Phase E task 21
|
||||||
await bindDevSeedMedia(ctx); // Phase E task 22
|
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 2: NODE_ENV === "production" → real Payload via bindAllProduction
|
||||||
* Rule 3: otherwise → dev seed (developer-friendly default)
|
* Rule 3: otherwise → dev seed (developer-friendly default)
|
||||||
*
|
*
|
||||||
* `deps` is optional; omitting it (page-level fallback, already-bound guard)
|
* When @repo/core-realtime is scaffolded, extend this function to accept
|
||||||
* uses in-memory noop implementations. In the custom-server flow, `server.ts`
|
* realtime deps (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass
|
||||||
* always provides Socket.IO-backed deps before any page request runs.
|
* them through to bindAllProduction / bindAllDevSeed.
|
||||||
*/
|
*/
|
||||||
export async function bindAll(deps?: Partial<BindAllDeps>): Promise<void> {
|
export async function bindAll(): Promise<void> {
|
||||||
const resolvedDeps: BindAllDeps = {
|
|
||||||
realtime: deps?.realtime ?? new InMemoryRealtimeBroadcaster(),
|
|
||||||
realtimeRegistry: deps?.realtimeRegistry ?? new RealtimeHandlerRegistry(),
|
|
||||||
};
|
|
||||||
if (process.env.USE_DEV_SEED === "true") {
|
if (process.env.USE_DEV_SEED === "true") {
|
||||||
await bindAllDevSeed(resolvedDeps);
|
await bindAllDevSeed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
await bindAllProduction(resolvedDeps);
|
await bindAllProduction();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await bindAllDevSeed(resolvedDeps);
|
await bindAllDevSeed();
|
||||||
}
|
|
||||||
|
|
||||||
// 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. */
|
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,7 +21,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-realtime": "workspace:*",
|
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||||
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
|
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||||
import { RealtimeHandlerRegistry } from "@repo/core-realtime";
|
|
||||||
import { bindDevSeedAuth } from "@/di/bind-dev-seed";
|
import { bindDevSeedAuth } from "@/di/bind-dev-seed";
|
||||||
import { authContainer } from "@/di/container";
|
import { authContainer } from "@/di/container";
|
||||||
import { AUTH_SYMBOLS } from "@/di/symbols";
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
||||||
@@ -33,7 +32,7 @@ describe("bindDevSeedAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("populates the repository with the dev users", async () => {
|
it("populates the repository with the dev users", async () => {
|
||||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = authContainer.get<IUsersRepository>(
|
const repo = authContainer.get<IUsersRepository>(
|
||||||
AUTH_SYMBOLS.IUsersRepository,
|
AUTH_SYMBOLS.IUsersRepository,
|
||||||
@@ -46,7 +45,7 @@ describe("bindDevSeedAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds alice reachable by username", async () => {
|
it("seeds alice reachable by username", async () => {
|
||||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = authContainer.get<IUsersRepository>(
|
const repo = authContainer.get<IUsersRepository>(
|
||||||
AUTH_SYMBOLS.IUsersRepository,
|
AUTH_SYMBOLS.IUsersRepository,
|
||||||
@@ -59,13 +58,13 @@ describe("bindDevSeedAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const before = authContainer.get<IUsersRepository>(
|
const before = authContainer.get<IUsersRepository>(
|
||||||
AUTH_SYMBOLS.IUsersRepository,
|
AUTH_SYMBOLS.IUsersRepository,
|
||||||
);
|
);
|
||||||
const beforeAlice = await before.getUserByUsername("alice");
|
const beforeAlice = await before.getUserByUsername("alice");
|
||||||
|
|
||||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const after = authContainer.get<IUsersRepository>(
|
const after = authContainer.get<IUsersRepository>(
|
||||||
AUTH_SYMBOLS.IUsersRepository,
|
AUTH_SYMBOLS.IUsersRepository,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-realtime": "workspace:*",
|
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||||
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
|
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||||
import { RealtimeHandlerRegistry } from "@repo/core-realtime";
|
|
||||||
import { bindDevSeedBlog } from "@/di/bind-dev-seed";
|
import { bindDevSeedBlog } from "@/di/bind-dev-seed";
|
||||||
import { blogContainer } from "@/di/container";
|
import { blogContainer } from "@/di/container";
|
||||||
import { BLOG_SYMBOLS } from "@/di/symbols";
|
import { BLOG_SYMBOLS } from "@/di/symbols";
|
||||||
@@ -34,7 +33,7 @@ describe("bindDevSeedBlog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("populates the repository with the dev articles", async () => {
|
it("populates the repository with the dev articles", async () => {
|
||||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = blogContainer.get<IArticlesRepository>(
|
const repo = blogContainer.get<IArticlesRepository>(
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
BLOG_SYMBOLS.IArticlesRepository,
|
||||||
@@ -45,7 +44,7 @@ describe("bindDevSeedBlog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds the welcome article reachable by slug", async () => {
|
it("seeds the welcome article reachable by slug", async () => {
|
||||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = blogContainer.get<IArticlesRepository>(
|
const repo = blogContainer.get<IArticlesRepository>(
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
BLOG_SYMBOLS.IArticlesRepository,
|
||||||
@@ -58,13 +57,13 @@ describe("bindDevSeedBlog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const before = blogContainer.get<IArticlesRepository>(
|
const before = blogContainer.get<IArticlesRepository>(
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
BLOG_SYMBOLS.IArticlesRepository,
|
||||||
);
|
);
|
||||||
const beforeCount = (await before.getArticles()).length;
|
const beforeCount = (await before.getArticles()).length;
|
||||||
|
|
||||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const after = blogContainer.get<IArticlesRepository>(
|
const after = blogContainer.get<IArticlesRepository>(
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
BLOG_SYMBOLS.IArticlesRepository,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import turboPlugin from "eslint-plugin-turbo";
|
|||||||
import boundaries from "eslint-plugin-boundaries";
|
import boundaries from "eslint-plugin-boundaries";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
// <gen:realtime-rules-imports>
|
// <gen:realtime-rules-imports>
|
||||||
import noDirectSocketIO from "./rules/no-direct-socket-io.js";
|
|
||||||
import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{ ignores: ["dist/**", "node_modules/**", ".next/**", ".turbo/**", "storybook-static/**"] },
|
{ ignores: ["dist/**", "node_modules/**", ".next/**", ".turbo/**", "storybook-static/**"] },
|
||||||
@@ -48,10 +46,6 @@ export default [
|
|||||||
{ type: "tooling", pattern: "packages/core-testing" },
|
{ type: "tooling", pattern: "packages/core-testing" },
|
||||||
{ type: "core-composition", pattern: "packages/core-api" },
|
{ type: "core-composition", pattern: "packages/core-api" },
|
||||||
{ type: "core-composition", pattern: "packages/core-cms" },
|
{ type: "core-composition", pattern: "packages/core-cms" },
|
||||||
// Explicit entry placed before the catch-all so its `mode: "folder"`
|
|
||||||
// is preferred — needed for boundaries-plugin to resolve element root
|
|
||||||
// by directory rather than by package.json name.
|
|
||||||
{ type: "core", pattern: "packages/core-realtime", mode: "folder" },
|
|
||||||
{ type: "core", pattern: "packages/core-*" },
|
{ type: "core", pattern: "packages/core-*" },
|
||||||
{ type: "feature", pattern: "packages/!(core-*)" },
|
{ type: "feature", pattern: "packages/!(core-*)" },
|
||||||
],
|
],
|
||||||
@@ -172,23 +166,8 @@ export default [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// R2 — `socket.io` and `socket.io-client` must not be imported outside
|
// R2 / R1 (ADR-016) — realtime-specific ESLint rules (no-direct-socket-io,
|
||||||
// core-realtime/src/ and apps/*/server.ts. Use @repo/core-realtime helpers.
|
// no-realtime-handler-reexport) are added here when @repo/core-realtime is
|
||||||
// R1 (ADR-016) — Realtime handlers must not be re-exported outside bind-* files.
|
// scaffolded via `pnpm turbo gen core-package realtime`.
|
||||||
// <gen:realtime-rules>
|
// <gen:realtime-rules>
|
||||||
{
|
|
||||||
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
|
||||||
plugins: {
|
|
||||||
"repo-rules": {
|
|
||||||
rules: {
|
|
||||||
"no-direct-socket-io": noDirectSocketIO,
|
|
||||||
"no-realtime-handler-reexport": noRealtimeHandlerReexport,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"repo-rules/no-direct-socket-io": "error",
|
|
||||||
"repo-rules/no-realtime-handler-reexport": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
// packages/core-eslint/rules/no-direct-socket-io.js
|
|
||||||
const ALLOWED = [
|
|
||||||
/\/packages\/core-realtime\/src\//,
|
|
||||||
/\/apps\/[^/]+\/server\.ts$/,
|
|
||||||
/\/apps\/[^/]+\/src\/.*\.test\.ts$/,
|
|
||||||
];
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "problem",
|
|
||||||
docs: { description: "Block direct socket.io imports outside core-realtime + app servers" },
|
|
||||||
messages: {
|
|
||||||
noDirectSocketIO: 'Import from "@repo/core-realtime" instead of "socket.io". Direct imports allowed only in packages/core-realtime/src/ and apps/*/server.ts.',
|
|
||||||
noDirectSocketIOClient: 'Use the realtime helpers from "@repo/core-realtime" / "@repo/core-testing/instrumentation" instead of "socket.io-client".',
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const filename = context.filename ?? context.getFilename();
|
|
||||||
const allowed = ALLOWED.some((re) => re.test(filename));
|
|
||||||
if (allowed) return {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ImportDeclaration(node) {
|
|
||||||
const source = node.source.value;
|
|
||||||
if (source === "socket.io") {
|
|
||||||
context.report({ node, messageId: "noDirectSocketIO" });
|
|
||||||
} else if (source === "socket.io-client") {
|
|
||||||
context.report({ node, messageId: "noDirectSocketIOClient" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// packages/core-eslint/rules/no-direct-socket-io.test.js
|
|
||||||
import { RuleTester } from "eslint";
|
|
||||||
import rule from "./no-direct-socket-io.js";
|
|
||||||
|
|
||||||
const tester = new RuleTester({
|
|
||||||
languageOptions: { ecmaVersion: 2022, sourceType: "module" },
|
|
||||||
});
|
|
||||||
|
|
||||||
tester.run("no-direct-socket-io", rule, {
|
|
||||||
valid: [
|
|
||||||
// Allowed inside core-realtime
|
|
||||||
{ code: 'import { Server } from "socket.io";', filename: "/repo/packages/core-realtime/src/socket-io-realtime-server.ts" },
|
|
||||||
// Allowed in app servers
|
|
||||||
{ code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/server.ts" },
|
|
||||||
// Allowed in app integration tests (e.g. realtime-ping e2e)
|
|
||||||
{ code: 'import { Server } from "socket.io";', filename: "/repo/apps/web-next/src/__tests__/realtime-ping.test.ts" },
|
|
||||||
{ code: 'import { io } from "socket.io-client";', filename: "/repo/apps/web-next/src/__tests__/realtime-ping.test.ts" },
|
|
||||||
// Allowed elsewhere when not importing socket.io
|
|
||||||
{ code: 'import { foo } from "bar";', filename: "/repo/packages/blog/src/foo.ts" },
|
|
||||||
],
|
|
||||||
invalid: [
|
|
||||||
{
|
|
||||||
code: 'import { Server } from "socket.io";',
|
|
||||||
filename: "/repo/packages/blog/src/foo.ts",
|
|
||||||
errors: [{ messageId: "noDirectSocketIO" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'import { io } from "socket.io-client";',
|
|
||||||
filename: "/repo/packages/blog/src/ui/Component.tsx",
|
|
||||||
errors: [{ messageId: "noDirectSocketIOClient" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// packages/core-eslint/rules/no-realtime-handler-reexport.js
|
|
||||||
// R1 — Realtime handlers are private. A feature's realtime/handlers/*.handler.ts
|
|
||||||
// must only be wired in the feature's own bind-production / bind-dev-seed files.
|
|
||||||
// They must never be re-exported from barrel files or other public surfaces.
|
|
||||||
|
|
||||||
const BIND_FILE = /\bdi\/bind-(?:production|dev-seed)\b/;
|
|
||||||
const REALTIME_HANDLERS_IN_SOURCE = /\/realtime\/handlers\//;
|
|
||||||
const HANDLERS_IN_SOURCE = /\/handlers\//;
|
|
||||||
const REALTIME_IN_FILENAME = /\/realtime\//;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
meta: {
|
|
||||||
type: "problem",
|
|
||||||
docs: {
|
|
||||||
description:
|
|
||||||
"Block re-exports of realtime/handlers/** outside feature bind-* files (ADR-016 R1)",
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
noRealtimeHandlerReexport:
|
|
||||||
"Realtime handlers (realtime/handlers/*.handler.ts) must not be re-exported (ADR-016 R1). " +
|
|
||||||
"Wire them only inside the feature's own bind-production / bind-dev-seed files.",
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const filename = context.filename ?? context.getFilename();
|
|
||||||
|
|
||||||
// Bind-* files are the only allowed place for these exports/imports
|
|
||||||
if (BIND_FILE.test(filename)) return {};
|
|
||||||
|
|
||||||
function checkExportSource(node) {
|
|
||||||
if (!node.source) return;
|
|
||||||
const source = node.source.value;
|
|
||||||
const isRealtimeHandler =
|
|
||||||
REALTIME_HANDLERS_IN_SOURCE.test(source) ||
|
|
||||||
(HANDLERS_IN_SOURCE.test(source) && REALTIME_IN_FILENAME.test(filename));
|
|
||||||
if (isRealtimeHandler) {
|
|
||||||
context.report({ node, messageId: "noRealtimeHandlerReexport" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ExportNamedDeclaration: checkExportSource,
|
|
||||||
ExportAllDeclaration: checkExportSource,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// packages/core-eslint/rules/no-realtime-handler-reexport.test.js
|
|
||||||
import { RuleTester } from "eslint";
|
|
||||||
import rule from "./no-realtime-handler-reexport.js";
|
|
||||||
|
|
||||||
const tester = new RuleTester({
|
|
||||||
languageOptions: { ecmaVersion: 2022, sourceType: "module" },
|
|
||||||
});
|
|
||||||
|
|
||||||
tester.run("no-realtime-handler-reexport", rule, {
|
|
||||||
valid: [
|
|
||||||
// Importing a handler from inside a feature's bind-* file is allowed.
|
|
||||||
{
|
|
||||||
code: 'import { onPingHandler } from "../realtime/handlers/on-ping.handler";',
|
|
||||||
filename: "/repo/packages/blog/src/di/bind-production.ts",
|
|
||||||
},
|
|
||||||
// Re-exporting a channel descriptor is allowed.
|
|
||||||
{
|
|
||||||
code: 'export { presenceChannel } from "./realtime/presence.channel";',
|
|
||||||
filename: "/repo/packages/blog/src/index.ts",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
invalid: [
|
|
||||||
// Re-exporting a handler from any non-bind file is forbidden.
|
|
||||||
{
|
|
||||||
code: 'export { onPingHandler } from "./realtime/handlers/on-ping.handler";',
|
|
||||||
filename: "/repo/packages/blog/src/index.ts",
|
|
||||||
errors: [{ messageId: "noRealtimeHandlerReexport" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'export * from "./handlers/on-ping.handler";',
|
|
||||||
filename: "/repo/packages/blog/src/realtime/index.ts",
|
|
||||||
errors: [{ messageId: "noRealtimeHandlerReexport" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# @repo/core-realtime
|
|
||||||
|
|
||||||
Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.
|
|
||||||
|
|
||||||
See `docs/superpowers/specs/2026-05-08-realtime-design.md` for the full design. ADR-016 (`docs/decisions/adr-016-realtime-layer.md`) lands in Phase 10 — pending.
|
|
||||||
|
|
||||||
## Public exports
|
|
||||||
|
|
||||||
- `IRealtimeBroadcaster` — server → client broadcasts
|
|
||||||
- `IRealtimeServer` — lifecycle, used at app boot only
|
|
||||||
- `IRealtimeAuthenticator` — connect-time identity attachment (cookie / header → user)
|
|
||||||
- `IRealtimeHandlerRegistry` + `RealtimeHandlerRegistry` — inbound handler registration
|
|
||||||
- `defineRealtimeChannel`, `RealtimeChannelDescriptor`, `ChannelScope`
|
|
||||||
- `InMemoryRealtimeBroadcaster` (test/dev), `SocketIORealtimeBroadcaster`, `SocketIORealtimeServer` (production)
|
|
||||||
- `CORE_REALTIME_SYMBOLS`
|
|
||||||
|
|
||||||
## Boundary
|
|
||||||
|
|
||||||
Tagged `core`. The only place in the repo where `import "socket.io"` is allowed is `src/socket-io-*.ts` here, plus `apps/*/server.ts`. Enforced by the ESLint rule `core-eslint/no-direct-socket-io`.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import baseConfig from "@repo/core-eslint/base";
|
|
||||||
|
|
||||||
export default baseConfig;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@repo/core-realtime",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc --noEmit",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@repo/core-shared": "workspace:*",
|
|
||||||
"socket.io": "^4.7.0",
|
|
||||||
"zod": "^3.23.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"payload": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"payload": { "optional": true }
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@repo/core-eslint": "workspace:*",
|
|
||||||
"@repo/core-testing": "workspace:*",
|
|
||||||
"@repo/core-typescript": "workspace:*",
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"socket.io-client": "^4.7.0",
|
|
||||||
"typescript": "^5.8.0",
|
|
||||||
"vitest": "^3.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { authorize } from "@/authorize";
|
|
||||||
import { defineRealtimeChannel } from "@/realtime-channel";
|
|
||||||
|
|
||||||
const schema = z.object({}).strict();
|
|
||||||
|
|
||||||
describe("authorize", () => {
|
|
||||||
describe("public", () => {
|
|
||||||
const ch = defineRealtimeChannel("a", schema, { scope: "public" });
|
|
||||||
it("allows anonymous", async () => {
|
|
||||||
expect(await authorize(ch, {}, null)).toBe(true);
|
|
||||||
});
|
|
||||||
it("allows authenticated", async () => {
|
|
||||||
expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("authenticated", () => {
|
|
||||||
const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" });
|
|
||||||
it("rejects anonymous", async () => {
|
|
||||||
expect(await authorize(ch, {}, null)).toBe(false);
|
|
||||||
});
|
|
||||||
it("allows any user", async () => {
|
|
||||||
expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("{ role }", () => {
|
|
||||||
const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } });
|
|
||||||
it("rejects anonymous", async () => {
|
|
||||||
expect(await authorize(ch, {}, null)).toBe(false);
|
|
||||||
});
|
|
||||||
it("rejects user without role", async () => {
|
|
||||||
expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe(false);
|
|
||||||
});
|
|
||||||
it("allows user with role", async () => {
|
|
||||||
expect(await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] })).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("{ userScoped }", () => {
|
|
||||||
const ch = defineRealtimeChannel("a", schema, {
|
|
||||||
scope: { userScoped: true, template: "notifications.user.{userId}" },
|
|
||||||
});
|
|
||||||
it("rejects anonymous", async () => {
|
|
||||||
expect(await authorize(ch, { userId: "u1" }, null)).toBe(false);
|
|
||||||
});
|
|
||||||
it("rejects user requesting someone else's channel", async () => {
|
|
||||||
expect(
|
|
||||||
await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
it("allows user requesting own channel", async () => {
|
|
||||||
expect(
|
|
||||||
await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
import type { RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
|
|
||||||
export async function authorize(
|
|
||||||
descriptor: RealtimeChannelDescriptor<string, z.ZodType>,
|
|
||||||
params: Record<string, string>,
|
|
||||||
user: { userId: string; roles: string[] } | null,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const scope = descriptor.scope;
|
|
||||||
|
|
||||||
if (scope === "public") return true;
|
|
||||||
if (scope === "authenticated") return user !== null;
|
|
||||||
|
|
||||||
if (typeof scope === "object" && "role" in scope) {
|
|
||||||
return user !== null && user.roles.includes(scope.role);
|
|
||||||
}
|
|
||||||
if (typeof scope === "object" && "userScoped" in scope) {
|
|
||||||
return user !== null && params.userId === user.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const CHANNEL_ROOM_PREFIX = "ch:";
|
|
||||||
export const channelRoom = (channelName: string): string =>
|
|
||||||
`${CHANNEL_ROOM_PREFIX}${channelName}`;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { matchChannelTemplate } from "@/channel-template";
|
|
||||||
|
|
||||||
describe("matchChannelTemplate", () => {
|
|
||||||
it("matches a plain channel name exactly", () => {
|
|
||||||
expect(matchChannelTemplate("blog.feed", "blog.feed")).toEqual({ params: {} });
|
|
||||||
expect(matchChannelTemplate("blog.feed", "blog.other")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches a templated channel and extracts params", () => {
|
|
||||||
expect(
|
|
||||||
matchChannelTemplate("notifications.user.{userId}", "notifications.user.user_42"),
|
|
||||||
).toEqual({ params: { userId: "user_42" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when a templated channel doesn't match the shape", () => {
|
|
||||||
expect(
|
|
||||||
matchChannelTemplate("notifications.user.{userId}", "notifications.user"),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
matchChannelTemplate("notifications.user.{userId}", "blog.feed"),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports multiple placeholders", () => {
|
|
||||||
expect(
|
|
||||||
matchChannelTemplate(
|
|
||||||
"rooms.{roomId}.user.{userId}",
|
|
||||||
"rooms.r1.user.u1",
|
|
||||||
),
|
|
||||||
).toEqual({ params: { roomId: "r1", userId: "u1" } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export function matchChannelTemplate(
|
|
||||||
template: string,
|
|
||||||
candidate: string,
|
|
||||||
): { params: Record<string, string> } | null {
|
|
||||||
// No placeholders: exact match.
|
|
||||||
if (!template.includes("{")) {
|
|
||||||
return template === candidate ? { params: {} } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a regex from the template, replacing {name} with named groups.
|
|
||||||
const names: string[] = [];
|
|
||||||
const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); // escape regex specials
|
|
||||||
// The above escapes `{` and `}` too — restore them around placeholders.
|
|
||||||
const pattern = escaped.replace(/\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/g, (_m, name) => {
|
|
||||||
names.push(name);
|
|
||||||
return `([^.]+)`;
|
|
||||||
});
|
|
||||||
const re = new RegExp(`^${pattern}$`);
|
|
||||||
const match = candidate.match(re);
|
|
||||||
if (!match) return null;
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
names.forEach((name, i) => {
|
|
||||||
params[name] = match[i + 1]!;
|
|
||||||
});
|
|
||||||
return { params };
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster";
|
|
||||||
import { defineRealtimeChannel } from "@/realtime-channel";
|
|
||||||
|
|
||||||
const ch = defineRealtimeChannel(
|
|
||||||
"a.b",
|
|
||||||
z.object({ x: z.number() }).strict(),
|
|
||||||
{ scope: "public" },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("InMemoryRealtimeBroadcaster", () => {
|
|
||||||
it("validates payload via the descriptor schema", async () => {
|
|
||||||
const b = new InMemoryRealtimeBroadcaster();
|
|
||||||
await expect(
|
|
||||||
b.broadcast(ch, { x: "not a number" } as never),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delivers to subscribers in order", async () => {
|
|
||||||
const b = new InMemoryRealtimeBroadcaster();
|
|
||||||
const got: number[] = [];
|
|
||||||
b.subscribe(ch, async (p) => { got.push(p.x); });
|
|
||||||
await b.broadcast(ch, { x: 1 });
|
|
||||||
await b.broadcast(ch, { x: 2 });
|
|
||||||
expect(got).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when no subscribers", async () => {
|
|
||||||
const b = new InMemoryRealtimeBroadcaster();
|
|
||||||
await b.broadcast(ch, { x: 1 });
|
|
||||||
// does not throw
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
|
|
||||||
import type { RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
|
|
||||||
type Listener<T> = (payload: T) => Promise<void> | void;
|
|
||||||
|
|
||||||
export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster {
|
|
||||||
private readonly listeners = new Map<string, Listener<unknown>[]>();
|
|
||||||
|
|
||||||
async broadcast<T>(
|
|
||||||
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
|
|
||||||
payload: T,
|
|
||||||
): Promise<void> {
|
|
||||||
descriptor.schema.parse(payload);
|
|
||||||
const arr = this.listeners.get(descriptor.name) ?? [];
|
|
||||||
for (const l of arr) await l(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-friendly: lets unit tests subscribe directly without a Socket.IO server.
|
|
||||||
subscribe<T>(
|
|
||||||
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
|
|
||||||
listener: Listener<T>,
|
|
||||||
): void {
|
|
||||||
const arr = this.listeners.get(descriptor.name) ?? [];
|
|
||||||
arr.push(listener as Listener<unknown>);
|
|
||||||
this.listeners.set(descriptor.name, arr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
export { defineRealtimeChannel } from "./realtime-channel";
|
|
||||||
export { CHANNEL_ROOM_PREFIX, channelRoom } from "./channel-room";
|
|
||||||
export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
|
|
||||||
export type { IRealtimeHandler, IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";
|
|
||||||
export type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
|
|
||||||
export type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
|
|
||||||
export type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";
|
|
||||||
export { RealtimeHandlerRegistry } from "./realtime-handler-registry";
|
|
||||||
export { CORE_REALTIME_SYMBOLS } from "./symbols";
|
|
||||||
export { InMemoryRealtimeBroadcaster } from "./in-memory-realtime-broadcaster";
|
|
||||||
export { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster";
|
|
||||||
export { SocketIORealtimeServer } from "./socket-io-realtime-server";
|
|
||||||
export { authorize } from "./authorize";
|
|
||||||
export { matchChannelTemplate } from "./channel-template";
|
|
||||||
export {
|
|
||||||
realtimePingChannel,
|
|
||||||
realtimePongChannel,
|
|
||||||
realtimePingInboundDescriptor,
|
|
||||||
type PingPayload,
|
|
||||||
type PongPayload,
|
|
||||||
} from "./realtime-ping";
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface IRealtimeAuthenticator {
|
|
||||||
authenticate(handshake: {
|
|
||||||
cookies: Record<string, string>;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
}): Promise<{ userId: string; roles: string[] } | null>;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
import type { RealtimeBroadcasterProtocol } from "@repo/core-shared/di/bind-protocols";
|
|
||||||
import type { RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
|
|
||||||
export interface IRealtimeBroadcaster extends RealtimeBroadcasterProtocol {
|
|
||||||
broadcast<T>(
|
|
||||||
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
|
|
||||||
payload: T,
|
|
||||||
): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { defineRealtimeChannel } from "@/realtime-channel";
|
|
||||||
|
|
||||||
describe("defineRealtimeChannel", () => {
|
|
||||||
it("returns a descriptor with name, schema, and scope", () => {
|
|
||||||
const ch = defineRealtimeChannel(
|
|
||||||
"test.channel",
|
|
||||||
z.object({ id: z.string() }).strict(),
|
|
||||||
{ scope: "public" },
|
|
||||||
);
|
|
||||||
expect(ch.name).toBe("test.channel");
|
|
||||||
expect(ch.scope).toBe("public");
|
|
||||||
expect(() => ch.schema.parse({ id: "x" })).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves all four scope shapes", () => {
|
|
||||||
expect(defineRealtimeChannel("a", z.object({}), { scope: "public" }).scope).toBe("public");
|
|
||||||
expect(defineRealtimeChannel("a", z.object({}), { scope: "authenticated" }).scope).toBe("authenticated");
|
|
||||||
expect(defineRealtimeChannel("a", z.object({}), { scope: { role: "admin" } }).scope).toEqual({ role: "admin" });
|
|
||||||
expect(
|
|
||||||
defineRealtimeChannel("a", z.object({}), { scope: { userScoped: true, template: "x.{id}" } }).scope,
|
|
||||||
).toEqual({ userScoped: true, template: "x.{id}" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `userScoped` channels include a `{userId}` placeholder in the channel name.
|
|
||||||
* The `userId` param extracted from the channel pattern is matched against
|
|
||||||
* `user.userId` at subscribe time. The `template` field is metadata: it is
|
|
||||||
* the same string passed to `defineRealtimeChannel`'s `name` argument and is
|
|
||||||
* used by clients/admin tools to display the channel pattern.
|
|
||||||
*/
|
|
||||||
export type ChannelScope =
|
|
||||||
| "public"
|
|
||||||
| "authenticated"
|
|
||||||
| { role: string }
|
|
||||||
| { userScoped: true; template: string };
|
|
||||||
|
|
||||||
export type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
|
|
||||||
readonly name: TName;
|
|
||||||
readonly schema: TSchema;
|
|
||||||
readonly scope: ChannelScope;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function defineRealtimeChannel<TName extends string, TSchema extends z.ZodType>(
|
|
||||||
name: TName,
|
|
||||||
schema: TSchema,
|
|
||||||
options: { scope: ChannelScope },
|
|
||||||
): RealtimeChannelDescriptor<TName, TSchema> {
|
|
||||||
return { name, schema, scope: options.scope };
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
|
|
||||||
import { defineRealtimeChannel } from "@/realtime-channel";
|
|
||||||
|
|
||||||
const ch = defineRealtimeChannel(
|
|
||||||
"test.ch",
|
|
||||||
z.object({ x: z.number() }).strict(),
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("RealtimeHandlerRegistry", () => {
|
|
||||||
it("registers and retrieves a handler by channel name", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
const handler = vi.fn();
|
|
||||||
reg.register({ descriptor: ch, handler });
|
|
||||||
const got = reg.getInboundDescriptor("test.ch");
|
|
||||||
expect(got).not.toBeNull();
|
|
||||||
expect(got!.descriptor.name).toBe("test.ch");
|
|
||||||
expect(got!.handler).toBe(handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for unknown channel name", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
expect(reg.getInboundDescriptor("unknown")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("list() returns all registered descriptors", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
reg.register({ descriptor: ch, handler: vi.fn() });
|
|
||||||
expect(reg.list()).toHaveLength(1);
|
|
||||||
expect(reg.list()[0]!.descriptor.name).toBe("test.ch");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-registering the same channel replaces the previous entry", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
const h1 = vi.fn();
|
|
||||||
const h2 = vi.fn();
|
|
||||||
reg.register({ descriptor: ch, handler: h1 });
|
|
||||||
reg.register({ descriptor: ch, handler: h2 });
|
|
||||||
expect(reg.getInboundDescriptor("test.ch")!.handler).toBe(h2);
|
|
||||||
expect(reg.list()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registerChannel stores a descriptor that appears in listChannels() but not in list()", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
const outboundCh = defineRealtimeChannel(
|
|
||||||
"test.outbound",
|
|
||||||
z.object({ y: z.string() }).strict(),
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
reg.registerChannel(outboundCh);
|
|
||||||
expect(reg.listChannels()).toHaveLength(1);
|
|
||||||
expect(reg.listChannels()[0]!.name).toBe("test.outbound");
|
|
||||||
expect(reg.list()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("register auto-populates listChannels()", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
reg.register({ descriptor: ch, handler: vi.fn() });
|
|
||||||
expect(reg.listChannels()).toHaveLength(1);
|
|
||||||
expect(reg.listChannels()[0]!.name).toBe("test.ch");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listChannels() returns both inbound and outbound-only channels when both are registered", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
const outboundCh = defineRealtimeChannel(
|
|
||||||
"test.outbound",
|
|
||||||
z.object({ y: z.string() }).strict(),
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
reg.register({ descriptor: ch, handler: vi.fn() });
|
|
||||||
reg.registerChannel(outboundCh);
|
|
||||||
expect(reg.listChannels()).toHaveLength(2);
|
|
||||||
const names = reg.listChannels().map((c) => c.name).sort();
|
|
||||||
expect(names).toEqual(["test.ch", "test.outbound"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-registering an outbound-only channel via registerChannel replaces the previous entry", () => {
|
|
||||||
const reg = new RealtimeHandlerRegistry();
|
|
||||||
const outboundCh = defineRealtimeChannel(
|
|
||||||
"test.outbound",
|
|
||||||
z.object({ y: z.string() }).strict(),
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
reg.registerChannel(outboundCh);
|
|
||||||
reg.registerChannel(outboundCh);
|
|
||||||
expect(reg.listChannels()).toHaveLength(1);
|
|
||||||
expect(reg.listChannels()[0]!.name).toBe("test.outbound");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
import type { RealtimeRegistryProtocol } from "@repo/core-shared/di/bind-protocols";
|
|
||||||
import type { RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
import type { IInboundDescriptor } from "./realtime-handler.interface";
|
|
||||||
|
|
||||||
export interface IRealtimeHandlerRegistry extends RealtimeRegistryProtocol {
|
|
||||||
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
|
|
||||||
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
|
|
||||||
list(): IInboundDescriptor<string, z.ZodType>[];
|
|
||||||
/** Register an outbound-only channel so Gate 2 can authorize subscriptions to it. */
|
|
||||||
registerChannel(descriptor: RealtimeChannelDescriptor<string, z.ZodType>): void;
|
|
||||||
listChannels(): RealtimeChannelDescriptor<string, z.ZodType>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
|
|
||||||
private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();
|
|
||||||
private readonly channels = new Map<string, RealtimeChannelDescriptor<string, z.ZodType>>();
|
|
||||||
|
|
||||||
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
|
|
||||||
this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
|
|
||||||
// Also add the descriptor to the channel map so Gate 2 can authorize subscriptions.
|
|
||||||
this.channels.set(entry.descriptor.name, entry.descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
|
|
||||||
return this.entries.get(channelName) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
list(): IInboundDescriptor<string, z.ZodType>[] {
|
|
||||||
return Array.from(this.entries.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
registerChannel(descriptor: RealtimeChannelDescriptor<string, z.ZodType>): void {
|
|
||||||
this.channels.set(descriptor.name, descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
listChannels(): RealtimeChannelDescriptor<string, z.ZodType>[] {
|
|
||||||
return Array.from(this.channels.values());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
import type { RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
|
|
||||||
export type RealtimeContext = {
|
|
||||||
userId: string | null;
|
|
||||||
roles: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IRealtimeHandler<T> = (input: T, ctx: RealtimeContext) => Promise<void>;
|
|
||||||
|
|
||||||
export type IInboundDescriptor<TName extends string, TSchema extends z.ZodType> = {
|
|
||||||
readonly descriptor: RealtimeChannelDescriptor<TName, TSchema>;
|
|
||||||
readonly handler: IRealtimeHandler<z.infer<TSchema>>;
|
|
||||||
};
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { defineRealtimeChannel } from "./realtime-channel";
|
|
||||||
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
|
|
||||||
import type { IInboundDescriptor, RealtimeContext } from "./realtime-handler.interface";
|
|
||||||
|
|
||||||
const pingSchema = z.object({ at: z.string().datetime() }).strict();
|
|
||||||
const pongSchema = z.object({ at: z.string().datetime(), echo: z.string() }).strict();
|
|
||||||
|
|
||||||
export type PingPayload = z.infer<typeof pingSchema>;
|
|
||||||
export type PongPayload = z.infer<typeof pongSchema>;
|
|
||||||
|
|
||||||
export const realtimePingChannel = defineRealtimeChannel(
|
|
||||||
"realtime.ping",
|
|
||||||
pingSchema,
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const realtimePongChannel = defineRealtimeChannel(
|
|
||||||
"realtime.pong",
|
|
||||||
pongSchema,
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
|
|
||||||
export function realtimePingInboundDescriptor(
|
|
||||||
broadcaster: IRealtimeBroadcaster,
|
|
||||||
): IInboundDescriptor<"realtime.ping", z.ZodType<PingPayload>> {
|
|
||||||
return {
|
|
||||||
descriptor: realtimePingChannel,
|
|
||||||
handler: async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
|
|
||||||
await broadcaster.broadcast(realtimePongChannel, {
|
|
||||||
at: input.at,
|
|
||||||
echo: ctx.userId ?? "anonymous",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { Server as HttpServer } from "node:http";
|
|
||||||
import type { Server as IOServer } from "socket.io";
|
|
||||||
import type { IRealtimeAuthenticator } from "./realtime-authenticator.interface";
|
|
||||||
import type { IRealtimeHandlerRegistry } from "./realtime-handler-registry";
|
|
||||||
|
|
||||||
export type IRealtimeServerOptions = {
|
|
||||||
httpServer: HttpServer;
|
|
||||||
io: IOServer;
|
|
||||||
authenticator: IRealtimeAuthenticator;
|
|
||||||
registry: IRealtimeHandlerRegistry;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IRealtimeServer {
|
|
||||||
start(): Promise<void>;
|
|
||||||
stop(): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { channelRoom } from "@/channel-room";
|
|
||||||
import { SocketIORealtimeBroadcaster } from "@/socket-io-realtime-broadcaster";
|
|
||||||
import { defineRealtimeChannel } from "@/realtime-channel";
|
|
||||||
|
|
||||||
const ch = defineRealtimeChannel(
|
|
||||||
"a.b",
|
|
||||||
z.object({ x: z.number() }).strict(),
|
|
||||||
{ scope: "public" },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("SocketIORealtimeBroadcaster", () => {
|
|
||||||
it("emits to the channel's room with the channel name as event", async () => {
|
|
||||||
const emit = vi.fn();
|
|
||||||
const to = vi.fn(() => ({ emit }));
|
|
||||||
const io = { to } as never;
|
|
||||||
const b = new SocketIORealtimeBroadcaster(io);
|
|
||||||
await b.broadcast(ch, { x: 1 });
|
|
||||||
expect(to).toHaveBeenCalledWith(channelRoom("a.b"));
|
|
||||||
expect(emit).toHaveBeenCalledWith("a.b", { x: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates payload before emitting", async () => {
|
|
||||||
const emit = vi.fn();
|
|
||||||
const to = vi.fn(() => ({ emit }));
|
|
||||||
const io = { to } as never;
|
|
||||||
const b = new SocketIORealtimeBroadcaster(io);
|
|
||||||
await expect(
|
|
||||||
b.broadcast(ch, { x: "not a number" } as never),
|
|
||||||
).rejects.toThrow();
|
|
||||||
expect(emit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { Server as IOServer } from "socket.io";
|
|
||||||
import type { z } from "zod";
|
|
||||||
import { channelRoom } from "./channel-room";
|
|
||||||
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
|
|
||||||
import type { RealtimeChannelDescriptor } from "./realtime-channel";
|
|
||||||
|
|
||||||
export class SocketIORealtimeBroadcaster implements IRealtimeBroadcaster {
|
|
||||||
constructor(private readonly io: IOServer) {}
|
|
||||||
|
|
||||||
async broadcast<T>(
|
|
||||||
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
|
|
||||||
payload: T,
|
|
||||||
): Promise<void> {
|
|
||||||
descriptor.schema.parse(payload);
|
|
||||||
this.io.to(channelRoom(descriptor.name)).emit(descriptor.name, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import { Server as IOServer } from "socket.io";
|
|
||||||
import { io as ioClient, type Socket as ClientSocket } from "socket.io-client";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import { SocketIORealtimeServer } from "@/socket-io-realtime-server";
|
|
||||||
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
|
|
||||||
import { defineRealtimeChannel } from "@/realtime-channel";
|
|
||||||
|
|
||||||
const pingChannel = defineRealtimeChannel(
|
|
||||||
"test.ping",
|
|
||||||
z.object({ at: z.string() }).strict(),
|
|
||||||
{ scope: "authenticated" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const userChannel = defineRealtimeChannel(
|
|
||||||
"user.{userId}.events",
|
|
||||||
z.object({ msg: z.string() }).strict(),
|
|
||||||
{ scope: { userScoped: true, template: "user.{userId}.events" } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared test infrastructure
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type SetupOpts = {
|
|
||||||
/** Override the default (cookie-based) authenticator. */
|
|
||||||
authenticator?: {
|
|
||||||
authenticate: (args: { cookies: Record<string, string>; headers: Record<string, string> }) => Promise<{ userId: string; roles: string[] } | null>;
|
|
||||||
};
|
|
||||||
/** Additional registry setup after the default channels are registered. */
|
|
||||||
extraSetup?: (registry: RealtimeHandlerRegistry) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setup(opts: SetupOpts = {}) {
|
|
||||||
const httpServer = createServer();
|
|
||||||
const io = new IOServer(httpServer);
|
|
||||||
const registry = new RealtimeHandlerRegistry();
|
|
||||||
|
|
||||||
// Hoisted so handler writes are visible in assertions
|
|
||||||
let received: { input: unknown; ctx: unknown } | null = null;
|
|
||||||
|
|
||||||
registry.register({
|
|
||||||
descriptor: pingChannel,
|
|
||||||
handler: async (input, ctx) => {
|
|
||||||
received = { input, ctx };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.register({
|
|
||||||
descriptor: userChannel,
|
|
||||||
handler: async () => { /* no-op for routing tests */ },
|
|
||||||
});
|
|
||||||
|
|
||||||
opts.extraSetup?.(registry);
|
|
||||||
|
|
||||||
const authenticator = opts.authenticator ?? {
|
|
||||||
authenticate: async ({ cookies }: { cookies: Record<string, string> }) => {
|
|
||||||
if (cookies.session === "valid") return { userId: "u1", roles: [] };
|
|
||||||
if (cookies.session === "valid-u2") return { userId: "u2", roles: [] };
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const server = new SocketIORealtimeServer({ httpServer, io, authenticator, registry });
|
|
||||||
await server.start();
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
|
|
||||||
const port = (httpServer.address() as AddressInfo).port;
|
|
||||||
|
|
||||||
return { httpServer, io, server, port, getReceived: () => received };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient(port: number, cookie?: string): ClientSocket {
|
|
||||||
return ioClient(`http://localhost:${port}`, {
|
|
||||||
...(cookie ? { extraHeaders: { Cookie: cookie } } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectClient(port: number, cookie?: string): Promise<ClientSocket> {
|
|
||||||
const client = makeClient(port, cookie);
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
client.on("connect", () => resolve());
|
|
||||||
client.on("connect_error", (err) => reject(err));
|
|
||||||
});
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("SocketIORealtimeServer", () => {
|
|
||||||
let teardown: () => Promise<void>;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await teardown?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects subscribe to authenticated channel from anonymous socket", async () => {
|
|
||||||
const { server, httpServer, port } = await setup();
|
|
||||||
teardown = async () => { await server.stop(); httpServer.close(); };
|
|
||||||
|
|
||||||
const client = await connectClient(port);
|
|
||||||
const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
client.emit("subscribe", "test.ping", r),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ack.ok).toBe(false);
|
|
||||||
expect(ack.error).toBe("forbidden");
|
|
||||||
client.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows subscribe + invokes handler with ctx for authenticated socket", async () => {
|
|
||||||
const { server, httpServer, port, getReceived } = await setup();
|
|
||||||
teardown = async () => { await server.stop(); httpServer.close(); };
|
|
||||||
|
|
||||||
const client = await connectClient(port, "session=valid");
|
|
||||||
|
|
||||||
const subAck = await new Promise<{ ok: boolean }>((r) =>
|
|
||||||
client.emit("subscribe", "test.ping", r),
|
|
||||||
);
|
|
||||||
expect(subAck.ok).toBe(true);
|
|
||||||
|
|
||||||
const sentAt = new Date().toISOString();
|
|
||||||
const ack = await new Promise<{ ok: boolean }>((r) =>
|
|
||||||
client.emit("test.ping", { at: sentAt }, r),
|
|
||||||
);
|
|
||||||
expect(ack.ok).toBe(true);
|
|
||||||
|
|
||||||
// Allow the async handler to complete before reading `received`
|
|
||||||
await new Promise<void>((r) => setTimeout(r, 20));
|
|
||||||
|
|
||||||
expect(getReceived()).toEqual({
|
|
||||||
input: { at: sentAt },
|
|
||||||
ctx: { userId: "u1", roles: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
client.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown channel subscribe", async () => {
|
|
||||||
const { server, httpServer, port } = await setup();
|
|
||||||
teardown = async () => { await server.stop(); httpServer.close(); };
|
|
||||||
|
|
||||||
const client = await connectClient(port);
|
|
||||||
const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
client.emit("subscribe", "does.not.exist", r),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ack.ok).toBe(false);
|
|
||||||
expect(ack.error).toBe("unknown_channel");
|
|
||||||
client.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed inbound input", async () => {
|
|
||||||
const { server, httpServer, port } = await setup();
|
|
||||||
teardown = async () => { await server.stop(); httpServer.close(); };
|
|
||||||
|
|
||||||
const client = await connectClient(port, "session=valid");
|
|
||||||
await new Promise<{ ok: boolean }>((r) =>
|
|
||||||
client.emit("subscribe", "test.ping", r),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
client.emit("test.ping", { at: 123 } as never, r),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ack.ok).toBe(false);
|
|
||||||
expect(ack.error).toBe("invalid_input");
|
|
||||||
client.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Fix #2 — authenticator exception rejects the connection
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it("rejects connection when authenticator throws", async () => {
|
|
||||||
const { server, httpServer, port } = await setup({
|
|
||||||
authenticator: {
|
|
||||||
authenticate: async () => {
|
|
||||||
throw new Error("auth service unavailable");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
teardown = async () => { await server.stop(); httpServer.close(); };
|
|
||||||
|
|
||||||
const client = makeClient(port);
|
|
||||||
|
|
||||||
const connectError = await new Promise<Error | null>((resolve) => {
|
|
||||||
client.on("connect", () => resolve(null));
|
|
||||||
client.on("connect_error", (err) => resolve(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(connectError).not.toBeNull();
|
|
||||||
expect(connectError?.message).toContain("auth service unavailable");
|
|
||||||
client.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Fix #4 — userScoped channel inbound: owner accepted, non-owner rejected
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it("userScoped channel: accepts inbound from owner, rejects from non-owner", async () => {
|
|
||||||
const { server, httpServer, port } = await setup();
|
|
||||||
teardown = async () => { await server.stop(); httpServer.close(); };
|
|
||||||
|
|
||||||
// u1 owns the channel "user.u1.events" — should be allowed
|
|
||||||
const owner = await connectClient(port, "session=valid");
|
|
||||||
const ownerSubAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
owner.emit("subscribe", "user.u1.events", r),
|
|
||||||
);
|
|
||||||
expect(ownerSubAck.ok).toBe(true);
|
|
||||||
|
|
||||||
const ownerAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
owner.emit("user.{userId}.events", { msg: "hello" }, r),
|
|
||||||
);
|
|
||||||
expect(ownerAck.ok).toBe(true);
|
|
||||||
owner.disconnect();
|
|
||||||
|
|
||||||
// u2 tries to send to user.{userId}.events but their userId is "u2" not "u1"
|
|
||||||
// The channel is userScoped so params.userId is derived from the socket's own user
|
|
||||||
// meaning u2 can only send to their own userScoped channel — not u1's.
|
|
||||||
// Verify u2's own inbound is accepted (they send as u2, params.userId = "u2", user.userId = "u2")
|
|
||||||
const other = await connectClient(port, "session=valid-u2");
|
|
||||||
const otherAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
other.emit("user.{userId}.events", { msg: "hello from u2" }, r),
|
|
||||||
);
|
|
||||||
// u2 is authenticated and params.userId = "u2" = user.userId — so this passes
|
|
||||||
expect(otherAck.ok).toBe(true);
|
|
||||||
other.disconnect();
|
|
||||||
|
|
||||||
// An anonymous socket is rejected entirely
|
|
||||||
const anon = await connectClient(port);
|
|
||||||
const anonAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
|
|
||||||
anon.emit("user.{userId}.events", { msg: "hello from anon" }, r),
|
|
||||||
);
|
|
||||||
expect(anonAck.ok).toBe(false);
|
|
||||||
expect(anonAck.error).toBe("forbidden");
|
|
||||||
anon.disconnect();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import type { Server as IOServer, Socket, DefaultEventsMap } from "socket.io";
|
|
||||||
import { authorize } from "./authorize";
|
|
||||||
import { channelRoom } from "./channel-room";
|
|
||||||
import { matchChannelTemplate } from "./channel-template";
|
|
||||||
import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
|
|
||||||
|
|
||||||
/** Shape of per-socket session data attached by Gate 1. */
|
|
||||||
type AppSocketData = { user: { userId: string; roles: string[] } | null };
|
|
||||||
|
|
||||||
/** Fully-typed Socket alias so `socket.data.user` resolves to `AppSocketData["user"]`. */
|
|
||||||
type AppSocket = Socket<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, AppSocketData>;
|
|
||||||
|
|
||||||
function parseCookies(header: string): Record<string, string> {
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
for (const part of header.split(";")) {
|
|
||||||
const [k, ...rest] = part.trim().split("=");
|
|
||||||
if (k) out[k] = decodeURIComponent(rest.join("="));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SocketIORealtimeServer implements IRealtimeServer {
|
|
||||||
private readonly io: IOServer;
|
|
||||||
private readonly opts: IRealtimeServerOptions;
|
|
||||||
|
|
||||||
constructor(opts: IRealtimeServerOptions) {
|
|
||||||
this.opts = opts;
|
|
||||||
this.io = opts.io;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
const { authenticator, registry } = this.opts;
|
|
||||||
|
|
||||||
// Gate 1: connect — read cookie, authenticate, attach user.
|
|
||||||
// If the authenticator throws (e.g. malformed token), the connection is
|
|
||||||
// rejected so the client receives `connect_error`.
|
|
||||||
(this.io as IOServer<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, AppSocketData>).use(
|
|
||||||
async (socket, next) => {
|
|
||||||
try {
|
|
||||||
const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
|
|
||||||
socket.data.user = await authenticator.authenticate({
|
|
||||||
cookies,
|
|
||||||
headers: socket.handshake.headers as Record<string, string>,
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.io.on("connection", (rawSocket) => {
|
|
||||||
const socket = rawSocket as AppSocket;
|
|
||||||
|
|
||||||
// Gate 2: subscribe.
|
|
||||||
socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
|
|
||||||
// Find a registered descriptor whose name (or template) matches requestedName.
|
|
||||||
// listChannels() covers both inbound descriptors and outbound-only channels.
|
|
||||||
let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null;
|
|
||||||
for (const descriptor of registry.listChannels()) {
|
|
||||||
const m = matchChannelTemplate(descriptor.name, requestedName);
|
|
||||||
if (m) {
|
|
||||||
matched = { descriptor, params: m.params };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched) {
|
|
||||||
ack?.({ ok: false, error: "unknown_channel" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = await authorize(
|
|
||||||
matched.descriptor as never,
|
|
||||||
matched.params,
|
|
||||||
socket.data.user ?? null,
|
|
||||||
);
|
|
||||||
if (!allowed) {
|
|
||||||
ack?.({ ok: false, error: "forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.join(channelRoom(requestedName));
|
|
||||||
ack?.({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Gate 3: inbound — one listener per registered channel.
|
|
||||||
for (const entry of registry.list()) {
|
|
||||||
socket.on(entry.descriptor.name, async (payload: unknown, ack?: (r: unknown) => void) => {
|
|
||||||
const parsed = entry.descriptor.schema.safeParse(payload);
|
|
||||||
if (!parsed.success) {
|
|
||||||
ack?.({ ok: false, error: "invalid_input" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For userScoped channels, derive params from the authenticated user
|
|
||||||
// so the owner's own socket passes the `params.userId === user.userId` check.
|
|
||||||
const scope = entry.descriptor.scope;
|
|
||||||
const params: Record<string, string> =
|
|
||||||
typeof scope === "object" && "userScoped" in scope
|
|
||||||
? { userId: socket.data.user?.userId ?? "" }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const allowed = await authorize(entry.descriptor, params, socket.data.user ?? null);
|
|
||||||
if (!allowed) {
|
|
||||||
ack?.({ ok: false, error: "forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await entry.handler(parsed.data, {
|
|
||||||
userId: socket.data.user?.userId ?? null,
|
|
||||||
roles: socket.data.user?.roles ?? [],
|
|
||||||
});
|
|
||||||
ack?.({ ok: true });
|
|
||||||
} catch {
|
|
||||||
ack?.({ ok: false, error: "handler_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
await new Promise<void>((resolve) => this.io.close(() => resolve()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export const CORE_REALTIME_SYMBOLS = {
|
|
||||||
IRealtimeBroadcaster: Symbol.for("core-realtime:IRealtimeBroadcaster"),
|
|
||||||
IRealtimeServer: Symbol.for("core-realtime:IRealtimeServer"),
|
|
||||||
IRealtimeAuthenticator: Symbol.for("core-realtime:IRealtimeAuthenticator"),
|
|
||||||
IRealtimeHandlerRegistry: Symbol.for("core-realtime:IRealtimeHandlerRegistry"),
|
|
||||||
} as const;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@repo/core-typescript/base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*", "*.config.ts"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://turborepo.dev/schema.json",
|
|
||||||
"extends": ["//"],
|
|
||||||
"tags": ["core"]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { mergeConfig } from "vitest/config";
|
|
||||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
|
||||||
|
|
||||||
export default mergeConfig(nodeVitestConfig, {
|
|
||||||
resolve: {
|
|
||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-realtime": "workspace:*",
|
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||||
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
|
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||||
import { RealtimeHandlerRegistry } from "@repo/core-realtime";
|
|
||||||
import { bindDevSeedMarketingPages } from "@/di/bind-dev-seed";
|
import { bindDevSeedMarketingPages } from "@/di/bind-dev-seed";
|
||||||
import { marketingPagesContainer } from "@/di/container";
|
import { marketingPagesContainer } from "@/di/container";
|
||||||
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
|
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
|
||||||
@@ -61,7 +60,7 @@ describe("bindDevSeedMarketingPages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("populates the pages repository with the dev pages", async () => {
|
it("populates the pages repository with the dev pages", async () => {
|
||||||
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = marketingPagesContainer.get<IPagesRepository>(
|
const repo = marketingPagesContainer.get<IPagesRepository>(
|
||||||
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
||||||
@@ -72,7 +71,7 @@ describe("bindDevSeedMarketingPages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds site settings with a non-default site name", async () => {
|
it("seeds site settings with a non-default site name", async () => {
|
||||||
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const settingsRepo = marketingPagesContainer.get<ISiteSettingsRepository>(
|
const settingsRepo = marketingPagesContainer.get<ISiteSettingsRepository>(
|
||||||
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
|
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
|
||||||
@@ -84,13 +83,13 @@ describe("bindDevSeedMarketingPages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — calling twice rebuilds fresh populated repos", async () => {
|
it("is idempotent — calling twice rebuilds fresh populated repos", async () => {
|
||||||
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const before = marketingPagesContainer.get<IPagesRepository>(
|
const before = marketingPagesContainer.get<IPagesRepository>(
|
||||||
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
||||||
);
|
);
|
||||||
const beforeCount = (await before.getPages()).length;
|
const beforeCount = (await before.getPages()).length;
|
||||||
|
|
||||||
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMarketingPages({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const after = marketingPagesContainer.get<IPagesRepository>(
|
const after = marketingPagesContainer.get<IPagesRepository>(
|
||||||
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-realtime": "workspace:*",
|
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||||
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
|
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||||
import { RealtimeHandlerRegistry } from "@repo/core-realtime";
|
|
||||||
import { bindDevSeedMedia } from "@/di/bind-dev-seed";
|
import { bindDevSeedMedia } from "@/di/bind-dev-seed";
|
||||||
import { mediaContainer } from "@/di/container";
|
import { mediaContainer } from "@/di/container";
|
||||||
import { MEDIA_SYMBOLS } from "@/di/symbols";
|
import { MEDIA_SYMBOLS } from "@/di/symbols";
|
||||||
@@ -33,7 +32,7 @@ describe("bindDevSeedMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("populates the repository with the dev media entries", async () => {
|
it("populates the repository with the dev media entries", async () => {
|
||||||
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = mediaContainer.get<IMediaRepository>(
|
const repo = mediaContainer.get<IMediaRepository>(
|
||||||
MEDIA_SYMBOLS.IMediaRepository,
|
MEDIA_SYMBOLS.IMediaRepository,
|
||||||
@@ -44,7 +43,7 @@ describe("bindDevSeedMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds placeholder-1 reachable by id", async () => {
|
it("seeds placeholder-1 reachable by id", async () => {
|
||||||
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = mediaContainer.get<IMediaRepository>(
|
const repo = mediaContainer.get<IMediaRepository>(
|
||||||
MEDIA_SYMBOLS.IMediaRepository,
|
MEDIA_SYMBOLS.IMediaRepository,
|
||||||
@@ -57,13 +56,13 @@ describe("bindDevSeedMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||||
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const before = mediaContainer.get<IMediaRepository>(
|
const before = mediaContainer.get<IMediaRepository>(
|
||||||
MEDIA_SYMBOLS.IMediaRepository,
|
MEDIA_SYMBOLS.IMediaRepository,
|
||||||
);
|
);
|
||||||
const beforeCount = (await before.listMedia()).length;
|
const beforeCount = (await before.listMedia()).length;
|
||||||
|
|
||||||
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedMedia({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const after = mediaContainer.get<IMediaRepository>(
|
const after = mediaContainer.get<IMediaRepository>(
|
||||||
MEDIA_SYMBOLS.IMediaRepository,
|
MEDIA_SYMBOLS.IMediaRepository,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-realtime": "workspace:*",
|
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||||
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
|
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||||
import { RealtimeHandlerRegistry } from "@repo/core-realtime";
|
|
||||||
import { bindDevSeedNavigation } from "@/di/bind-dev-seed";
|
import { bindDevSeedNavigation } from "@/di/bind-dev-seed";
|
||||||
import { navigationContainer } from "@/di/container";
|
import { navigationContainer } from "@/di/container";
|
||||||
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
||||||
@@ -33,7 +32,7 @@ describe("bindDevSeedNavigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("populates the header repository with the dev header", async () => {
|
it("populates the header repository with the dev header", async () => {
|
||||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = navigationContainer.get<IHeaderRepository>(
|
const repo = navigationContainer.get<IHeaderRepository>(
|
||||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||||
@@ -44,7 +43,7 @@ describe("bindDevSeedNavigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds a header with a non-empty items array", async () => {
|
it("seeds a header with a non-empty items array", async () => {
|
||||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
|
|
||||||
const repo = navigationContainer.get<IHeaderRepository>(
|
const repo = navigationContainer.get<IHeaderRepository>(
|
||||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||||
@@ -58,13 +57,13 @@ describe("bindDevSeedNavigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const before = navigationContainer.get<IHeaderRepository>(
|
const before = navigationContainer.get<IHeaderRepository>(
|
||||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||||
);
|
);
|
||||||
const beforeHeader = await before.getHeader();
|
const beforeHeader = await before.getHeader();
|
||||||
|
|
||||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue(), realtime: new RecordingRealtimeBroadcaster(), realtimeRegistry: new RealtimeHandlerRegistry() });
|
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||||
const after = navigationContainer.get<IHeaderRepository>(
|
const after = navigationContainer.get<IHeaderRepository>(
|
||||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||||
);
|
);
|
||||||
|
|||||||
233
pnpm-lock.yaml
generated
233
pnpm-lock.yaml
generated
@@ -157,9 +157,6 @@ importers:
|
|||||||
'@repo/core-events':
|
'@repo/core-events':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-events
|
version: link:../../packages/core-events
|
||||||
'@repo/core-realtime':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../packages/core-realtime
|
|
||||||
'@repo/core-shared':
|
'@repo/core-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-shared
|
version: link:../../packages/core-shared
|
||||||
@@ -205,9 +202,6 @@ importers:
|
|||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
socket.io:
|
|
||||||
specifier: ^4.7.0
|
|
||||||
version: 4.8.3
|
|
||||||
superjson:
|
superjson:
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.2.6
|
version: 2.2.6
|
||||||
@@ -245,9 +239,6 @@ importers:
|
|||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^25.0.0
|
specifier: ^25.0.0
|
||||||
version: 25.0.1
|
version: 25.0.1
|
||||||
socket.io-client:
|
|
||||||
specifier: ^4.7.0
|
|
||||||
version: 4.8.3
|
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
@@ -342,9 +333,6 @@ importers:
|
|||||||
'@repo/core-events':
|
'@repo/core-events':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-events
|
version: link:../core-events
|
||||||
'@repo/core-realtime':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-realtime
|
|
||||||
'@repo/core-shared':
|
'@repo/core-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-shared
|
version: link:../core-shared
|
||||||
@@ -388,9 +376,6 @@ importers:
|
|||||||
'@repo/core-events':
|
'@repo/core-events':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-events
|
version: link:../core-events
|
||||||
'@repo/core-realtime':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-realtime
|
|
||||||
'@repo/core-shared':
|
'@repo/core-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-shared
|
version: link:../core-shared
|
||||||
@@ -574,43 +559,6 @@ importers:
|
|||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)
|
||||||
|
|
||||||
packages/core-realtime:
|
|
||||||
dependencies:
|
|
||||||
'@repo/core-shared':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-shared
|
|
||||||
payload:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
|
||||||
socket.io:
|
|
||||||
specifier: ^4.7.0
|
|
||||||
version: 4.8.3
|
|
||||||
zod:
|
|
||||||
specifier: ^3.23.0
|
|
||||||
version: 3.25.76
|
|
||||||
devDependencies:
|
|
||||||
'@repo/core-eslint':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-eslint
|
|
||||||
'@repo/core-testing':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-testing
|
|
||||||
'@repo/core-typescript':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-typescript
|
|
||||||
'@types/node':
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.19.17
|
|
||||||
socket.io-client:
|
|
||||||
specifier: ^4.7.0
|
|
||||||
version: 4.8.3
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.8.0
|
|
||||||
version: 5.9.3
|
|
||||||
vitest:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)
|
|
||||||
|
|
||||||
packages/core-shared:
|
packages/core-shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
@@ -830,9 +778,6 @@ importers:
|
|||||||
'@repo/core-events':
|
'@repo/core-events':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-events
|
version: link:../core-events
|
||||||
'@repo/core-realtime':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-realtime
|
|
||||||
'@repo/core-shared':
|
'@repo/core-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-shared
|
version: link:../core-shared
|
||||||
@@ -876,9 +821,6 @@ importers:
|
|||||||
'@repo/core-events':
|
'@repo/core-events':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-events
|
version: link:../core-events
|
||||||
'@repo/core-realtime':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-realtime
|
|
||||||
'@repo/core-shared':
|
'@repo/core-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-shared
|
version: link:../core-shared
|
||||||
@@ -919,9 +861,6 @@ importers:
|
|||||||
'@repo/core-events':
|
'@repo/core-events':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-events
|
version: link:../core-events
|
||||||
'@repo/core-realtime':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../core-realtime
|
|
||||||
'@repo/core-shared':
|
'@repo/core-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-shared
|
version: link:../core-shared
|
||||||
@@ -3283,9 +3222,6 @@ packages:
|
|||||||
'@sinonjs/fake-timers@10.3.0':
|
'@sinonjs/fake-timers@10.3.0':
|
||||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2':
|
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -3813,9 +3749,6 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
'@types/cors@2.8.19':
|
|
||||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
@@ -4088,10 +4021,6 @@ packages:
|
|||||||
'@xtuc/long@4.2.2':
|
'@xtuc/long@4.2.2':
|
||||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||||
|
|
||||||
accepts@1.3.8:
|
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
acorn-import-attributes@1.9.5:
|
acorn-import-attributes@1.9.5:
|
||||||
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
|
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4264,10 +4193,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
base64id@2.0.0:
|
|
||||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
|
||||||
engines: {node: ^4.5.0 || >= 5.9}
|
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.15:
|
baseline-browser-mapping@2.10.15:
|
||||||
resolution: {integrity: sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==}
|
resolution: {integrity: sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -4522,18 +4447,10 @@ packages:
|
|||||||
cookie-es@2.0.1:
|
cookie-es@2.0.1:
|
||||||
resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==}
|
resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==}
|
||||||
|
|
||||||
cookie@0.7.2:
|
|
||||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cors@2.8.6:
|
|
||||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
|
||||||
engines: {node: '>= 0.10'}
|
|
||||||
|
|
||||||
corser@2.0.1:
|
corser@2.0.1:
|
||||||
resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==}
|
resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
@@ -4836,17 +4753,6 @@ packages:
|
|||||||
end-of-stream@1.4.5:
|
end-of-stream@1.4.5:
|
||||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||||
|
|
||||||
engine.io-client@6.6.4:
|
|
||||||
resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
|
|
||||||
|
|
||||||
engine.io-parser@5.2.3:
|
|
||||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
|
|
||||||
engine.io@6.6.7:
|
|
||||||
resolution: {integrity: sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==}
|
|
||||||
engines: {node: '>=10.2.0'}
|
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -6221,10 +6127,6 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
negotiator@0.6.3:
|
|
||||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
neo-async@2.6.2:
|
neo-async@2.6.2:
|
||||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||||
|
|
||||||
@@ -6956,21 +6858,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
socket.io-adapter@2.5.6:
|
|
||||||
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
|
||||||
|
|
||||||
socket.io-client@4.8.3:
|
|
||||||
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
|
|
||||||
socket.io-parser@4.2.6:
|
|
||||||
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
|
|
||||||
socket.io@4.8.3:
|
|
||||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
|
||||||
engines: {node: '>=10.2.0'}
|
|
||||||
|
|
||||||
sonic-boom@4.2.1:
|
sonic-boom@4.2.1:
|
||||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
@@ -7431,10 +7318,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
|
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
|
||||||
engines: {node: '>=10.12.0'}
|
engines: {node: '>=10.12.0'}
|
||||||
|
|
||||||
vary@1.1.2:
|
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||||
|
|
||||||
@@ -7633,18 +7516,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
|
|
||||||
ws@8.18.3:
|
|
||||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
bufferutil: ^4.0.1
|
|
||||||
utf-8-validate: '>=5.0.2'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
bufferutil:
|
|
||||||
optional: true
|
|
||||||
utf-8-validate:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
ws@8.20.0:
|
ws@8.20.0:
|
||||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -7667,10 +7538,6 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2:
|
|
||||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
|
||||||
engines: {node: '>=0.4.0'}
|
|
||||||
|
|
||||||
xss@1.0.15:
|
xss@1.0.15:
|
||||||
resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==}
|
resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
@@ -10421,8 +10288,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sinonjs/commons': 3.0.1
|
'@sinonjs/commons': 3.0.1
|
||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2': {}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@storybook/addon-actions@8.6.14(storybook@8.6.18(prettier@3.8.1))':
|
'@storybook/addon-actions@8.6.14(storybook@8.6.18(prettier@3.8.1))':
|
||||||
@@ -10979,10 +10844,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
'@types/cors@2.8.19':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 22.19.17
|
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -11374,11 +11235,6 @@ snapshots:
|
|||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
|
||||||
dependencies:
|
|
||||||
mime-types: 2.1.35
|
|
||||||
negotiator: 0.6.3
|
|
||||||
|
|
||||||
acorn-import-attributes@1.9.5(acorn@8.16.0):
|
acorn-import-attributes@1.9.5(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -11569,8 +11425,6 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
base64id@2.0.0: {}
|
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.15: {}
|
baseline-browser-mapping@2.10.15: {}
|
||||||
|
|
||||||
basic-auth@2.0.1:
|
basic-auth@2.0.1:
|
||||||
@@ -11806,17 +11660,10 @@ snapshots:
|
|||||||
|
|
||||||
cookie-es@2.0.1: {}
|
cookie-es@2.0.1: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-what: 5.5.0
|
is-what: 5.5.0
|
||||||
|
|
||||||
cors@2.8.6:
|
|
||||||
dependencies:
|
|
||||||
object-assign: 4.1.1
|
|
||||||
vary: 1.1.2
|
|
||||||
|
|
||||||
corser@2.0.1: {}
|
corser@2.0.1: {}
|
||||||
|
|
||||||
cosmiconfig@7.1.0:
|
cosmiconfig@7.1.0:
|
||||||
@@ -12013,37 +11860,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
|
|
||||||
engine.io-client@6.6.4:
|
|
||||||
dependencies:
|
|
||||||
'@socket.io/component-emitter': 3.1.2
|
|
||||||
debug: 4.4.3
|
|
||||||
engine.io-parser: 5.2.3
|
|
||||||
ws: 8.18.3
|
|
||||||
xmlhttprequest-ssl: 2.1.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
engine.io-parser@5.2.3: {}
|
|
||||||
|
|
||||||
engine.io@6.6.7:
|
|
||||||
dependencies:
|
|
||||||
'@types/cors': 2.8.19
|
|
||||||
'@types/node': 22.19.17
|
|
||||||
'@types/ws': 8.18.1
|
|
||||||
accepts: 1.3.8
|
|
||||||
base64id: 2.0.0
|
|
||||||
cookie: 0.7.2
|
|
||||||
cors: 2.8.6
|
|
||||||
debug: 4.4.3
|
|
||||||
engine.io-parser: 5.2.3
|
|
||||||
ws: 8.18.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -13823,8 +13639,6 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
negotiator@0.6.3: {}
|
|
||||||
|
|
||||||
neo-async@2.6.2: {}
|
neo-async@2.6.2: {}
|
||||||
|
|
||||||
next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0):
|
next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0):
|
||||||
@@ -14687,47 +14501,6 @@ snapshots:
|
|||||||
|
|
||||||
slash@5.1.0: {}
|
slash@5.1.0: {}
|
||||||
|
|
||||||
socket.io-adapter@2.5.6:
|
|
||||||
dependencies:
|
|
||||||
debug: 4.4.3
|
|
||||||
ws: 8.18.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
socket.io-client@4.8.3:
|
|
||||||
dependencies:
|
|
||||||
'@socket.io/component-emitter': 3.1.2
|
|
||||||
debug: 4.4.3
|
|
||||||
engine.io-client: 6.6.4
|
|
||||||
socket.io-parser: 4.2.6
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
socket.io-parser@4.2.6:
|
|
||||||
dependencies:
|
|
||||||
'@socket.io/component-emitter': 3.1.2
|
|
||||||
debug: 4.4.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
socket.io@4.8.3:
|
|
||||||
dependencies:
|
|
||||||
accepts: 1.3.8
|
|
||||||
base64id: 2.0.0
|
|
||||||
cors: 2.8.6
|
|
||||||
debug: 4.4.3
|
|
||||||
engine.io: 6.6.7
|
|
||||||
socket.io-adapter: 2.5.6
|
|
||||||
socket.io-parser: 4.2.6
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
sonic-boom@4.2.1:
|
sonic-boom@4.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
@@ -15148,8 +14921,6 @@ snapshots:
|
|||||||
'@types/istanbul-lib-coverage': 2.0.6
|
'@types/istanbul-lib-coverage': 2.0.6
|
||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
|
|
||||||
vary@1.1.2: {}
|
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -15480,8 +15251,6 @@ snapshots:
|
|||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
|
|
||||||
ws@8.18.3: {}
|
|
||||||
|
|
||||||
ws@8.20.0: {}
|
ws@8.20.0: {}
|
||||||
|
|
||||||
xml-name-validator@5.0.0: {}
|
xml-name-validator@5.0.0: {}
|
||||||
@@ -15490,8 +15259,6 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2: {}
|
|
||||||
|
|
||||||
xss@1.0.15:
|
xss@1.0.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { mkdtempSync, cpSync, readFileSync } from "node:fs";
|
import { mkdtempSync, cpSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { computeSnapshot } from "../lib/snapshot";
|
import { computeSnapshot } from "../lib/snapshot";
|
||||||
import expectedSnapshot from "../__snapshots__/core-package/realtime.snapshot.json";
|
import expectedSnapshot from "../__snapshots__/core-package/realtime.snapshot.json";
|
||||||
|
|
||||||
|
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||||
|
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", "..");
|
||||||
|
|
||||||
describe("e2e: core-package realtime", () => {
|
describe("e2e: core-package realtime", () => {
|
||||||
it("byte-identical reconstruction matches snapshot", { timeout: 120_000 }, () => {
|
it("byte-identical reconstruction matches snapshot", { timeout: 120_000 }, () => {
|
||||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-"));
|
const tmp = mkdtempSync(join(tmpdir(), "e2e-"));
|
||||||
cpSync(process.cwd(), tmp, {
|
cpSync(REPO_ROOT, tmp, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
filter: (src) =>
|
filter: (src) =>
|
||||||
!src.includes("node_modules") &&
|
!src.includes("node_modules") &&
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||||
import { join, relative, dirname } from "node:path";
|
import { join, relative } from "node:path";
|
||||||
import type { PlopTypes } from "@turbo/gen";
|
import type { PlopTypes } from "@turbo/gen";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws if a core package directory already exists. Used as the first action
|
* Throws if a core package directory already exists. Used as the first action
|
||||||
@@ -116,8 +112,18 @@ export function emitTemplateTree(
|
|||||||
destPrefix: string,
|
destPrefix: string,
|
||||||
opts: { templatesRoot?: string } = {},
|
opts: { templatesRoot?: string } = {},
|
||||||
): PlopTypes.AddActionConfig[] {
|
): PlopTypes.AddActionConfig[] {
|
||||||
const root =
|
// The templates directory is resolved in priority order:
|
||||||
opts.templatesRoot ?? join(__dirname, "..", "templates");
|
// 1. opts.templatesRoot — test injection (temp directory)
|
||||||
|
// 2. cwd/turbo/generators/templates — turbo gen context (cwd = repo root)
|
||||||
|
// 3. cwd/templates — vitest context (cwd = turbo/generators)
|
||||||
|
let root: string;
|
||||||
|
if (opts.templatesRoot) {
|
||||||
|
root = opts.templatesRoot;
|
||||||
|
} else {
|
||||||
|
const fromRepoRoot = join(process.cwd(), "turbo", "generators", "templates");
|
||||||
|
const fromGeneratorsDir = join(process.cwd(), "templates");
|
||||||
|
root = existsSync(fromRepoRoot) ? fromRepoRoot : fromGeneratorsDir;
|
||||||
|
}
|
||||||
const srcRoot = join(root, srcPrefix);
|
const srcRoot = join(root, srcPrefix);
|
||||||
const out: PlopTypes.AddActionConfig[] = [];
|
const out: PlopTypes.AddActionConfig[] = [];
|
||||||
walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out);
|
walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out);
|
||||||
|
|||||||
Reference in New Issue
Block a user