410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
/**
|
|
* Realtime rooms.
|
|
*
|
|
* A file in `app/realtime/` exports `default defineRoom({ onConnect, onMessage,
|
|
* onLeave })` and is served at `ws://host/realtime/<name>`. The framework's
|
|
* client runtime (`/__wrnexus/realtime.js`) handles the browser side, so pages
|
|
* ship NO hand-written WebSocket code.
|
|
*
|
|
* Handlers get a `RoomClient` with everything you need:
|
|
* client.send(msg) → this connection
|
|
* client.broadcast(msg) → everyone else in the room
|
|
* client.room.broadcast(msg) → everyone (incl. sender)
|
|
* client.to(id | ids).send(msg) → specific connection(s)
|
|
* client.toUser(u | users).send() → a user / selected users (all their tabs)
|
|
* client.user = "u1" → identify a connection for targeting
|
|
* client.data / client.room.state → per-connection / shared room state
|
|
*
|
|
* The dynamic route `app/realtime/[room].ts` gives one handler many independent
|
|
* rooms — `/realtime/lobby` and `/realtime/game-7` are separate room instances.
|
|
*/
|
|
|
|
// --- Low-level socket the registry drives (a subset of Bun's ServerWebSocket) ---
|
|
|
|
export interface RawSocket {
|
|
send(data: string): unknown;
|
|
close(code?: number, reason?: string): void;
|
|
}
|
|
|
|
// --- Legacy raw handler (still supported alongside defineRoom) ---
|
|
|
|
export interface RealtimeSocket<Data = unknown> {
|
|
readonly data: Data;
|
|
send(data: string | Uint8Array): number;
|
|
subscribe(topic: string): void;
|
|
unsubscribe(topic: string): void;
|
|
publish(topic: string, data: string | Uint8Array): number;
|
|
isSubscribed(topic: string): boolean;
|
|
close(code?: number, reason?: string): void;
|
|
}
|
|
|
|
export interface RealtimeHandler<Data = unknown> {
|
|
open?(ws: RealtimeSocket<Data>): void | Promise<void>;
|
|
message?(ws: RealtimeSocket<Data>, message: string | Uint8Array): void | Promise<void>;
|
|
close?(ws: RealtimeSocket<Data>, code?: number, reason?: string): void | Promise<void>;
|
|
drain?(ws: RealtimeSocket<Data>): void | Promise<void>;
|
|
}
|
|
|
|
// --- Room API ---
|
|
|
|
export interface Target {
|
|
/** Send a message (objects are JSON-serialized). */
|
|
send(message: unknown): void;
|
|
}
|
|
|
|
export interface Room<TData = Record<string, unknown>> {
|
|
readonly name: string;
|
|
/** Shared, in-memory room state (lives while ≥1 client is connected). */
|
|
readonly state: Record<string, unknown>;
|
|
/** All connected clients. */
|
|
clients(): RoomClient<TData>[];
|
|
/** Number of connected clients. */
|
|
count(): number;
|
|
/** Send to everyone in the room, including the sender. */
|
|
broadcast(message: unknown): void;
|
|
/** Target specific connection id(s). */
|
|
to(id: string | string[]): Target;
|
|
/** Target a user / users by identity (reaches all their connections). */
|
|
toUser(user: string | string[]): Target;
|
|
}
|
|
|
|
export interface RoomClient<TData = Record<string, unknown>> {
|
|
/** Unique per connection (a tab). */
|
|
readonly id: string;
|
|
/** App identity for targeting; assign it in `onConnect`. */
|
|
user: string | undefined;
|
|
/** Query params from the connection URL. */
|
|
readonly query: Record<string, string>;
|
|
/** Per-connection scratch state. */
|
|
readonly data: TData;
|
|
readonly room: Room<TData>;
|
|
/** Send to THIS connection. */
|
|
send(message: unknown): void;
|
|
/** Send to everyone else in the room. */
|
|
broadcast(message: unknown): void;
|
|
/** Target specific connection id(s). */
|
|
to(id: string | string[]): Target;
|
|
/** Target a user / users by identity. */
|
|
toUser(user: string | string[]): Target;
|
|
/** Close this connection. */
|
|
close(code?: number, reason?: string): void;
|
|
}
|
|
|
|
/** Info available when authorizing a connection, before it is accepted. */
|
|
export interface RoomAuthInfo {
|
|
/** Authenticated session user id, or `?user=` — undefined when anonymous. */
|
|
user?: string;
|
|
/** Connection URL query params. */
|
|
query: Record<string, string>;
|
|
/** The upgrade request's headers (cookies, etc.). */
|
|
headers: Headers;
|
|
}
|
|
|
|
export interface RoomHandlers<TData = Record<string, unknown>> {
|
|
/**
|
|
* Gate the connection BEFORE it is accepted. Return false to reject the
|
|
* upgrade with 403 (e.g. `authorize: (info) => !!info.user` to require auth).
|
|
*/
|
|
authorize?(info: RoomAuthInfo): boolean | Promise<boolean>;
|
|
/** A client connected (a new tab joined the room). */
|
|
onConnect?(client: RoomClient<TData>): void | Promise<void>;
|
|
/** A message arrived (JSON is parsed; non-JSON arrives as a string). */
|
|
onMessage?(client: RoomClient<TData>, message: any): void | Promise<void>;
|
|
/** A client disconnected. */
|
|
onLeave?(client: RoomClient<TData>): void | Promise<void>;
|
|
}
|
|
|
|
export interface RoomDefinition<TData = Record<string, unknown>> {
|
|
readonly __wrnexusRoom: true;
|
|
readonly handlers: RoomHandlers<TData>;
|
|
}
|
|
|
|
/** Define a realtime room. Export the result as the `default` of a realtime file. */
|
|
export function defineRoom<TData = Record<string, unknown>>(
|
|
handlers: RoomHandlers<TData>,
|
|
): RoomDefinition<TData> {
|
|
return { __wrnexusRoom: true, handlers };
|
|
}
|
|
|
|
export function isRoomDefinition(value: unknown): value is RoomDefinition {
|
|
return (
|
|
!!value &&
|
|
typeof value === "object" &&
|
|
(value as { __wrnexusRoom?: unknown }).__wrnexusRoom === true
|
|
);
|
|
}
|
|
|
|
// --- Registry (server-side connection manager) ---
|
|
|
|
interface Conn {
|
|
id: string;
|
|
user?: string;
|
|
data: Record<string, unknown>;
|
|
query: Record<string, string>;
|
|
socket: RawSocket;
|
|
roomName: string;
|
|
client: RoomClient;
|
|
}
|
|
|
|
interface RoomImpl {
|
|
name: string;
|
|
state: Record<string, unknown>;
|
|
def: RoomDefinition;
|
|
conns: Map<string, Conn>;
|
|
users: Map<string, Set<string>>; // user identity → connection ids
|
|
}
|
|
|
|
export interface RealtimeConnectMeta {
|
|
room: string;
|
|
def: RoomDefinition;
|
|
query?: Record<string, string>;
|
|
user?: string;
|
|
}
|
|
|
|
/** One cross-instance message: a room broadcast, or a targeted user send. */
|
|
export interface RealtimeEnvelope {
|
|
room: string;
|
|
/** If set, deliver only to these user identities; otherwise the whole room. */
|
|
users?: string[];
|
|
message: unknown;
|
|
}
|
|
|
|
/**
|
|
* A pub/sub bridge for horizontal scaling. Wire the registry to a shared bus
|
|
* (Redis pub/sub, NATS, …): local broadcasts/`toUser` sends are published to
|
|
* peers, and messages received from peers are delivered via `registry.deliver`.
|
|
* Connection-targeted sends (`send`, `to(id)`) stay local (ids are per-process).
|
|
*/
|
|
export interface RealtimeBridge {
|
|
publish(envelope: RealtimeEnvelope): void;
|
|
}
|
|
|
|
export interface RealtimeRegistry {
|
|
open(socket: RawSocket, meta: RealtimeConnectMeta): void | Promise<void>;
|
|
message(socket: RawSocket, raw: string | Uint8Array): void | Promise<void>;
|
|
close(socket: RawSocket): void | Promise<void>;
|
|
/** Attach a cross-instance bridge (call once at startup). */
|
|
setBridge(bridge: RealtimeBridge): void;
|
|
/** Deliver an envelope received from a peer to LOCAL connections only. */
|
|
deliver(envelope: RealtimeEnvelope): void;
|
|
/** Number of live connections (across all rooms) — for tests/metrics. */
|
|
size(): number;
|
|
}
|
|
|
|
function serialize(message: unknown): string {
|
|
return typeof message === "string" ? message : JSON.stringify(message);
|
|
}
|
|
|
|
/** Create the registry that maps sockets ↔ rooms and drives room handlers. */
|
|
export function createRealtimeRegistry(): RealtimeRegistry {
|
|
const rooms = new Map<string, RoomImpl>();
|
|
const bySocket = new Map<RawSocket, Conn>();
|
|
let bridge: RealtimeBridge | null = null;
|
|
let applyingRemote = false; // true while delivering a peer envelope (no re-publish)
|
|
|
|
const publish = (envelope: RealtimeEnvelope): void => {
|
|
if (bridge && !applyingRemote) bridge.publish(envelope);
|
|
};
|
|
|
|
const send = (conn: Conn | undefined, payload: string): void => {
|
|
if (!conn) return;
|
|
try {
|
|
conn.socket.send(payload);
|
|
} catch {
|
|
/* socket already gone */
|
|
}
|
|
};
|
|
|
|
const reindexUser = (room: RoomImpl, conn: Conn, next: string | undefined): void => {
|
|
if (conn.user === next) return;
|
|
if (conn.user) {
|
|
const set = room.users.get(conn.user);
|
|
if (set) {
|
|
set.delete(conn.id);
|
|
if (!set.size) room.users.delete(conn.user);
|
|
}
|
|
}
|
|
conn.user = next;
|
|
if (next) {
|
|
let set = room.users.get(next);
|
|
if (!set) room.users.set(next, (set = new Set()));
|
|
set.add(conn.id);
|
|
}
|
|
};
|
|
|
|
const idsForUsers = (room: RoomImpl, user: string | string[]): string[] => {
|
|
const out: string[] = [];
|
|
for (const u of Array.isArray(user) ? user : [user]) {
|
|
const set = room.users.get(u);
|
|
if (set) out.push(...set);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const makeRoomApi = (room: RoomImpl): Room => ({
|
|
name: room.name,
|
|
state: room.state,
|
|
clients: () => Array.from(room.conns.values(), (c) => c.client),
|
|
count: () => room.conns.size,
|
|
broadcast: (message) => {
|
|
const payload = serialize(message);
|
|
for (const c of room.conns.values()) send(c, payload);
|
|
publish({ room: room.name, message });
|
|
},
|
|
to: (id) => ({
|
|
send: (message) => {
|
|
// Connection-targeted: local only (ids are per-process).
|
|
const payload = serialize(message);
|
|
for (const cid of Array.isArray(id) ? id : [id]) send(room.conns.get(cid), payload);
|
|
},
|
|
}),
|
|
toUser: (user) => ({
|
|
send: (message) => {
|
|
const payload = serialize(message);
|
|
for (const cid of idsForUsers(room, user)) send(room.conns.get(cid), payload);
|
|
publish({ room: room.name, users: Array.isArray(user) ? user : [user], message });
|
|
},
|
|
}),
|
|
});
|
|
|
|
const makeClientApi = (room: RoomImpl, conn: Conn): RoomClient => {
|
|
const roomApi = makeRoomApi(room);
|
|
return {
|
|
id: conn.id,
|
|
get user() {
|
|
return conn.user;
|
|
},
|
|
set user(value: string | undefined) {
|
|
reindexUser(room, conn, value);
|
|
},
|
|
query: conn.query,
|
|
data: conn.data,
|
|
room: roomApi,
|
|
send: (message) => send(conn, serialize(message)),
|
|
broadcast: (message) => {
|
|
const payload = serialize(message);
|
|
for (const c of room.conns.values()) if (c.id !== conn.id) send(c, payload);
|
|
// Peers deliver to all their conns (all "others" relative to this one).
|
|
publish({ room: room.name, message });
|
|
},
|
|
to: roomApi.to,
|
|
toUser: roomApi.toUser,
|
|
close: (code, reason) => conn.socket.close(code, reason),
|
|
};
|
|
};
|
|
|
|
return {
|
|
async open(socket, meta) {
|
|
let room = rooms.get(meta.room);
|
|
if (!room) {
|
|
room = { name: meta.room, state: {}, def: meta.def, conns: new Map(), users: new Map() };
|
|
rooms.set(meta.room, room);
|
|
}
|
|
const conn: Conn = {
|
|
id: randomId(),
|
|
data: {},
|
|
query: meta.query ?? {},
|
|
socket,
|
|
roomName: meta.room,
|
|
client: null as unknown as RoomClient,
|
|
};
|
|
conn.client = makeClientApi(room, conn);
|
|
room.conns.set(conn.id, conn);
|
|
bySocket.set(socket, conn);
|
|
if (meta.user) reindexUser(room, conn, meta.user);
|
|
await room.def.handlers.onConnect?.(conn.client);
|
|
},
|
|
|
|
async message(socket, raw) {
|
|
const conn = bySocket.get(socket);
|
|
if (!conn) return;
|
|
const room = rooms.get(conn.roomName);
|
|
if (!room) return;
|
|
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
|
let message: unknown;
|
|
try {
|
|
message = JSON.parse(text);
|
|
} catch {
|
|
message = text;
|
|
}
|
|
await room.def.handlers.onMessage?.(conn.client, message);
|
|
},
|
|
|
|
async close(socket) {
|
|
const conn = bySocket.get(socket);
|
|
if (!conn) return;
|
|
bySocket.delete(socket);
|
|
const room = rooms.get(conn.roomName);
|
|
if (!room) return;
|
|
try {
|
|
await room.def.handlers.onLeave?.(conn.client);
|
|
} finally {
|
|
room.conns.delete(conn.id);
|
|
reindexUser(room, conn, undefined);
|
|
if (room.conns.size === 0) rooms.delete(room.name);
|
|
}
|
|
},
|
|
|
|
setBridge(b) {
|
|
bridge = b;
|
|
},
|
|
|
|
deliver(envelope) {
|
|
const room = rooms.get(envelope.room);
|
|
if (!room) return;
|
|
applyingRemote = true; // suppress re-publishing what we received
|
|
try {
|
|
const payload = serialize(envelope.message);
|
|
if (envelope.users) {
|
|
for (const cid of idsForUsers(room, envelope.users)) send(room.conns.get(cid), payload);
|
|
} else {
|
|
for (const c of room.conns.values()) send(c, payload);
|
|
}
|
|
} finally {
|
|
applyingRemote = false;
|
|
}
|
|
},
|
|
|
|
size: () => bySocket.size,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A minimal pub/sub bus (structurally satisfied by `@wrnexus/pubsub`). Used to
|
|
* bridge realtime broadcasts across processes without a hard dependency.
|
|
*/
|
|
export interface RealtimeBus {
|
|
publish(topic: string, message: unknown): void | Promise<void>;
|
|
subscribe(topic: string, handler: (message: unknown, topic: string) => void): () => void;
|
|
}
|
|
|
|
/**
|
|
* Bridge a realtime registry across processes/instances via a pub/sub bus (use
|
|
* the Redis driver so it crosses machines). After this, `client.room.broadcast`
|
|
* and `client.toUser(...)` reach connected clients on **every** app process/
|
|
* instance subscribed to the same bus — the foundation for realtime that works
|
|
* with multiple running apps behind the gateway. Connection-targeted sends
|
|
* (`send`, `to(id)`) stay local. Returns an unsubscribe function.
|
|
*
|
|
* import { createRealtimeRegistry, bridgeRealtime } from "@wrnexus/core";
|
|
* import { createPubSub } from "@wrnexus/pubsub";
|
|
* import { redisDriver } from "@wrnexus/pubsub/redis";
|
|
* bridgeRealtime(registry, createPubSub(redisDriver(process.env.REDIS_URL)));
|
|
*/
|
|
export function bridgeRealtime(
|
|
registry: RealtimeRegistry,
|
|
bus: RealtimeBus,
|
|
topic = "wrnexus:realtime",
|
|
): () => void {
|
|
registry.setBridge({ publish: (envelope) => void bus.publish(topic, envelope) });
|
|
return bus.subscribe(topic, (message) => registry.deliver(message as RealtimeEnvelope));
|
|
}
|
|
|
|
function randomId(): string {
|
|
const bytes = new Uint8Array(12);
|
|
crypto.getRandomValues(bytes);
|
|
let out = "";
|
|
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
return out;
|
|
}
|