import { Callbacks } from './callbacks'; import { Client, type CompatRoom, connectRoomOverChannel, registerLoopbackRoom } from './client'; import type { SignalEnvelope } from './cloudflare/protocol'; import { createLoopbackPair } from './loopback'; import type { Envelope } from './protocol'; import { type RoomClass, UniversalRoomRuntime } from './runtime'; import { WEBRTC_UNRELIABLE_CHANNEL_LABEL, WebRTCDataChannelPacketChannel } from './webrtc'; const HOST_LOSS_CLOSE_DELAY_MS = 1_000; export type P2PColyseusMode = | { kind: 'colyseus'; url: string } | { kind: 'universal-server'; url: string } | { kind: 'p2p-host'; signalingUrl: string; iceServers: RTCIceServer[]; forceRelay?: boolean | undefined; heartbeatIntervalMs?: number | undefined; accessToken?: string | undefined; } | { kind: 'p2p-join'; signalingUrl: string; roomId?: string | undefined; iceServers: RTCIceServer[]; forceRelay?: boolean | undefined; directTimeoutMs?: number | undefined; accessToken?: string | undefined; } | { kind: 'loopback' }; export interface P2PColyseusBackendOptions { mode: P2PColyseusMode; rooms?: Record | undefined; } export interface EngineNetConnectOptions { url: string; room: string; joinOptions?: Record | undefined; } export interface EngineNetTransport { readonly sessionId: string; readonly roomId?: string | undefined; readonly replication: EngineStateReplication; send(type: string, payload?: unknown): void; onMessage(type: string, handler: (payload: T) => void): () => void; onLeave(handler: (code?: number) => void): () => void; leave(): void; } export interface EngineStateReplication { onAdd = Record>( collection: string, handler: (item: T, key: string) => void, ): () => void; onRemove = Record>( collection: string, handler: (item: T, key: string) => void, ): () => void; onChange(item: Record, handler: () => void): () => void; onStateChange = Record>( handler: (state: T) => void, ): () => void; } export interface EngineNetBackend { connect(opts: EngineNetConnectOptions): Promise; } export function createP2PColyseusBackend(options: P2PColyseusBackendOptions): EngineNetBackend { for (const [roomName, roomClass] of Object.entries(options.rooms ?? {})) { registerLoopbackRoom(roomName, roomClass); } return { async connect(_opts: EngineNetConnectOptions): Promise { if (options.mode.kind === 'colyseus') { return connectRealColyseus(options.mode.url, _opts); } if (options.mode.kind === 'p2p-host') { const room = await connectP2PHostRoom(options.mode, options.rooms, _opts); return adaptCompatRoom(room, room.roomId); } if (options.mode.kind === 'p2p-join') { const room = await connectP2PJoinRoom(options.mode, _opts); return adaptCompatRoom(room, room.roomId); } const client = new Client( options.mode.kind === 'universal-server' ? options.mode.url : 'loopback://engine', ); const room = await client.joinOrCreate(_opts.room, _opts.joinOptions); return adaptCompatRoom(room); }, }; } export async function connectP2PHostRoom( mode: Extract, rooms: Record | undefined, opts: EngineNetConnectOptions, ): Promise { const roomClass = rooms?.[opts.room]; if (!roomClass) throw new Error(`P2P host room not registered: ${opts.room}`); const hostSignalId = `host-${randomId()}`; const registered = await signal(mode.signalingUrl, { kind: 'host-register', roomName: opts.room, from: hostSignalId, accessToken: mode.accessToken, }); if (registered.kind !== 'host-registered') { throw new Error(`P2P host registration failed: ${JSON.stringify(registered)}`); } // Real Colyseus passes the creating client's join options to onCreate // (joinOrCreate semantics) — the hosted room must see them too. const runtime = new UniversalRoomRuntime(roomClass, opts.room, opts.joinOptions); const [hostLoopback, localLoopback] = createLoopbackPair('host-local', 'host-runtime'); runtime.attach(hostLoopback); const localRoom = await connectRoomOverChannel(localLoopback, opts.room, opts.joinOptions); localRoom.roomId = registered.roomId; localRoom.reconnectionToken = `${localRoom.roomId}:${localRoom.sessionId}`; const stop = startHostSignalLoop({ signalingUrl: mode.signalingUrl, roomId: registered.roomId, hostSignalId, runtime, iceServers: mode.iceServers, forceRelay: mode.forceRelay === true, heartbeatIntervalMs: mode.heartbeatIntervalMs, accessToken: mode.accessToken, }); const originalLeave = localRoom.leave.bind(localRoom); localRoom.leave = async (consented = true) => { stop(); return originalLeave(consented); }; return localRoom; } export async function connectP2PJoinRoom( mode: Extract, opts: EngineNetConnectOptions, ): Promise { const clientSignalId = `client-${randomId()}`; const joined = await signal(mode.signalingUrl, { kind: 'join-request', roomName: opts.room, roomId: mode.roomId, from: clientSignalId, options: opts.joinOptions, accessToken: mode.accessToken, }); if (joined.kind !== 'join-routed') { throw new Error(`P2P join failed: ${JSON.stringify(joined)}`); } if (mode.forceRelay === true) { return connectP2PJoinRelayRoom(mode, joined, clientSignalId, opts); } const peer = new RTCPeerConnection({ iceServers: mode.iceServers }); const dataChannel = peer.createDataChannel('p2p-colyseus'); // Second channel for `sendUnreliable`: unordered, no retransmits. Created // alongside the reliable one so both are negotiated in the single // offer/answer round-trip; we only WAIT on the reliable channel to open // (below), and `WebRTCDataChannelPacketChannel` falls back to it if this one // is not open yet. const unreliableChannel = peer.createDataChannel(WEBRTC_UNRELIABLE_CHANNEL_LABEL, { ordered: false, maxRetransmits: 0, }); const pendingCandidates: RTCIceCandidateInit[] = []; peer.onicecandidate = (event) => { if (!event.candidate) return; void signal(mode.signalingUrl, { kind: 'rtc-ice', roomId: joined.roomId, target: joined.hostSignalId, from: clientSignalId, candidate: event.candidate.toJSON(), }); }; const offer = await peer.createOffer(); await peer.setLocalDescription(offer); await signal(mode.signalingUrl, { kind: 'rtc-offer', roomId: joined.roomId, target: joined.hostSignalId, from: clientSignalId, sdp: peer.localDescription?.toJSON() ?? offer, }); try { await waitForDataChannelOpen({ channel: dataChannel, poll: async () => { const drained = await drain(mode.signalingUrl, clientSignalId); for (const message of drained) { if (message.kind === 'rtc-answer') { await peer.setRemoteDescription(message.sdp); for (const candidate of pendingCandidates.splice(0)) await peer.addIceCandidate(candidate); } else if (message.kind === 'rtc-ice') { if (peer.remoteDescription) await peer.addIceCandidate(message.candidate); else pendingCandidates.push(message.candidate); } } }, ...(mode.directTimeoutMs === undefined ? {} : { timeoutMs: mode.directTimeoutMs }), }); } catch { peer.close(); return connectP2PJoinRelayRoom(mode, joined, clientSignalId, opts); } const room = await connectRoomOverChannel( new WebRTCDataChannelPacketChannel(joined.hostSignalId, dataChannel, unreliableChannel), opts.room, opts.joinOptions, ); room.roomId = joined.roomId; room.reconnectionToken = `${room.roomId}:${room.sessionId}`; return room; } async function connectP2PJoinRelayRoom( mode: Extract, joined: Extract, clientSignalId: string, opts: EngineNetConnectOptions, ): Promise { const room = await connectRoomOverChannel( new HttpRelayPacketChannel({ signalingUrl: mode.signalingUrl, roomId: joined.roomId, peerId: clientSignalId, signalPeerId: clientSignalId, targetPeerId: joined.hostSignalId, accessToken: mode.accessToken, }), opts.room, opts.joinOptions, ); room.roomId = joined.roomId; room.reconnectionToken = `${room.roomId}:${room.sessionId}`; return room; } function startHostSignalLoop(options: { signalingUrl: string; roomId: string; hostSignalId: string; runtime: UniversalRoomRuntime; iceServers: RTCIceServer[]; forceRelay: boolean; heartbeatIntervalMs?: number | undefined; accessToken?: string | undefined; }): () => void { let stopped = false; const heartbeatInterval = setInterval(() => { if (stopped) return; void signal(options.signalingUrl, { kind: 'heartbeat', roomId: options.roomId, from: options.hostSignalId, }); }, options.heartbeatIntervalMs ?? 5_000); const peers = new Map(); const pending = new Map(); // Per-peer reliable WebRTC channel, plus any unreliable sub-channel whose // `ondatachannel` arrived before its reliable sibling. The two channels a // client offers surface as TWO `ondatachannel` events; we fold them into one // `WebRTCDataChannelPacketChannel`, tolerant of either arrival order. const webrtcChannels = new Map(); const pendingUnreliable = new Map(); const attachedChannels = new Set<{ send(envelope: Envelope): void; close(reason?: string): void; }>(); const relayChannels = new Map(); const getRelayChannel = (target: string): HostRelayPacketChannel => { let channel = relayChannels.get(target); if (!channel) { channel = new HostRelayPacketChannel({ signalingUrl: options.signalingUrl, roomId: options.roomId, peerId: target, signalPeerId: options.hostSignalId, targetPeerId: target, accessToken: options.accessToken, }); relayChannels.set(target, channel); attachedChannels.add(channel); options.runtime.attach(channel); } return channel; }; const loop = async () => { while (!stopped) { const messages = await drain(options.signalingUrl, options.hostSignalId); for (const message of messages) { if (message.kind === 'relay-open') { const target = message.from ?? 'client'; getRelayChannel(target); continue; } if (message.kind === 'relay-data') { const target = message.from ?? 'client'; getRelayChannel(target).deliver(message.envelope); continue; } if (message.kind === 'rtc-offer') { const target = message.from ?? 'client'; const peer = new RTCPeerConnection({ iceServers: options.iceServers }); peers.set(target, peer); peer.ondatachannel = (event) => { if (event.channel.label === WEBRTC_UNRELIABLE_CHANNEL_LABEL) { const existing = webrtcChannels.get(target); if (existing) existing.attachUnreliableChannel(event.channel); else pendingUnreliable.set(target, event.channel); return; } const channel = new WebRTCDataChannelPacketChannel(target, event.channel); const unreliable = pendingUnreliable.get(target); if (unreliable) { channel.attachUnreliableChannel(unreliable); pendingUnreliable.delete(target); } webrtcChannels.set(target, channel); attachedChannels.add(channel); options.runtime.attach(channel); }; peer.onicecandidate = (event) => { if (!event.candidate) return; void signal(options.signalingUrl, { kind: 'rtc-ice', roomId: options.roomId, target, from: options.hostSignalId, candidate: event.candidate.toJSON(), }); }; await peer.setRemoteDescription(message.sdp); for (const candidate of pending.get(target) ?? []) await peer.addIceCandidate(candidate); pending.delete(target); const answer = await peer.createAnswer(); await peer.setLocalDescription(answer); await signal(options.signalingUrl, { kind: 'rtc-answer', roomId: options.roomId, target, from: options.hostSignalId, sdp: peer.localDescription?.toJSON() ?? answer, }); } else if (message.kind === 'rtc-ice') { const target = message.from ?? 'client'; const peer = peers.get(target); if (peer?.remoteDescription) await peer.addIceCandidate(message.candidate); else { const list = pending.get(target) ?? []; list.push(message.candidate); pending.set(target, list); } } } await sleep(10); } for (const channel of attachedChannels) closeRemoteForHostLoss(channel); for (const peer of peers.values()) closePeerAfterHostLoss(peer); }; void loop(); return () => { stopped = true; clearInterval(heartbeatInterval); for (const channel of attachedChannels) closeRemoteForHostLoss(channel); for (const peer of peers.values()) closePeerAfterHostLoss(peer); }; } function closeRemoteForHostLoss(channel: { send(envelope: Envelope): void; close(reason?: string): void; closeAfterFlush?(delayMs?: number): void; }): void { const sendLeave = () => { try { channel.send({ kind: 'leave', code: 1000, reason: 'host-left' }); } catch { // The underlying transport may already be closed; close handlers still run below. } }; sendLeave(); setTimeout(sendLeave, 50); setTimeout(sendLeave, 150); setTimeout(sendLeave, 300); if (channel.closeAfterFlush) { channel.closeAfterFlush(HOST_LOSS_CLOSE_DELAY_MS); } else { setTimeout(() => channel.close('host-left'), HOST_LOSS_CLOSE_DELAY_MS); } } function closePeerAfterHostLoss(peer: RTCPeerConnection): void { setTimeout(() => peer.close(), HOST_LOSS_CLOSE_DELAY_MS); } function adaptCompatRoom(room: CompatRoom, roomId?: string): EngineNetTransport { // The engine transport is string-keyed and STABLE; bridge the real // `@colyseus/schema` string-keyed callbacks strategy (over the client's real // decoder) to it. `onStateChange` is not a callbacks-strategy method — it is // the room's own signal, emitted after each decode. // biome-ignore lint/suspicious/noExplicitAny: bridging the generic Callbacks strategy to the string-keyed engine interface. const $ = room.decoder ? (Callbacks.get(room.decoder) as any) : undefined; const transport: EngineNetTransport = { get sessionId() { return room.sessionId; }, roomId, replication: { onAdd(collection, handler) { if (!$) return () => undefined; return $.onAdd(collection, (item: unknown, key: unknown) => handler(item as never, String(key)), ); }, onRemove(collection, handler) { if (!$) return () => undefined; return $.onRemove(collection, (item: unknown, key: unknown) => handler(item as never, String(key)), ); }, onChange(item, handler) { if (!$) return () => undefined; return $.onChange(item, handler); }, onStateChange(handler) { return room.onStateChange.add((state) => handler(state as never)); }, }, send(type, payload) { room.send(type, payload); }, onMessage(type, handler) { return room.onMessage(type, handler); }, onLeave(handler) { return room.onLeave.add(handler); }, leave() { room.leave(); }, }; return transport; } class HttpRelayPacketChannel { readonly mode = 'relay' as const; private readonly envelopeHandlers = new Set<(envelope: Envelope) => void>(); private readonly closeHandlers = new Set<(reason?: string) => void>(); private closed = false; private opened = false; private sendQueue: Promise = Promise.resolve(); constructor( private readonly options: { signalingUrl: string; roomId: string; peerId: string; signalPeerId: string; targetPeerId: string; accessToken?: string | undefined; }, ) { void this.poll(); } get peerId(): string { return this.options.peerId; } send(envelope: Envelope): void { this.sendQueue = this.sendQueue .catch(() => undefined) .then(() => this.sendAsync(envelope)) .catch((error) => { this.close(error instanceof Error ? error.message : String(error)); }); } private async sendAsync(envelope: Envelope): Promise { if (!this.opened) { const opened = await signal(this.options.signalingUrl, { kind: 'relay-open', roomId: this.options.roomId, target: this.options.targetPeerId, from: this.options.signalPeerId, }); if (opened.kind === 'error') throw new Error(opened.message); this.opened = true; } const result = await signal(this.options.signalingUrl, { kind: 'relay-data', roomId: this.options.roomId, target: this.options.targetPeerId, from: this.options.signalPeerId, // Attach the managed token so the relay can meter these bytes against the // authenticated account under enforcement; omitted for self-host / BYOK. ...(this.options.accessToken ? { accessToken: this.options.accessToken } : {}), envelope, }); if (result.kind === 'error') throw new Error(result.message); } onEnvelope(handler: (envelope: Envelope) => void): () => void { this.envelopeHandlers.add(handler); return () => this.envelopeHandlers.delete(handler); } onClose(handler: (reason?: string) => void): () => void { this.closeHandlers.add(handler); return () => this.closeHandlers.delete(handler); } close(reason?: string): void { if (this.closed) return; this.closed = true; for (const handler of this.closeHandlers) handler(reason); } private async poll(): Promise { while (!this.closed) { const messages = await drain(this.options.signalingUrl, this.options.signalPeerId); for (const message of messages) { if (message.kind === 'relay-data') { for (const handler of this.envelopeHandlers) handler(message.envelope); } } await sleep(10); } } } class HostRelayPacketChannel { readonly mode = 'relay' as const; private readonly envelopeHandlers = new Set<(envelope: Envelope) => void>(); private readonly closeHandlers = new Set<(reason?: string) => void>(); private closed = false; private opened = false; private sendQueue: Promise = Promise.resolve(); constructor( private readonly options: { signalingUrl: string; roomId: string; peerId: string; signalPeerId: string; targetPeerId: string; accessToken?: string | undefined; }, ) {} get peerId(): string { return this.options.peerId; } send(envelope: Envelope): void { this.sendQueue = this.sendQueue .catch(() => undefined) .then(() => this.sendAsync(envelope)) .catch((error) => { this.close(error instanceof Error ? error.message : String(error)); }); } deliver(envelope: Envelope): void { if (this.closed) return; for (const handler of this.envelopeHandlers) handler(envelope); } private async sendAsync(envelope: Envelope): Promise { if (!this.opened) { const opened = await signal(this.options.signalingUrl, { kind: 'relay-open', roomId: this.options.roomId, target: this.options.targetPeerId, from: this.options.signalPeerId, }); if (opened.kind === 'error') throw new Error(opened.message); this.opened = true; } const result = await signal(this.options.signalingUrl, { kind: 'relay-data', roomId: this.options.roomId, target: this.options.targetPeerId, from: this.options.signalPeerId, // Attach the managed token so the relay can meter these bytes against the // authenticated account under enforcement; omitted for self-host / BYOK. ...(this.options.accessToken ? { accessToken: this.options.accessToken } : {}), envelope, }); if (result.kind === 'error') throw new Error(result.message); } onEnvelope(handler: (envelope: Envelope) => void): () => void { this.envelopeHandlers.add(handler); return () => this.envelopeHandlers.delete(handler); } onClose(handler: (reason?: string) => void): () => void { this.closeHandlers.add(handler); return () => this.closeHandlers.delete(handler); } close(reason?: string): void { if (this.closed) return; this.closed = true; for (const handler of this.closeHandlers) handler(reason); } } async function connectRealColyseus( url: string, opts: EngineNetConnectOptions, ): Promise { const sdk = (await import('@colyseus/sdk')) as typeof import('@colyseus/sdk'); const client = new sdk.Client(url); const room = await client.joinOrCreate(opts.room, opts.joinOptions); const sourceReplication = sdk.Callbacks.get(room); const callbackReplication = sourceReplication as unknown as { onAdd(collection: string, handler: (item: unknown, key: unknown) => void): () => void; onRemove(collection: string, handler: (item: unknown, key: unknown) => void): () => void; onChange(item: unknown, handler: () => void): () => void; }; const replication: EngineStateReplication = { onAdd(collection, handler) { return callbackReplication.onAdd(collection, (item, key) => handler(item as never, String(key)), ); }, onRemove(collection, handler) { return callbackReplication.onRemove(collection, (item, key) => handler(item as never, String(key)), ); }, onChange(item, handler) { return callbackReplication.onChange(item, handler); }, onStateChange(handler) { const callable = room.onStateChange as unknown as | ((handler: (state: unknown) => void) => () => void) | { add(handler: (state: unknown) => void): () => void } | undefined; if (typeof callable === 'function') return callable((state: unknown) => handler(state as never)); if (callable && typeof callable.add === 'function') { return callable.add((state: unknown) => handler(state as never)); } return () => undefined; }, }; return { get sessionId() { return room.sessionId; }, replication, send(type, payload) { room.send(type, payload); }, onMessage(type, handler) { room.onMessage(type, handler); return () => undefined; }, onLeave(handler) { room.onLeave((code) => handler(code)); return () => undefined; }, leave() { void room.leave(); }, }; } async function signal(signalingUrl: string, body: SignalEnvelope): Promise { const response = await fetch(signalingUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(`P2P signaling failed: ${response.status}`); return (await response.json()) as SignalEnvelope; } async function drain(signalingUrl: string, peerId: string): Promise { const response = await signal(signalingUrl, { kind: 'relay-drain', peerId }); return response.kind === 'relay-drained' ? [...response.messages] : []; } async function waitForDataChannelOpen(options: { channel: RTCDataChannel; poll: () => Promise; timeoutMs?: number; }): Promise { if (options.timeoutMs !== undefined && options.timeoutMs <= 0) { throw new Error('P2P WebRTC data channel open timeout'); } const started = Date.now(); while (options.channel.readyState !== 'open') { if (Date.now() - started > (options.timeoutMs ?? 5_000)) { throw new Error('P2P WebRTC data channel open timeout'); } await options.poll(); await sleep(10); } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function randomId(): string { return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2); }