feat(generators): capture core-realtime as verbatim template files
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const CHANNEL_ROOM_PREFIX = "ch:";
|
||||
export const channelRoom = (channelName: string): string =>
|
||||
`${CHANNEL_ROOM_PREFIX}${channelName}`;
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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 };
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface IRealtimeAuthenticator {
|
||||
authenticate(handshake: {
|
||||
cookies: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<{ userId: string; roles: string[] } | null>;
|
||||
}
|
||||
@@ -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<T>(
|
||||
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -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}" });
|
||||
});
|
||||
});
|
||||
@@ -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<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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
@@ -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<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>>;
|
||||
};
|
||||
@@ -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<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",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
@@ -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<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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "*.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://turborepo.dev/schema.json",
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
@@ -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") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user