import { Decoder, getDecoderStateCallbacks, Schema, type SchemaCallbackProxy, } from '@colyseus/schema'; import type { PacketChannel } from './channels'; import type { P2PColyseusMode } from './engine'; import { createSignal } from './events'; import { createLoopbackPair } from './loopback'; import { type Envelope, P2P_CLOSE_CODES } from './protocol'; import { getRegisteredRoom, registeredRoomNames, registerLoopbackRoom } from './room-registry'; import { type RoomClass, UniversalRoomRuntime } from './runtime'; import { createWebSocketPacketChannel } from './websocket'; import { base64ToBytes } from './wire-bytes'; export { registerLoopbackRoom } from './room-registry'; let configuredP2P: | { mode: Extract; rooms?: Record | undefined; maxLocalClientsPerRoom?: number | undefined; } | undefined; const hostedP2PRoomIds = new Map(); const localP2PClientCounts = new Map(); export function configureP2PColyseusClient(options: { mode: Extract; rooms?: Record | undefined; maxLocalClientsPerRoom?: number | undefined; }): void { configuredP2P = options; publishP2PMode(options.mode.kind); hostedP2PRoomIds.clear(); localP2PClientCounts.clear(); for (const [roomName, roomClass] of Object.entries(options.rooms ?? {})) { registerLoopbackRoom(roomName, roomClass); } } export function resetP2PColyseusClient(): void { configuredP2P = undefined; hostedP2PRoomIds.clear(); localP2PClientCounts.clear(); publishP2PMode(''); } export class AbortError extends Error { constructor(message = 'aborted') { super(message); this.name = 'AbortError'; } } export class MatchMakeError extends Error { constructor( message: string, readonly code?: number, ) { super(message); this.name = 'MatchMakeError'; } } export class Auth { token: string | undefined; userData: UserData | undefined; constructor(readonly client?: Client) {} async login(options?: { token?: string; userData?: UserData }): Promise { this.token = options?.token; this.userData = options?.userData; return this.userData; } async logout(): Promise { this.token = undefined; this.userData = undefined; } } export interface AvailableRoom { clients: number; maxClients: number; metadata: unknown; name: string; roomId: string; } export class Client { static VERSION = 'p2p-colyseus-compat'; readonly auth = new Auth(this); constructor(readonly url: string) {} async create(roomName: string, options?: unknown): Promise { return this.joinOrCreate(roomName, options); } async join(roomName: string, options?: unknown): Promise { return this.joinOrCreate(roomName, options); } async joinById(roomId: string, options?: unknown): Promise { return this.joinOrCreate(roomId, options); } async reconnect(reconnectionToken: string): Promise { const [roomId, token] = reconnectionToken.split(':'); if (!roomId || !token) { throw new Error( 'Invalid reconnection token format.\nThe format should be roomId:reconnectionToken', ); } const room = await this.joinById(roomId, { reconnectionToken: token }); room.reconnectionToken = reconnectionToken; return room; } async consumeSeatReservation(response: { name: string; sessionId: string; roomId: string; reconnectionToken?: string; expiresAt?: number | string | Date; }): Promise { if (isExpiredReservation(response.expiresAt)) { throw new MatchMakeError('Seat reservation expired', P2P_CLOSE_CODES.unauthorized); } const room = await this.joinOrCreate(response.name, { sessionId: response.sessionId, reconnectionToken: response.reconnectionToken, }); room.roomId = response.roomId; room.sessionId = response.sessionId; room.reconnectionToken = response.reconnectionToken ?? `${response.roomId}:${response.sessionId}`; return room; } async getLatency(options: { pingCount?: number } = {}): Promise { if (this.url.startsWith('loopback://')) return 0; const pingCount = Math.max(1, options.pingCount ?? 1); const room = await this.joinOrCreate('__latency__').catch(() => undefined); if (!room) return Number.POSITIVE_INFINITY; const latencies: number[] = []; for (let i = 0; i < pingCount; i++) { latencies.push(await new Promise((resolve) => room.ping(resolve))); } await room.leave(); return latencies.reduce((sum, latency) => sum + latency, 0) / latencies.length; } async getAvailableRooms(roomName?: string): Promise { const roomNames = configuredP2P ? Object.keys(configuredP2P.rooms ?? {}) : registeredRoomNames(); return roomNames .filter((name) => roomName === undefined || name === roomName) .map((name) => ({ clients: localP2PClientCounts.get(name) ?? 0, maxClients: Number.POSITIVE_INFINITY, metadata: {}, name, roomId: hostedP2PRoomIds.get(name) ?? name, })); } async joinOrCreate(roomName: string, options?: unknown): Promise { if (configuredP2P) { reserveLocalP2PClient(roomName); const engine = await import('./engine'); if (configuredP2P.mode.kind === 'p2p-host') { const hostedRoomId = hostedP2PRoomIds.get(roomName); if (hostedRoomId) { const room = await engine.connectP2PJoinRoom( { ...configuredP2P.mode, kind: 'p2p-join', roomId: hostedRoomId }, { url: this.url, room: roomName, joinOptions: options as Record | undefined, }, ); releaseLocalP2PClientOnLeave(roomName, room); return room; } const room = await engine.connectP2PHostRoom(configuredP2P.mode, configuredP2P.rooms, { url: this.url, room: roomName, joinOptions: options as Record | undefined, }); hostedP2PRoomIds.set(roomName, room.roomId); publishHostedP2PRoomId(room.roomId); releaseLocalP2PClientOnLeave(roomName, room); return room; } const room = await engine.connectP2PJoinRoom(configuredP2P.mode, { url: this.url, room: roomName, joinOptions: options as Record | undefined, }); releaseLocalP2PClientOnLeave(roomName, room); return room; } if (this.url.startsWith('ws://') || this.url.startsWith('wss://')) { const socket = await openWebSocket(this.url); return connectRoomOverChannel( createWebSocketPacketChannel(`client-${randomId()}`, socket), roomName, options, ); } if (!this.url.startsWith('loopback://')) { throw new Error(`Unsupported @vgai/p2p-colyseus client URL: ${this.url}`); } const roomClass = getRegisteredRoom(roomName); if (!roomClass) throw new Error(`Loopback room not registered: ${roomName}`); // joinOrCreate semantics: the creating client's options reach onCreate. const runtime = new UniversalRoomRuntime(roomClass, roomName, options); const [hostChannel, clientChannel] = createLoopbackPair('client-1', 'host'); runtime.attach(hostChannel); return connectRoomOverChannel(clientChannel, roomName, options); } } function reserveLocalP2PClient(roomName: string): void { const max = configuredP2P?.maxLocalClientsPerRoom; if (max === undefined) { localP2PClientCounts.set(roomName, (localP2PClientCounts.get(roomName) ?? 0) + 1); return; } const current = localP2PClientCounts.get(roomName) ?? 0; if (current >= max) { throw new Error(`P2P local client limit reached for room: ${roomName}`); } localP2PClientCounts.set(roomName, current + 1); } function releaseLocalP2PClientOnLeave(roomName: string, room: CompatRoom): void { let released = false; const release = () => { if (released) return; released = true; localP2PClientCounts.set(roomName, Math.max(0, (localP2PClientCounts.get(roomName) ?? 1) - 1)); }; room.onLeave.add(release); const originalLeave = room.leave.bind(room); room.leave = async (consented = true) => { try { return await originalLeave(consented); } finally { release(); } }; } function publishHostedP2PRoomId(roomId: string): void { const maybeWindow = globalThis as unknown as { window?: { __VGAI_P2P_COLYSEUS_ROOM_ID__?: string }; __VGAI_P2P_COLYSEUS_ROOM_ID__?: string; }; maybeWindow.__VGAI_P2P_COLYSEUS_ROOM_ID__ = roomId; if (maybeWindow.window) maybeWindow.window.__VGAI_P2P_COLYSEUS_ROOM_ID__ = roomId; } function publishP2PMode(mode: string): void { const maybeWindow = globalThis as unknown as { window?: { __VGAI_P2P_COLYSEUS_MODE__?: string }; __VGAI_P2P_COLYSEUS_MODE__?: string; }; maybeWindow.__VGAI_P2P_COLYSEUS_MODE__ = mode; if (maybeWindow.window) maybeWindow.window.__VGAI_P2P_COLYSEUS_MODE__ = mode; } export const ColyseusSDK = Client; /** * The v4 Callbacks proxy over the client's REAL `@colyseus/schema` decoder — * identical to `@colyseus/sdk`'s `getStateCallbacks(room)` * (`$(state).players.onAdd(...)`, `$(item).listen(...)`, `$(item).onChange`, * `$(from).bindTo(...)`). The legacy string-keyed API is `Callbacks.get(room)`, * which the real `@colyseus/schema` `Callbacks` provides directly off * `room.serializer.decoder`. */ export function getStateCallbacks(room: CompatRoom): SchemaCallbackProxy { if (!room.decoder) { throw new Error( `getStateCallbacks: room "${room.name}" has no decoded @colyseus/schema state — ` + `register the room class via configureP2PColyseusClient({ rooms }).`, ); } return getDecoderStateCallbacks(room.decoder); } export function registerSerializer(): void {} /** * Build the client-side decoder root from the LOCALLY-REGISTERED room class * (shipped to the client via `configureP2PColyseusClient({ rooms })`). No * Reflection handshake — a fresh instance of the room's `Schema` state class is * all the real `Decoder` needs, and it is compatible with the host `Encoder` * because both use the same class through the same `@colyseus/schema`. */ function buildDecoder(roomName: string): Decoder | undefined { const RoomClass = getRegisteredRoom(roomName); if (!RoomClass) return undefined; const probe = new RoomClass() as { state?: unknown; onCreate?: (options?: unknown) => void }; let state = probe.state; if (!(state instanceof Schema)) { // Rooms that assign state in onCreate rather than a field initializer. try { probe.onCreate?.(); } catch { /* best-effort probe — the state class is all we need */ } state = probe.state; } try { (probe as { _disposeRuntime?: () => void })._disposeRuntime?.(); } catch { /* the probe never attached a runtime; nothing to clean up */ } if (!(state instanceof Schema)) return undefined; return new Decoder(new (state.constructor as new () => Schema)()); } export class CompatRoom { /** * The client's real `@colyseus/schema` decoder — undefined for stateless * rooms. Rebuilt from a fresh state root on a FULL snapshot (resync) so * host-side removals since the last sync cannot linger as ghost entries. */ decoder: Decoder | undefined; /** Exposed so the real `Callbacks.get(room)` / `getStateCallbacks(room)` resolve the decoder. */ readonly serializer: { decoder: Decoder | undefined }; readonly onLeave = createSignal<(code?: number, reason?: string) => void>(); readonly onError = createSignal<(code: number, message?: string) => void>(); readonly onReconnect = createSignal<() => void>(); readonly onDrop = createSignal<(code?: number, reason?: string) => void>(); readonly onStateChange = createSignal<(state: Record) => void>(); roomId = ''; sessionId = ''; reconnectionToken = ''; state: Record = {}; private readonly messages = new Map void>>(); private readonly wildcardMessages = new Set<(type: string, payload: unknown) => void>(); private readonly pendingPings = new Map void>(); private clock = 0; constructor( readonly name: string, private readonly channel: PacketChannel, ) { this.decoder = buildDecoder(name); this.serializer = { decoder: this.decoder }; if (this.decoder) this.state = this.decoder.state as unknown as Record; this.channel.onEnvelope((envelope) => this.handleEnvelope(envelope)); this.channel.onClose((reason) => { this.onDrop.emit(undefined, reason); this.onLeave.emit(undefined, reason); }); } async join(options?: unknown): Promise { const requestId = crypto.randomUUID?.() ?? `join-${Date.now()}`; const joined = new Promise((resolve, reject) => { // JOIN-PHASE GUARD (H4): while this join is pending, a binary frame or // an early channel close is a live signal the peer does not speak this // package's compat wire protocol at all — most concretely, a compat // client pointed at a real Colyseus server (raw `ws://`), which frames // its own binary schema-diff protocol and never sends `join-ok`/ // `join-error`. Without this, that mismatch hangs `join()` forever. All // three listeners are torn down the moment the join settles one way or // another, so none of them can observe — or reject on — anything that // happens in a normal session after a successful join, including its // normal eventual close. const unsubscribers: Array<() => void> = []; const teardown = () => { for (const off of unsubscribers) off(); }; unsubscribers.push( this.channel.onEnvelope((envelope) => { if (envelope.kind === 'join-ok' && envelope.requestId === requestId) { teardown(); this.roomId = this.name; this.sessionId = envelope.sessionId; this.reconnectionToken = `${this.roomId}:${this.sessionId}`; this.clock = envelope.clock; this.applyEncodedState(envelope.state); resolve(); } else if (envelope.kind === 'join-error' && envelope.requestId === requestId) { teardown(); reject(new Error(envelope.message)); } }), ); if (this.channel.onProtocolError) { unsubscribers.push( this.channel.onProtocolError((reason) => { teardown(); reject(mixedEndpointError(reason)); }), ); } unsubscribers.push( this.channel.onClose((reason) => { teardown(); reject(mixedEndpointError(reason ?? 'the connection closed')); }), ); }); this.channel.send({ kind: 'join', requestId, room: this.name, options }); await joined; } send(type: string, payload?: unknown): void { this.channel.send({ kind: 'message', type, payload }); } sendUnreliable(type: string, payload?: T): void { // Route over the channel's unreliable (unordered, no-retransmit) transport // when it has one — only the WebRTC channel does. On ordered transports // (loopback, websocket, relay, real Colyseus) there is no unreliable // channel, and degrading to the reliable `send` path is correct: the // message still arrives, just ordered/reliable. if (this.channel.sendUnreliable) { this.channel.sendUnreliable({ kind: 'message', type, payload }); return; } this.send(type, payload); } sendBytes(type: string | number, bytes: Uint8Array): void { this.send(String(type), bytes); } ping(callback: (ms: number) => void): void { const t = Date.now(); this.pendingPings.set(t, callback); this.channel.send({ kind: 'ping', t }); } onMessage(type: '*', handler: (type: string, payload: T) => void): () => void; onMessage(type: string, handler: (payload: T) => void): () => void; onMessage( type: string, handler: ((payload: T) => void) | ((type: string, payload: T) => void), ): () => void { if (type === '*') { const wrapped = handler as (type: string, payload: unknown) => void; this.wildcardMessages.add(wrapped); return () => this.wildcardMessages.delete(wrapped); } const handlers = this.messages.get(type) ?? new Set<(payload: unknown) => void>(); const wrapped = handler as (payload: unknown) => void; handlers.add(wrapped); this.messages.set(type, handlers); return () => handlers.delete(wrapped); } async leave(_consented = true): Promise { const left = new Promise((resolve) => { this.onLeave.once((code) => resolve(code ?? 1000)); }); this.channel.send({ kind: 'leave', code: 1000 }); return left; } removeAllListeners(): void { this.messages.clear(); this.wildcardMessages.clear(); this.onLeave.clear(); this.onDrop.clear(); this.onError.clear(); this.onReconnect.clear(); this.onStateChange.clear(); } // Dispatch-only: each branch's own logic (and complexity) lives in the // handler it calls, so this switch stays a flat, cheap-to-read routing // table no matter how gnarly an individual envelope kind's handling gets. private handleEnvelope(envelope: Envelope): void { switch (envelope.kind) { case 'state-snapshot': this.handleStateSnapshot(envelope); return; case 'state-patch': this.handleStatePatch(envelope); return; case 'message': this.handleMessageEnvelope(envelope); return; case 'leave': this.onLeave.emit(envelope.code, envelope.reason); queueMicrotask(() => this.channel.close('left')); return; case 'ping': this.channel.send({ kind: 'pong', t: envelope.t }); return; case 'pong': this.resolvePendingPing(envelope.t); return; default: // 'join' / 'join-ok' / 'join-error' / 'state-resync' are handled by // the join-phase listeners installed in join(); once a session is // established there is nothing for the steady-state handler to do. return; } } /** Decode base64-carried `@colyseus/schema` bytes into the live decoder state. */ private applyEncodedState(base64: string): void { if (!this.decoder) return; this.decoder.decode(base64ToBytes(base64)); this.state = this.decoder.state as unknown as Record; } /** * Replace the decoder with a fresh root before applying a FULL snapshot. A * full snapshot (`encodeAll`) emits only ADDs for current entries and never * removals; decoding it onto the already-populated decoder would leave stale * "ghost" entries for anything the host dropped since the last sync. A fresh * root — built from the same locally-registered room class the join decoder * used — reflects EXACTLY the snapshot. * * Consequence: this swaps the decoder the `$`/Callbacks proxy is bound to, so * persistent `$(state)....onAdd/onChange` handlers registered before a resync * stop firing afterward and would need re-registration. Accepted because a * ghost entry is data corruption (correctness outranks callback persistence) * and a full-snapshot resync is rare (state patches ride the ordered-reliable * channel — gaps essentially only follow real loss or a setState-replace). If * that ever bites a real game, the parity-correct fix is to reconcile the live * decoder's collections in place instead of swapping the root. */ private rebuildDecoder(): void { const fresh = buildDecoder(this.name); if (!fresh) return; this.decoder = fresh; this.serializer.decoder = fresh; this.state = fresh.state as unknown as Record; } private handleStateSnapshot(envelope: Extract): void { if (envelope.clock <= this.clock) return; this.clock = envelope.clock; // FULL snapshot only (resync / setState-replace) — clear ghosts, then decode. this.rebuildDecoder(); this.applyEncodedState(envelope.state); this.onStateChange.emit(this.state); } private handleStatePatch(envelope: Extract): void { if (envelope.clock <= this.clock) return; if (envelope.clock > this.clock + 1) { this.channel.send({ kind: 'state-resync', clock: this.clock }); return; } this.clock = envelope.clock; this.applyEncodedState(envelope.patch); this.onStateChange.emit(this.state); } private handleMessageEnvelope(envelope: Extract): void { if (envelope.type === 'error' && isErrorPayload(envelope.payload)) { this.onError.emit(envelope.payload.code, envelope.payload.message); return; } for (const handler of this.messages.get(envelope.type) ?? []) handler(envelope.payload); for (const handler of this.wildcardMessages) handler(envelope.type, envelope.payload); } private resolvePendingPing(t: number): void { const callback = this.pendingPings.get(t); if (callback) { this.pendingPings.delete(t); callback(Math.max(0, Date.now() - t)); } } } function isExpiredReservation(expiresAt: number | string | Date | undefined): boolean { if (expiresAt === undefined) return false; const timestamp = expiresAt instanceof Date ? expiresAt.getTime() : typeof expiresAt === 'string' ? Date.parse(expiresAt) : expiresAt; return Number.isFinite(timestamp) && timestamp <= Date.now(); } function isErrorPayload(value: unknown): value is { code: number; message?: string } { return ( typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'number' ); } /** * The H4 "clear error" for a mixed real-Colyseus/compat endpoint pairing * caught by the join-phase guard in `CompatRoom.join()`. `reason` names the * live signal that tripped the guard (a binary frame, or the close reason). */ function mixedEndpointError(reason: string): Error { return new Error( `vgai P2P compat client received a non-text/binary frame (or the connection closed) ` + `before joining — this endpoint looks like a real Colyseus server. Signal: ${reason}. ` + `@vgai/p2p-colyseus and real Colyseus are not wire-compatible: use real @colyseus/sdk ` + `for a real server, or the P2P host for a P2P deployment (selected by the VGAI_P2P ` + `build alias). Supported: real-client<->real-server, compat-client<->compat-host; ` + `mixed pairings are unsupported.`, ); } export async function connectRoomOverChannel( channel: PacketChannel, roomName: string, options?: unknown, ): Promise { const room = new CompatRoom(roomName, channel); await room.join(options); return room; } async function openWebSocket(url: string): Promise { if (typeof WebSocket === 'undefined') { throw new Error('WebSocket is not available in this JavaScript runtime'); } const socket = new WebSocket(url); if (socket.readyState === WebSocket.OPEN) return socket; await new Promise((resolve, reject) => { socket.addEventListener('open', () => resolve(), { once: true }); socket.addEventListener( 'error', () => reject(new Error(`WebSocket connection failed: ${url}`)), { once: true, }, ); }); return socket; } function randomId(): string { return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2); }