import type { Router, HasRequiredKeys, Prettify, UnionToIntersection, Endpoint, HTTPMethod } from "@colyseus/better-call"; import { ColyseusSDK } from "./Client.ts"; import { ServerError } from "./errors/Errors.ts"; import { xhrFetch } from "./fetchXHR.ts"; /** * TODO: we should clean up the types repetition in this file. */ // Helper to check if a type is 'any' type IsAny = 0 extends 1 & T ? true : false; // Helper to check if a type resolves to any after indexed access // When T is any, T[K] is also any, but IsAny may not detect it due to deferred evaluation // We check multiple characteristics of 'any': // 1. Direct any check: IsAny // 2. Accepts all string keys: string extends keyof T // 3. Accepts all number and symbol keys: for complete 'any' detection type IsAnyOrAnyIndexed = IsAny extends true ? true : (string extends keyof T ? true : (number extends keyof T ? (symbol extends keyof T ? true : false) : false)); type HasRequired< T extends { body?: any; query?: any; params?: any; }, > = keyof RequiredOptionKeys extends never ? false : true; type InferContext = T extends (ctx: infer Ctx) => any ? Ctx extends object ? Ctx : never : never; // WithRequired - makes specific keys required // This works by spreading T and then overriding the specified keys to be non-nullable type WithRequired = Prettify }>; type WithoutServerOnly> = { [K in keyof T]: T[K] extends Endpoint ? O extends { metadata: { SERVER_ONLY: true } } ? never : T[K] : T[K]; }; // Method-specific options type type MethodOptions = API extends { [key: string]: infer T; } ? T extends Endpoint ? O["method"] extends M ? { [key in T["path"]]: T; } : O["method"] extends M[] ? M extends O["method"][number] ? { [key in T["path"]]: T; } : {} : O["method"] extends "*" ? { [key in T["path"]]: T; } : {} : {} : {}; // When the endpoint didn't declare a schema, better-call infers: // - body: `any` // - query: `Record | undefined` // - params: `Record | undefined` (no `:param` in path) // Under `strictNullChecks: false`, `undefined extends T` is vacuously true and // `undefined extends object` is also true, so the previous `extends object` // guard wrongly classified these undeclared slots as required. The checks // below use `IsAny` for body and `string extends keyof NonNullable<…>` to // detect the fallback index signature — both are immune to null-check mode. type IsUndeclaredSchema = [T] extends [never] ? true : string extends keyof NonNullable ? true : false; export type RequiredOptionKeys< C extends { body?: any; query?: any; params?: any; }, > = (IsAny extends true ? {} : NonNullable extends object ? HasRequiredKeys> extends true ? { body: true } : {} : { body: true }) & (IsUndeclaredSchema extends true ? {} : HasRequiredKeys> extends true ? { query: true } : {}) & (IsUndeclaredSchema extends true ? {} : HasRequiredKeys> extends true ? { params: true } : {}); type CommonHeaders = { accept: "application/json" | "text/plain" | "application/octet-stream"; "content-type": "application/json" | "text/plain" | "application/x-www-form-urlencoded" | "multipart/form-data" | "application/octet-stream"; authorization: "Bearer" | "Basic"; }; type FetchRequestOptions< Body = any, Query extends Record = any, Params extends Record | Array | undefined = any, Res = any, ExtraOptions extends Record = {} > = Prettify & { // baseURL?: string; /** * Headers */ headers?: CommonHeaders | Headers | HeadersInit; /** * Body */ body?: Body; /** * Query parameters (key-value pairs) */ query?: Query; /** * Dynamic parameters. * * If url is defined as /path/:id, params will be { id: string } */ params?: Params; }> type FetchResponse = { raw: Response; data: T; headers: Headers; status: number; statusText: string; }; export function isJSONSerializable(value: any) { if (value === undefined) { return false; } const t = typeof value; if (t === "string" || t === "number" || t === "boolean" || t === null) { return true; } if (t !== "object") { return false; } if (Array.isArray(value)) { return true; } if (value.buffer) { return false; } return ( (value.constructor && value.constructor.name === "Object") || typeof value.toJSON === "function" ); } const JSON_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i; export type ResponseType = "json" | "text" | "blob"; export function detectResponseType(request: Response): ResponseType { const _contentType = request.headers.get("content-type"); const textTypes = new Set([ "image/svg", "application/xml", "application/xhtml", "application/html", ]); if (!_contentType) { return "json"; } const contentType = _contentType.split(";").shift() || ""; if (JSON_RE.test(contentType)) { return "json"; } if (textTypes.has(contentType) || contentType.startsWith("text/")) { return "text"; } return "blob"; } function getURLWithQueryParams(url: string, option?: FetchRequestOptions) { const { params, query } = option || {}; // Parse the URL and extract existing query parameters const [urlPath, urlQuery] = url.split("?"); let path = urlPath; // Handle params substitution if (params) { if (Array.isArray(params)) { const paramPaths = path.split("/").filter((p) => p.startsWith(":")); for (const [index, key] of paramPaths.entries()) { const value = params[index]; path = path.replace(key, value); } } else { for (const [key, value] of Object.entries(params)) { path = path.replace(`:${key}`, String(value)); } } } // Merge query parameters from URL and options const queryParams = new URLSearchParams(urlQuery); if (query) { for (const [key, value] of Object.entries(query)) { if (value == null) continue; queryParams.set(key, String(value)); } } // Build final URL let queryParamString = queryParams.toString(); queryParamString = queryParamString.length > 0 ? `?${queryParamString}`.replace(/\+/g, "%20") : ""; return `${path}${queryParamString}`; } type InferredAPI = R extends { endpoints: Record } ? WithoutServerOnly : WithoutServerOnly>; // Helper type to resolve return type, returning 'any' when R is untyped type InferReturnType = IsAnyOrAnyIndexed extends true ? any : Awaited>; export type FetchFn = (url: string | URL | Request, init?: RequestInit) => Promise; export class HTTP { public authToken: string | undefined; public options: FetchRequestOptions; private sdk: ColyseusSDK; private _fetchFn: FetchFn | undefined; // alias "del()" to "delete()" public del = this.delete; constructor(sdk: ColyseusSDK, baseOptions: FetchRequestOptions, fetchFn?: FetchFn) { this.sdk = sdk; this.options = baseOptions; this._fetchFn = fetchFn; } /** * Lazily resolve the fetch implementation. * Falls back to XMLHttpRequest when fetch is unavailable (e.g. Cocos Creator Native). */ private get fetchFn(): FetchFn { if (!this._fetchFn) { this._fetchFn = (typeof(globalThis.fetch) !== 'undefined') ? globalThis.fetch.bind(globalThis) : xhrFetch; } return this._fetchFn; } private async request< M extends HTTPMethod, API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( method: M, path: K, options?: FetchRequestOptions ): Promise< FetchResponse>> > { return this.executeRequest(method, path, options); } // Overload for endpoints WITH required fields (body/query/params) get< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends true ? K : never), options: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : WithRequired, keyof RequiredOptionKeys> ): Promise< FetchResponse> >; // Overload for endpoints WITHOUT required fields (permissive when R is 'any') get< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends false ? K : never), options?: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : FetchRequestOptions ): Promise< FetchResponse> >; get(path: any, options?: any): Promise { return this.request("GET", path, options); } // Overload for endpoints WITH required fields (body/query/params) post< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: (IsAnyOrAnyIndexed extends true ? string : never) | (IsAny extends true ? string : never) | (HasRequired extends true ? K : never), options: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : (IsAny extends true ? FetchRequestOptions : WithRequired, keyof RequiredOptionKeys>) ): Promise< FetchResponse> >; // Overload for endpoints WITHOUT required fields (permissive when R is 'any') post< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: (IsAnyOrAnyIndexed extends true ? string : never) | (IsAny extends true ? string : never) | (HasRequired extends false ? K : never), options?: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : (IsAny extends true ? FetchRequestOptions : FetchRequestOptions) ): Promise< FetchResponse> >; post(path: any, options?: any): Promise { return this.request("POST", path, options); } // Overload for endpoints WITH required fields (body/query/params) delete< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends true ? K : never), options: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : WithRequired, keyof RequiredOptionKeys> ): Promise< FetchResponse> >; // Overload for endpoints WITHOUT required fields (permissive when R is 'any') delete< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends false ? K : never), options?: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : FetchRequestOptions ): Promise< FetchResponse> >; delete(path: any, options?: any): Promise { return this.request("DELETE", path, options); } // Overload for endpoints WITH required fields (body/query/params) patch< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends true ? K : never), options: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : WithRequired, keyof RequiredOptionKeys> ): Promise< FetchResponse> >; // Overload for endpoints WITHOUT required fields (permissive when R is 'any') patch< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends false ? K : never), options?: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : FetchRequestOptions ): Promise< FetchResponse> >; patch(path: any, options?: any): Promise { return this.request("PATCH", path, options); } // Overload for endpoints WITH required fields (body/query/params) put< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends true ? K : never), options: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : WithRequired, keyof RequiredOptionKeys> ): Promise< FetchResponse> >; // Overload for endpoints WITHOUT required fields (permissive when R is 'any') put< API extends InferredAPI = InferredAPI, OPT extends Prettify>> = Prettify>>, K extends keyof OPT = keyof OPT, C extends InferContext = InferContext >( path: IsAnyOrAnyIndexed extends true ? string : (HasRequired extends false ? K : never), options?: IsAnyOrAnyIndexed extends true ? FetchRequestOptions : FetchRequestOptions ): Promise< FetchResponse> >; put(path: any, options?: any): Promise { return this.request("PUT", path, options); } protected async executeRequest( method: M, path: any, requestOptions?: any ): Promise { // // FIXME: if FormData is provided, merging "baseOptions.body" with // "options.body" will not work as intended // let body = (this.options.body) ? { ...this.options.body, ...(requestOptions?.body as object || {}) } : requestOptions?.body; const query = (this.options.query) ? { ...this.options.query, ...(requestOptions?.query as object || {}) } : requestOptions?.query; const params = (this.options.params) ? { ...this.options.params, ...(requestOptions?.params as object || {}) } : requestOptions?.params; const headers = new Headers( (this.options.headers) ? { ...this.options.headers, ...(requestOptions?.headers || {}) } : requestOptions?.headers ); // Add Authorization header if authToken is set if (this.authToken && !headers.has("authorization")) { headers.set("authorization", `Bearer ${this.authToken}`); } // Stringify JSON-serializable objects for fetch() body if (isJSONSerializable(body) && typeof body === 'object' && body !== null) { if (!headers.has("content-type")) { headers.set("content-type", "application/json"); } for (const [key, value] of Object.entries(body)) { if (value instanceof Date) { body[key] = value.toISOString(); } } body = JSON.stringify(body); } const mergedOptions = { credentials: requestOptions?.credentials || "include", ...this.options, ...requestOptions, query, params, headers, body, method, }; const url = getURLWithQueryParams(this.sdk['getHttpEndpoint'](path.toString()), mergedOptions); let raw: Response; try { raw = await this.fetchFn(url, mergedOptions); } catch (err: any) { // If it's an AbortError, re-throw as-is if (err.name === 'AbortError') { throw err; } // Re-throw with network error code at top level (e.g. ECONNREFUSED) const networkError: ServerError = new ServerError(err.cause?.code || err.code, err.message); networkError.response = raw; networkError.cause = err.cause; throw networkError; } const contentType = raw.headers.get("content-type"); let data: any; if (contentType?.includes("json")) { data = await raw.json(); } else if (contentType?.includes("text")) { data = await raw.text(); } else { data = await raw.blob(); } if (!raw.ok) { throw new ServerError(raw.status, data.message ?? data.error ?? raw.statusText, { headers: raw.headers, status: raw.status, response: raw, data }); } return { raw, data, headers: raw.headers, status: raw.status, statusText: raw.statusText, }; } }