import { CloseCode, Protocol, type InferState, type SDKTypes, type ServerRoomLike, type ISeatReservation } from '@colyseus/shared-types'; import { MatchMakeError, ServerError } from './errors/Errors.ts'; import { Room } from './Room.ts'; import { SchemaConstructor } from './serializer/SchemaSerializer.ts'; import { HTTP, type FetchFn } from './HTTP.ts'; import { Auth } from './Auth.ts'; import { Connection } from './Connection.ts'; import { discordURLBuilder } from './3rd_party/discord.ts'; import { publishDebug } from './debug-channel.ts'; export type JoinOptions = any; export type { ISeatReservation }; // - React Native does not provide `window.location` // - Cocos Creator (Native) does not provide `window.location.hostname` const DEFAULT_ENDPOINT = (typeof (window) !== "undefined" && typeof (window?.location?.hostname) !== "undefined") ? `${window.location.protocol.replace("http", "ws")}//${window.location.hostname}${(window.location.port && `:${window.location.port}`)}` : "ws://127.0.0.1:2567"; export interface EndpointSettings { hostname: string, secure: boolean, port?: number, pathname?: string, searchParams?: string, /** @see {@link ClientOptions.protocol} — `"h3"` is experimental. */ protocol?: "ws" | "h3"; } export interface ClientOptions { headers?: { [id: string]: string }; urlBuilder?: (url: URL) => string; /** * Wire protocol: `"ws"` (WebSocket, the default) or `"h3"` (WebTransport). * * **`"h3"` is experimental.** It is the only protocol with a real * unreliable channel, so it's what `room.input({ mode: "unreliable" })` and * `@unreliable` state fields need to actually ride datagrams — on `"ws"` * that traffic is correct but travels the reliable channel. It needs * `@colyseus/h3-transport` server-side, and WebTransport browser support * (absent in Safari at time of writing). */ protocol?: "ws" | "h3"; fetchFn?: FetchFn; } /** A room listing entry returned by matchmaking queries. */ export interface RoomAvailable { name: string; roomId: string; clients: number; maxClients: number; metadata?: Metadata; } export interface LatencyOptions { /** "ws" for WebSocket, "h3" for WebTransport (default: "ws"). @see {@link ClientOptions.protocol} */ protocol?: "ws" | "h3"; /** Number of pings to send (default: 1). Returns the average latency when > 1. */ pingCount?: number; /** * Milliseconds to wait for the measurement before rejecting (default: 1500). * Bounds unreachable/blackholed endpoints so they can't stall selection. */ timeout?: number; } export class ColyseusSDK { static VERSION = "0.18"; /** * The HTTP client to make requests to the server. */ public http: HTTP; /** * The authentication module to authenticate into requests and rooms. */ public auth: Auth; /** * The settings used to connect to the server. */ public settings: EndpointSettings; protected urlBuilder: (url: URL) => string; constructor( settings: string | EndpointSettings = DEFAULT_ENDPOINT, options?: ClientOptions, ) { if (typeof (settings) === "string") { // // endpoint by url // const url = (settings.startsWith("/")) ? new URL(settings, DEFAULT_ENDPOINT) : new URL(settings); const secure = (url.protocol === "https:" || url.protocol === "wss:"); const port = Number(url.port || (secure ? 443 : 80)); this.settings = { hostname: url.hostname, pathname: url.pathname, port, secure, searchParams: url.searchParams.toString() || undefined, }; } else { // // endpoint by settings // if (settings.port === undefined) { settings.port = (settings.secure) ? 443 : 80; } if (settings.pathname === undefined) { settings.pathname = ""; } this.settings = settings; } // make sure pathname does not end with "/" if (this.settings.pathname.endsWith("/")) { this.settings.pathname = this.settings.pathname.slice(0, -1); } // specify room connection protocol if provided if (options?.protocol) { this.settings.protocol = options.protocol; } this.http = new HTTP(this, { headers: options?.headers || {}, }, options?.fetchFn); this.auth = new Auth(this.http); this.urlBuilder = options?.urlBuilder; // // Discord Embedded SDK requires a custom URL builder // if ( !this.urlBuilder && typeof (window) !== "undefined" && window?.location?.hostname?.includes("discordsays.com") ) { this.urlBuilder = discordURLBuilder; console.log("Colyseus SDK: Discord Embedded SDK detected. Using custom URL builder."); } } /** * Select the endpoint with the lowest latency. * @param endpoints Array of endpoints to select from. * @param options Client options. * @param latencyOptions Latency measurement options (protocol, pingCount, timeout) — forwarded to each {@link getLatency} call. * @returns The client with the lowest latency. */ static async selectByLatency( endpoints: Array, options?: ClientOptions, latencyOptions: LatencyOptions = {} ) { const clients = endpoints.map(endpoint => new ColyseusSDK(endpoint, options)); const latencies = (await Promise.allSettled(clients.map((client, index) => client.getLatency(latencyOptions).then(latency => { const settings = clients[index].settings; console.log(`🛜 Endpoint Latency: ${latency}ms - ${settings.hostname}:${settings.port}${settings.pathname}`); return [index, latency] })))) .filter((result) => result.status === 'fulfilled') .map(result => result.value); if (latencies.length === 0) { throw new Error('All endpoints failed to respond'); } return clients[latencies.sort((a, b) => a[1] - b[1])[0][0]]; } // Overload: Use room name from ServerType to infer room type public async joinOrCreate>( roomName: R, options?: Parameters[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass RoomType directly to extract state public async joinOrCreate( roomName: string, options?: Parameters>[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass State type directly public async joinOrCreate( roomName: string, options?: JoinOptions, rootSchema?: SchemaConstructor ): Promise> // Implementation public async joinOrCreate(roomName: string, options: JoinOptions = {}, rootSchema?: SchemaConstructor) { return await this.createMatchMakeRequest('joinOrCreate', roomName, options, rootSchema); } // Overload: Use room name from ServerType to infer room type public async create>( roomName: R, options?: Parameters[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass RoomType directly to extract state public async create( roomName: string, options?: Parameters>[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass State type directly public async create( roomName: string, options?: JoinOptions, rootSchema?: SchemaConstructor ): Promise> // Implementation public async create(roomName: string, options: JoinOptions = {}, rootSchema?: SchemaConstructor) { return await this.createMatchMakeRequest('create', roomName, options, rootSchema); } // Overload: Use room name from ServerType to infer room type public async join>( roomName: R, options?: Parameters[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass RoomType directly to extract state public async join( roomName: string, options?: Parameters>[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass State type directly public async join( roomName: string, options?: JoinOptions, rootSchema?: SchemaConstructor ): Promise> // Implementation public async join(roomName: string, options: JoinOptions = {}, rootSchema?: SchemaConstructor) { return await this.createMatchMakeRequest('join', roomName, options, rootSchema); } // Overload: Use room name from ServerType to infer room type public async joinById>( roomName: R, options?: Parameters[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass RoomType directly to extract state public async joinById( roomId: string, options?: Parameters>[1], rootSchema?: SchemaConstructor ): Promise> // Overload: Pass State type directly public async joinById( roomId: string, options?: JoinOptions, rootSchema?: SchemaConstructor ): Promise> // Implementation public async joinById(roomId: string, options: JoinOptions = {}, rootSchema?: SchemaConstructor) { return await this.createMatchMakeRequest('joinById', roomId, options, rootSchema); } /** * Re-establish connection with a room this client was previously connected to. * * @param reconnectionToken The `room.reconnectionToken` from previously connected room. * @param rootSchema (optional) Concrete root schema definition * @returns Promise */ // Overload: Use room name from ServerType to infer room type public async reconnect(reconnectionToken: string, roomName?: R): Promise> // Overload: Pass RoomType directly to extract state public async reconnect( reconnectionToken: string, rootSchema?: SchemaConstructor ): Promise> // Overload: Pass State type directly public async reconnect( reconnectionToken: string, rootSchema?: SchemaConstructor ): Promise> // Implementation public async reconnect(reconnectionToken: string, rootSchema?: SchemaConstructor) { if (typeof (reconnectionToken) === "string" && typeof (rootSchema) === "string") { throw new Error("DEPRECATED: .reconnect() now only accepts 'reconnectionToken' as argument.\nYou can get this token from previously connected `room.reconnectionToken`"); } const [roomId, token] = reconnectionToken.split(":"); if (!roomId || !token) { throw new Error("Invalid reconnection token format.\nThe format should be roomId:reconnectionToken"); } return await this.createMatchMakeRequest('reconnect', roomId, { reconnectionToken: token }, rootSchema); } public async consumeSeatReservation( response: ISeatReservation, rootSchema?: SchemaConstructor ): Promise> { const room = this.createRoom(response.name, rootSchema); room.roomId = response.roomId; room.sessionId = response.sessionId; const options: any = { sessionId: room.sessionId }; // forward "reconnection token" in case of reconnection. if (response.reconnectionToken) { options.reconnectionToken = response.reconnectionToken; } room.connect( this.buildEndpoint(response, options), // `protocol` lives on client settings, not the matchmake response — inject it so // Room.connect picks the right transport. Without this it silently falls back to ws. { ...response, protocol: this.settings.protocol }, this.http.options.headers ); return new Promise((resolve, reject) => { const onError = (code, message) => reject(new ServerError(code, message)); room.onError.once(onError); room['onJoin'].once(() => { room.onError.remove(onError); // Surface the connected room to `@colyseus/sdk/debug` (buffered // until the overlay loads). This is the single choke point for // join/create/joinById/reconnect, so every room gets a panel — // and one joined before a late debug import is adopted on replay, // not missed like the old consumeSeatReservation monkey-patch. publishDebug("room", room); resolve(room); }); }); } /** * Create a new connection with the server, and measure the latency. * * Always settles: resolves with the (average) round-trip time, or rejects on * connection error, server-side close before all pongs arrive, or timeout. * * @param options Latency measurement options (protocol, pingCount, timeout). */ public getLatency(options: LatencyOptions = {}): Promise { const protocol = options.protocol ?? "ws"; const pingCount = options.pingCount ?? 1; const timeout = options.timeout ?? 1500; return new Promise((resolve, reject) => { const conn = new Connection(protocol); const latencies: number[] = []; let pingStart = 0; let settled = false; let timeoutId: ReturnType; // run exactly once — guards against late events after resolve/reject // (e.g. our own conn.close() firing onclose, or a stray onclose/onerror pair) const settle = (run: () => void) => { if (settled) { return; } settled = true; clearTimeout(timeoutId); try { conn.close(); } catch (e) { /* socket may never have opened */ } run(); }; const fail = (message: string) => settle(() => reject(new ServerError(CloseCode.ABNORMAL_CLOSURE, `Failed to get latency: ${message}`))); // bound blackholed/filtered hosts that never fire onopen/onerror within the OS TCP timeout timeoutId = setTimeout(() => fail(`timed out after ${timeout}ms`), timeout); conn.events.onopen = () => { pingStart = Date.now(); conn.send(new Uint8Array([Protocol.PING])); }; conn.events.onmessage = (_: MessageEvent) => { latencies.push(Date.now() - pingStart); if (latencies.length < pingCount) { // Send another ping pingStart = Date.now(); conn.send(new Uint8Array([Protocol.PING])); } else { // Done, calculate average and close const average = latencies.reduce((sum, l) => sum + l, 0) / latencies.length; settle(() => resolve(average)); } }; // server closed the socket before all pongs arrived — fires without onerror on a clean close conn.events.onclose = (event: any) => fail(`connection closed${event?.code ? ` (${event.code})` : ""}${event?.reason ? `: ${event.reason}` : ""}`); conn.events.onerror = (event: ErrorEvent) => fail(event.message); try { conn.connect(this.getHttpEndpoint()); } catch (e: any) { fail(e?.message ?? "failed to connect"); } }); } protected async createMatchMakeRequest( method: string, roomName: string, options: JoinOptions = {}, rootSchema?: SchemaConstructor, ) { try { if (!roomName) { throw new Error("Must provide a room name"); } const httpResponse = await (this.http as HTTP).post(`/matchmake/${method}/${roomName}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: options }); const response = httpResponse.data as unknown as ISeatReservation; // forward reconnection token during "reconnect" methods. if (method === "reconnect") { response.reconnectionToken = options.reconnectionToken; } return await this.consumeSeatReservation(response, rootSchema); } catch (error) { if (error instanceof ServerError) { throw new MatchMakeError(error.message, error.code); } throw error; } } protected createRoom(roomName: string, rootSchema?: SchemaConstructor) { return new Room(roomName, rootSchema); } protected buildEndpoint(seatReservation: ISeatReservation, options: any = {}) { let protocol: string = this.settings.protocol || "ws"; let searchParams = this.settings.searchParams || ""; // forward authentication token if (this.http.authToken) { options['_authToken'] = this.http.authToken; } // append provided options for (const name in options) { if (!options.hasOwnProperty(name)) { continue; } searchParams += (searchParams ? '&' : '') + `${name}=${options[name]}`; } if (protocol === "h3") { protocol = "http"; } let endpoint = (this.settings.secure) ? `${protocol}s://` : `${protocol}://`; if (seatReservation.publicAddress) { endpoint += `${seatReservation.publicAddress}`; } else { endpoint += `${this.settings.hostname}${this.getEndpointPort()}${this.settings.pathname}`; } const endpointURL = `${endpoint}/${seatReservation.processId}/${seatReservation.roomId}?${searchParams}`; return (this.urlBuilder) ? this.urlBuilder(new URL(endpointURL)) : endpointURL; } protected getHttpEndpoint(segments: string = '') { const path = segments.startsWith("/") ? segments : `/${segments}`; let endpointURL = `${(this.settings.secure) ? "https" : "http"}://${this.settings.hostname}${this.getEndpointPort()}${this.settings.pathname}${path}`; if (this.settings.searchParams) { endpointURL += `?${this.settings.searchParams}`; } return (this.urlBuilder) ? this.urlBuilder(new URL(endpointURL)) : endpointURL; } protected getEndpointPort() { return (this.settings.port !== 80 && this.settings.port !== 443) ? `:${this.settings.port}` : ""; } } export const Client = ColyseusSDK; export type Client = InstanceType>;