feat(core-realtime): SocketIORealtimeServer (4 lifecycle gates)
This commit is contained in:
101
packages/core-realtime/src/socket-io-realtime-server.ts
Normal file
101
packages/core-realtime/src/socket-io-realtime-server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import type { Server as IOServer, Socket } from "socket.io";
|
||||
import { authorize } from "./authorize";
|
||||
import { matchChannelTemplate } from "./channel-template";
|
||||
import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
|
||||
|
||||
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 httpServer: HttpServer;
|
||||
private readonly io: IOServer;
|
||||
private readonly opts: IRealtimeServerOptions;
|
||||
|
||||
constructor(opts: IRealtimeServerOptions) {
|
||||
this.opts = opts;
|
||||
this.httpServer = opts.httpServer;
|
||||
this.io = opts.io;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const { authenticator, registry } = this.opts;
|
||||
|
||||
// Gate 1: connect — read cookie, authenticate, attach user.
|
||||
this.io.use(async (socket, next) => {
|
||||
const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
|
||||
socket.data.user = await authenticator.authenticate({
|
||||
cookies,
|
||||
headers: socket.handshake.headers as Record<string, string>,
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
this.io.on("connection", (socket: Socket) => {
|
||||
// Gate 2: subscribe.
|
||||
socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
|
||||
// Find a registered descriptor whose name (or template) matches requestedName.
|
||||
let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null;
|
||||
for (const entry of registry.list()) {
|
||||
const m = matchChannelTemplate(entry.descriptor.name, requestedName);
|
||||
if (m) {
|
||||
matched = { descriptor: entry.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(`ch:${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;
|
||||
}
|
||||
const allowed = await authorize(entry.descriptor, {}, 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> {
|
||||
this.io.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user