From 2dcfecc826a587d236a8559f7290a6fe4dbb7619 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sat, 9 May 2026 13:28:46 +0200 Subject: [PATCH] feat(generators): capture core-realtime as verbatim template files --- .../core-package/realtime/AGENTS.md.hbs | 19 ++ .../realtime/eslint.config.js.hbs | 3 + .../core-package/realtime/package.json.hbs | 35 +++ .../realtime/src/authorize.test.ts.hbs | 60 +++++ .../realtime/src/authorize.ts.hbs | 22 ++ .../realtime/src/channel-room.ts.hbs | 3 + .../realtime/src/channel-template.test.ts.hbs | 33 +++ .../realtime/src/channel-template.ts.hbs | 26 ++ ...in-memory-realtime-broadcaster.test.ts.hbs | 34 +++ .../src/in-memory-realtime-broadcaster.ts.hbs | 28 ++ .../core-package/realtime/src/index.ts.hbs | 22 ++ .../realtime-authenticator.interface.ts.hbs | 6 + .../src/realtime-broadcaster.interface.ts.hbs | 10 + .../realtime/src/realtime-channel.test.ts.hbs | 25 ++ .../realtime/src/realtime-channel.ts.hbs | 28 ++ .../src/realtime-handler-registry.test.ts.hbs | 91 +++++++ .../src/realtime-handler-registry.ts.hbs | 40 +++ .../src/realtime-handler.interface.ts.hbs | 14 + .../realtime/src/realtime-ping.ts.hbs | 36 +++ .../src/realtime-server.interface.ts.hbs | 16 ++ ...socket-io-realtime-broadcaster.test.ts.hbs | 34 +++ .../src/socket-io-realtime-broadcaster.ts.hbs | 17 ++ .../src/socket-io-realtime-server.test.ts.hbs | 241 ++++++++++++++++++ .../src/socket-io-realtime-server.ts.hbs | 125 +++++++++ .../core-package/realtime/src/symbols.ts.hbs | 6 + .../core-package/realtime/tsconfig.json.hbs | 12 + .../core-package/realtime/turbo.json.hbs | 5 + .../realtime/vitest.config.ts.hbs | 9 + 28 files changed, 1000 insertions(+) create mode 100644 turbo/generators/templates/core-package/realtime/AGENTS.md.hbs create mode 100644 turbo/generators/templates/core-package/realtime/eslint.config.js.hbs create mode 100644 turbo/generators/templates/core-package/realtime/package.json.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/authorize.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/authorize.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/channel-room.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/channel-template.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/channel-template.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/index.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-authenticator.interface.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-broadcaster.interface.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-channel.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-channel.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-handler.interface.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-ping.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/realtime-server.interface.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/src/symbols.ts.hbs create mode 100644 turbo/generators/templates/core-package/realtime/tsconfig.json.hbs create mode 100644 turbo/generators/templates/core-package/realtime/turbo.json.hbs create mode 100644 turbo/generators/templates/core-package/realtime/vitest.config.ts.hbs diff --git a/turbo/generators/templates/core-package/realtime/AGENTS.md.hbs b/turbo/generators/templates/core-package/realtime/AGENTS.md.hbs new file mode 100644 index 0000000..b046bdf --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/AGENTS.md.hbs @@ -0,0 +1,19 @@ +# @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`. diff --git a/turbo/generators/templates/core-package/realtime/eslint.config.js.hbs b/turbo/generators/templates/core-package/realtime/eslint.config.js.hbs new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/eslint.config.js.hbs @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/turbo/generators/templates/core-package/realtime/package.json.hbs b/turbo/generators/templates/core-package/realtime/package.json.hbs new file mode 100644 index 0000000..c492977 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/package.json.hbs @@ -0,0 +1,35 @@ +{ + "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" + } +} diff --git a/turbo/generators/templates/core-package/realtime/src/authorize.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/authorize.test.ts.hbs new file mode 100644 index 0000000..1d0e900 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/authorize.test.ts.hbs @@ -0,0 +1,60 @@ +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); + }); + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/authorize.ts.hbs b/turbo/generators/templates/core-package/realtime/src/authorize.ts.hbs new file mode 100644 index 0000000..5f6dd82 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/authorize.ts.hbs @@ -0,0 +1,22 @@ +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export async function authorize( + descriptor: RealtimeChannelDescriptor, + params: Record, + user: { userId: string; roles: string[] } | null, +): Promise { + 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; +} diff --git a/turbo/generators/templates/core-package/realtime/src/channel-room.ts.hbs b/turbo/generators/templates/core-package/realtime/src/channel-room.ts.hbs new file mode 100644 index 0000000..afd8e6d --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/channel-room.ts.hbs @@ -0,0 +1,3 @@ +export const CHANNEL_ROOM_PREFIX = "ch:"; +export const channelRoom = (channelName: string): string => + `${CHANNEL_ROOM_PREFIX}${channelName}`; diff --git a/turbo/generators/templates/core-package/realtime/src/channel-template.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/channel-template.test.ts.hbs new file mode 100644 index 0000000..39ff016 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/channel-template.test.ts.hbs @@ -0,0 +1,33 @@ +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" } }); + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/channel-template.ts.hbs b/turbo/generators/templates/core-package/realtime/src/channel-template.ts.hbs new file mode 100644 index 0000000..7264f5f --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/channel-template.ts.hbs @@ -0,0 +1,26 @@ +export function matchChannelTemplate( + template: string, + candidate: string, +): { params: Record } | 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 = {}; + names.forEach((name, i) => { + params[name] = match[i + 1]!; + }); + return { params }; +} diff --git a/turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.test.ts.hbs new file mode 100644 index 0000000..66de9b8 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.test.ts.hbs @@ -0,0 +1,34 @@ +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 + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.ts.hbs b/turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.ts.hbs new file mode 100644 index 0000000..516fc08 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/in-memory-realtime-broadcaster.ts.hbs @@ -0,0 +1,28 @@ +import type { z } from "zod"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +type Listener = (payload: T) => Promise | void; + +export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster { + private readonly listeners = new Map[]>(); + + async broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + 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( + descriptor: RealtimeChannelDescriptor>, + listener: Listener, + ): void { + const arr = this.listeners.get(descriptor.name) ?? []; + arr.push(listener as Listener); + this.listeners.set(descriptor.name, arr); + } +} diff --git a/turbo/generators/templates/core-package/realtime/src/index.ts.hbs b/turbo/generators/templates/core-package/realtime/src/index.ts.hbs new file mode 100644 index 0000000..86f0f8b --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/index.ts.hbs @@ -0,0 +1,22 @@ +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"; diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-authenticator.interface.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-authenticator.interface.ts.hbs new file mode 100644 index 0000000..97a4ccb --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-authenticator.interface.ts.hbs @@ -0,0 +1,6 @@ +export interface IRealtimeAuthenticator { + authenticate(handshake: { + cookies: Record; + headers: Record; + }): Promise<{ userId: string; roles: string[] } | null>; +} diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-broadcaster.interface.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-broadcaster.interface.ts.hbs new file mode 100644 index 0000000..e7765a7 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-broadcaster.interface.ts.hbs @@ -0,0 +1,10 @@ +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( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise; +} diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-channel.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-channel.test.ts.hbs new file mode 100644 index 0000000..357e732 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-channel.test.ts.hbs @@ -0,0 +1,25 @@ +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}" }); + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-channel.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-channel.ts.hbs new file mode 100644 index 0000000..a0aef9b --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-channel.ts.hbs @@ -0,0 +1,28 @@ +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 = { + readonly name: TName; + readonly schema: TSchema; + readonly scope: ChannelScope; +}; + +export function defineRealtimeChannel( + name: TName, + schema: TSchema, + options: { scope: ChannelScope }, +): RealtimeChannelDescriptor { + return { name, schema, scope: options.scope }; +} diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.test.ts.hbs new file mode 100644 index 0000000..cf6c7a8 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.test.ts.hbs @@ -0,0 +1,91 @@ +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"); + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.ts.hbs new file mode 100644 index 0000000..cb8fef1 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-handler-registry.ts.hbs @@ -0,0 +1,40 @@ +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(entry: IInboundDescriptor>): void; + getInboundDescriptor(channelName: string): IInboundDescriptor | null; + list(): IInboundDescriptor[]; + /** Register an outbound-only channel so Gate 2 can authorize subscriptions to it. */ + registerChannel(descriptor: RealtimeChannelDescriptor): void; + listChannels(): RealtimeChannelDescriptor[]; +} + +export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry { + private readonly entries = new Map>(); + private readonly channels = new Map>(); + + register(entry: IInboundDescriptor>): void { + this.entries.set(entry.descriptor.name, entry as IInboundDescriptor); + // 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 | null { + return this.entries.get(channelName) ?? null; + } + + list(): IInboundDescriptor[] { + return Array.from(this.entries.values()); + } + + registerChannel(descriptor: RealtimeChannelDescriptor): void { + this.channels.set(descriptor.name, descriptor); + } + + listChannels(): RealtimeChannelDescriptor[] { + return Array.from(this.channels.values()); + } +} diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-handler.interface.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-handler.interface.ts.hbs new file mode 100644 index 0000000..d0fb09e --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-handler.interface.ts.hbs @@ -0,0 +1,14 @@ +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export type RealtimeContext = { + userId: string | null; + roles: string[]; +}; + +export type IRealtimeHandler = (input: T, ctx: RealtimeContext) => Promise; + +export type IInboundDescriptor = { + readonly descriptor: RealtimeChannelDescriptor; + readonly handler: IRealtimeHandler>; +}; diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-ping.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-ping.ts.hbs new file mode 100644 index 0000000..0203131 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-ping.ts.hbs @@ -0,0 +1,36 @@ +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; +export type PongPayload = z.infer; + +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> { + return { + descriptor: realtimePingChannel, + handler: async (input: PingPayload, ctx: RealtimeContext): Promise => { + await broadcaster.broadcast(realtimePongChannel, { + at: input.at, + echo: ctx.userId ?? "anonymous", + }); + }, + }; +} diff --git a/turbo/generators/templates/core-package/realtime/src/realtime-server.interface.ts.hbs b/turbo/generators/templates/core-package/realtime/src/realtime-server.interface.ts.hbs new file mode 100644 index 0000000..30cbbd8 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/realtime-server.interface.ts.hbs @@ -0,0 +1,16 @@ +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; + stop(): Promise; +} diff --git a/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.test.ts.hbs new file mode 100644 index 0000000..b34a3a1 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.test.ts.hbs @@ -0,0 +1,34 @@ +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(); + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.ts.hbs b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.ts.hbs new file mode 100644 index 0000000..5ebc729 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-broadcaster.ts.hbs @@ -0,0 +1,17 @@ +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( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + descriptor.schema.parse(payload); + this.io.to(channelRoom(descriptor.name)).emit(descriptor.name, payload); + } +} diff --git a/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.test.ts.hbs b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.test.ts.hbs new file mode 100644 index 0000000..062f1fc --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.test.ts.hbs @@ -0,0 +1,241 @@ +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; headers: Record }) => 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 }) => { + 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((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 { + const client = makeClient(port, cookie); + await new Promise((resolve, reject) => { + client.on("connect", () => resolve()); + client.on("connect_error", (err) => reject(err)); + }); + return client; +} + +// --------------------------------------------------------------------------- + +describe("SocketIORealtimeServer", () => { + let teardown: () => Promise; + + 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((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((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(); + }); +}); diff --git a/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.ts.hbs b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.ts.hbs new file mode 100644 index 0000000..33c91be --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/socket-io-realtime-server.ts.hbs @@ -0,0 +1,125 @@ +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; + +function parseCookies(header: string): Record { + const out: Record = {}; + 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 { + 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).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, + }); + 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 } | 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 = + 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 { + await new Promise((resolve) => this.io.close(() => resolve())); + } +} diff --git a/turbo/generators/templates/core-package/realtime/src/symbols.ts.hbs b/turbo/generators/templates/core-package/realtime/src/symbols.ts.hbs new file mode 100644 index 0000000..a5c1663 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/src/symbols.ts.hbs @@ -0,0 +1,6 @@ +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; diff --git a/turbo/generators/templates/core-package/realtime/tsconfig.json.hbs b/turbo/generators/templates/core-package/realtime/tsconfig.json.hbs new file mode 100644 index 0000000..a2a44ea --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/tsconfig.json.hbs @@ -0,0 +1,12 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "*.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/turbo/generators/templates/core-package/realtime/turbo.json.hbs b/turbo/generators/templates/core-package/realtime/turbo.json.hbs new file mode 100644 index 0000000..3b1522d --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/turbo.json.hbs @@ -0,0 +1,5 @@ +{ + "$schema": "https://turborepo.dev/schema.json", + "extends": ["//"], + "tags": ["core"] +} diff --git a/turbo/generators/templates/core-package/realtime/vitest.config.ts.hbs b/turbo/generators/templates/core-package/realtime/vitest.config.ts.hbs new file mode 100644 index 0000000..2ee07c1 --- /dev/null +++ b/turbo/generators/templates/core-package/realtime/vitest.config.ts.hbs @@ -0,0 +1,9 @@ +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") }, + }, +});