@colyseus/core 0.18.4 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/Debug.cjs +1 -0
- package/build/Debug.cjs.map +1 -1
- package/build/IPC.cjs +1 -0
- package/build/IPC.cjs.map +1 -1
- package/build/Logger.cjs +1 -0
- package/build/Logger.cjs.map +1 -1
- package/build/MatchMaker.cjs +11 -3
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.mjs +10 -3
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +1 -0
- package/build/Protocol.cjs.map +1 -1
- package/build/Rewind.cjs +1 -0
- package/build/Rewind.cjs.map +1 -1
- package/build/Room.cjs +102 -0
- package/build/Room.cjs.map +2 -2
- package/build/Room.d.ts +57 -0
- package/build/Room.mjs +101 -0
- package/build/Room.mjs.map +2 -2
- package/build/RoomMessages.cjs +1 -0
- package/build/RoomMessages.cjs.map +1 -1
- package/build/RoomPlugin.cjs +1 -0
- package/build/RoomPlugin.cjs.map +1 -1
- package/build/Server.cjs +1 -0
- package/build/Server.cjs.map +1 -1
- package/build/Stats.cjs +1 -0
- package/build/Stats.cjs.map +1 -1
- package/build/Transport.cjs +39 -0
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +66 -1
- package/build/Transport.mjs +37 -1
- package/build/Transport.mjs.map +2 -2
- package/build/errors/RoomExceptions.cjs +1 -0
- package/build/errors/RoomExceptions.cjs.map +1 -1
- package/build/errors/SeatReservationError.cjs +1 -0
- package/build/errors/SeatReservationError.cjs.map +1 -1
- package/build/errors/ServerError.cjs +1 -0
- package/build/errors/ServerError.cjs.map +1 -1
- package/build/index.cjs +5 -0
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +1 -1
- package/build/index.mjs +3 -1
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +1 -0
- package/build/input/InputBuffer.cjs.map +1 -1
- package/build/input/RoomInput.cjs +64 -3
- package/build/input/RoomInput.cjs.map +2 -2
- package/build/input/RoomInput.d.ts +16 -2
- package/build/input/RoomInput.mjs +63 -3
- package/build/input/RoomInput.mjs.map +2 -2
- package/build/input/types.cjs +1 -0
- package/build/input/types.cjs.map +1 -1
- package/build/internal.cjs +1 -0
- package/build/internal.cjs.map +1 -1
- package/build/matchmaker/Lobby.cjs +1 -0
- package/build/matchmaker/Lobby.cjs.map +1 -1
- package/build/matchmaker/LocalDriver/LocalDriver.cjs +1 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +1 -1
- package/build/matchmaker/LocalDriver/Query.cjs +1 -0
- package/build/matchmaker/LocalDriver/Query.cjs.map +1 -1
- package/build/matchmaker/RegisteredHandler.cjs +1 -0
- package/build/matchmaker/RegisteredHandler.cjs.map +1 -1
- package/build/matchmaker/controller.cjs +1 -0
- package/build/matchmaker/controller.cjs.map +1 -1
- package/build/matchmaker/driver.cjs +1 -0
- package/build/matchmaker/driver.cjs.map +2 -2
- package/build/matchmaker/driver.d.ts +4 -2
- package/build/matchmaker/driver.mjs.map +1 -1
- package/build/presence/LocalPresence.cjs +1 -0
- package/build/presence/LocalPresence.cjs.map +1 -1
- package/build/presence/Presence.cjs +1 -0
- package/build/presence/Presence.cjs.map +1 -1
- package/build/rooms/LobbyRoom.cjs +1 -0
- package/build/rooms/LobbyRoom.cjs.map +1 -1
- package/build/rooms/QueueRoom.cjs +1 -0
- package/build/rooms/QueueRoom.cjs.map +1 -1
- package/build/rooms/RelayRoom.cjs +1 -0
- package/build/rooms/RelayRoom.cjs.map +1 -1
- package/build/router/default_routes.cjs +2 -7
- package/build/router/default_routes.cjs.map +2 -2
- package/build/router/default_routes.mjs +2 -8
- package/build/router/default_routes.mjs.map +2 -2
- package/build/router/index.cjs +1 -0
- package/build/router/index.cjs.map +1 -1
- package/build/router/node.cjs +1 -0
- package/build/router/node.cjs.map +1 -1
- package/build/serializer/NoneSerializer.cjs +1 -0
- package/build/serializer/NoneSerializer.cjs.map +1 -1
- package/build/serializer/SchemaSerializer.cjs +98 -0
- package/build/serializer/SchemaSerializer.cjs.map +2 -2
- package/build/serializer/SchemaSerializer.d.ts +25 -0
- package/build/serializer/SchemaSerializer.mjs +98 -1
- package/build/serializer/SchemaSerializer.mjs.map +2 -2
- package/build/serializer/Serializer.cjs +1 -0
- package/build/serializer/Serializer.cjs.map +2 -2
- package/build/serializer/Serializer.d.ts +15 -0
- package/build/utils/DevMode.cjs +1 -0
- package/build/utils/DevMode.cjs.map +1 -1
- package/build/utils/Env.cjs +1 -0
- package/build/utils/Env.cjs.map +1 -1
- package/build/utils/StandardSchema.cjs +1 -0
- package/build/utils/StandardSchema.cjs.map +1 -1
- package/build/utils/UserSessionIndex.cjs +1 -0
- package/build/utils/UserSessionIndex.cjs.map +1 -1
- package/build/utils/Utils.cjs +1 -0
- package/build/utils/Utils.cjs.map +1 -1
- package/build/utils/nanoevents.cjs +1 -0
- package/build/utils/nanoevents.cjs.map +1 -1
- package/package.json +6 -6
- package/src/MatchMaker.ts +20 -3
- package/src/Room.ts +122 -0
- package/src/Transport.ts +132 -4
- package/src/index.ts +1 -1
- package/src/input/RoomInput.ts +76 -3
- package/src/matchmaker/driver.ts +4 -2
- package/src/router/default_routes.ts +2 -8
- package/src/serializer/SchemaSerializer.ts +146 -1
- package/src/serializer/Serializer.ts +17 -0
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/UserSessionIndex.ts"],
|
|
4
4
|
"sourcesContent": ["/**\n * User \u2192 active sessions index: \"which active rooms is user X in?\"\n *\n * Without this, answering that question would require fanning out\n * `getInspectorView()` across every running room \u2014 O(rooms) per lookup.\n * Instead, each Room writes a small Presence hash entry on `_onJoin` and\n * removes it on `_onAfterLeave` / dispose. The admin endpoint reads the\n * hash with a single `hgetall`, then reconciles against the matchmaker's\n * live room listing to drop stale entries left behind by hard crashes\n * (no `onLeave` ran).\n *\n * Hash schema:\n * key: colyseus:user-rooms:{userId}\n * field: sessionId\n * value: JSON `{ roomId, roomName, joinedAt }` (joinedAt is unix ms)\n *\n * Anonymous clients (no userId resolvable) are skipped \u2014 the index is\n * for forensic / support workflows, not anonymous traffic. A Room can\n * also opt out wholesale by setting `trackUserSessions = false` (e.g.\n * a high-volume relay room that doesn't want to pay the Presence\n * write per join).\n *\n * Per-Room state (which sessionIds have an entry, and under which userId)\n * lives in a module-level WeakMap rather than as a field on Room itself.\n * Two reasons:\n *\n * 1. Room.ts stays thin \u2014 it only knows about three call points\n * (`trackRoomJoin`, `releaseRoomLeave`, `sweepRoomDispose`) and\n * doesn't have to carry an extra Map field or two private helper\n * methods solely for this concern.\n * 2. The WeakMap is GC-tied to the Room \u2014 when a Room instance is\n * collected, its entries vanish automatically. No explicit teardown\n * hook needed beyond the dispose sweep that drains Presence.\n *\n * @internal\n */\nimport type { Presence } from '../presence/Presence.ts';\n\nexport const USER_ROOMS_KEY_PREFIX = 'colyseus:user-rooms:';\n\nexport function userRoomsKey(userId: string): string {\n return USER_ROOMS_KEY_PREFIX + userId;\n}\n\n/**\n * What's serialized into the Presence hash value (sessionId is the\n * hash field key, not part of the body). Internal write-side shape.\n */\nexport interface UserRoomEntry {\n roomId: string;\n roomName: string;\n joinedAt: number;\n}\n\n/**\n * Public read-side shape returned by `listUserSessions`: a parsed\n * `UserRoomEntry` plus its sessionId, optionally enriched with\n * `processId` when reconcile against the matchmaker was on.\n */\nexport interface UserSessionInfo extends UserRoomEntry {\n sessionId: string;\n /**\n * Process hosting the room, per the matchmaker. Populated only\n * when `listUserSessions` was called with `reconcile: true` AND\n * the room is still in the matchmaker roster.\n */\n processId?: string;\n}\n\n/**\n * Structural subset of `Room` needed by the index. Lets this module\n * avoid importing `Room` (which would create a cycle) while still\n * staying typed at the call site.\n */\ninterface InspectorRoomShape {\n roomId: string;\n roomName: string;\n presence: Presence;\n}\n\n/**\n * Structural subset of a Client we read at join/leave. `userId` and\n * `auth` are both optional \u2014 the index simply skips clients without\n * either, which is the correct \"anonymous traffic doesn't show up\n * in support tooling\" behavior.\n */\ninterface InspectorClientShape {\n sessionId: string;\n userId?: string;\n auth?: { id?: string } | null;\n}\n\n/**\n * sessionId \u2192 userId for clients currently registered in the index,\n * scoped per Room. WeakMap-keyed so a forgotten Room takes its tracking\n * map with it.\n */\nconst tracked = new WeakMap<InspectorRoomShape, Map<string, string>>();\n\nfunction getTrackingMap(room: InspectorRoomShape): Map<string, string> {\n let map = tracked.get(room);\n if (!map) {\n map = new Map();\n tracked.set(room, map);\n }\n return map;\n}\n\nfunction resolveUserId(client: InspectorClientShape): string | undefined {\n return client.userId ?? client.auth?.id;\n}\n\n/**\n * Best-effort: write the join entry. Errors are swallowed because the\n * index is observability metadata \u2014 a Presence outage shouldn't reject\n * a player's join. Exposed as a pure helper for tests + the admin\n * endpoint; `trackRoomJoin` is the Room-flavored entrypoint.\n */\nexport async function trackUserSession(\n presence: Presence,\n userId: string,\n sessionId: string,\n entry: UserRoomEntry,\n): Promise<void> {\n try {\n await presence.hset(userRoomsKey(userId), sessionId, JSON.stringify(entry));\n } catch {\n // intentional: see fn-doc\n }\n}\n\n/**\n * Best-effort: remove the join entry. Errors are swallowed so a Presence\n * blip doesn't bubble into `_onAfterLeave` / `_dispose`.\n */\nexport async function releaseUserSession(\n presence: Presence,\n userId: string,\n sessionId: string,\n): Promise<void> {\n try {\n await presence.hdel(userRoomsKey(userId), sessionId);\n } catch {\n // intentional: see fn-doc\n }\n}\n\n/**\n * Record `client`'s join under `room` in the reverse index. No-op for\n * clients with no resolvable userId (anonymous), and when the room\n * carries no presence (shouldn't happen \u2014 defensive for unit tests).\n *\n * Fire-and-forget against Presence; the in-memory tracking map updates\n * synchronously so a follow-up `releaseRoomLeave` always finds the\n * right userId even if the Presence write is still in flight.\n */\nexport function trackRoomJoin(room: InspectorRoomShape, client: InspectorClientShape): void {\n if (!room.presence) { return; }\n const userId = resolveUserId(client);\n if (!userId) { return; }\n getTrackingMap(room).set(client.sessionId, userId);\n const entry: UserRoomEntry = {\n roomId: room.roomId,\n roomName: room.roomName,\n joinedAt: Date.now(),\n };\n void trackUserSession(room.presence, userId, client.sessionId, entry);\n}\n\n/**\n * Drop `client`'s entry from the reverse index. Idempotent \u2014 no-op\n * when the client wasn't tracked (anonymous, or tracking failed at\n * join time).\n */\nexport function releaseRoomLeave(room: InspectorRoomShape, client: InspectorClientShape): void {\n if (!room.presence) { return; }\n const map = tracked.get(room);\n const userId = map?.get(client.sessionId);\n if (!userId || !map) { return; }\n map.delete(client.sessionId);\n void releaseUserSession(room.presence, userId, client.sessionId);\n}\n\n/**\n * Sweep any still-tracked sessions for `room`. Called during dispose to\n * cover the case where `disconnect()` races the per-client `_onAfterLeave`\n * path \u2014 the read-side reconcile handles cross-process crash recovery,\n * but this is the cheap deterministic cleanup for a clean local dispose.\n *\n * Awaits the pending `hdel`s so the caller can sequence against \"the\n * index is now coherent\" \u2014 the dispose path uses that ordering.\n */\nexport async function sweepRoomDispose(room: InspectorRoomShape): Promise<void> {\n if (!room.presence) { return; }\n const map = tracked.get(room);\n if (!map || map.size === 0) { return; }\n const pending: Promise<void>[] = [];\n for (const [sessionId, userId] of map) {\n pending.push(releaseUserSession(room.presence, userId, sessionId));\n }\n map.clear();\n await Promise.all(pending);\n}\n\n/**\n * Minimal shape of a matchmaker room record needed for reconcile \u2014\n * keeps this module decoupled from the matchmaker / driver types.\n * `matchMaker.query()`'s actual return (`IRoomCache[]`) is structurally\n * a supertype of this, so callers can pass `matchMaker.query` directly.\n */\ninterface MatchmakerRoomLike {\n roomId: string;\n processId?: string;\n}\n\nexport interface ListUserSessionsOptions {\n /**\n * Drop entries whose `roomId` is no longer in the matchmaker roster\n * (the index can lag a crashed process). When `true`, the returned\n * entries also carry `processId` from the live room record.\n *\n * Off by default \u2014 most callers (kick everyone, count) don't need it\n * and the extra `matchMaker.query` round-trip isn't free.\n */\n reconcile?: boolean;\n\n /**\n * Fire-and-forget `hdel` for stale entries \u2014 those dropped by\n * reconcile (matchmaker doesn't know the room anymore) plus any\n * with corrupt JSON. Lets read endpoints self-heal the index on\n * each call. No-op when `reconcile` is `false`.\n */\n removeStale?: boolean;\n}\n\n/**\n * Read the user \u2192 active sessions index. Pure helper \u2014 the\n * `Presence` + matchmaker batch lookup are injected so this module\n * stays free of matchmaker imports (and so unit tests can drive it\n * with fake deps).\n *\n * Wire-op count per call:\n * - 1 HGETALL on the user's hash (always).\n * - 1 batch room lookup when `reconcile: true` AND there are\n * entries to verify; skipped otherwise.\n *\n * Bounded at 2 wire ops regardless of the user's session count.\n */\nexport async function listUserSessions(\n presence: Presence,\n findRooms: (roomIds: string[]) => Promise<Map<string, MatchmakerRoomLike>>,\n userId: string,\n options: ListUserSessionsOptions = {},\n): Promise<UserSessionInfo[]> {\n const reconcile = options.reconcile === true;\n const removeStale = reconcile && options.removeStale === true;\n\n let raw: Record<string, string>;\n try {\n raw = await presence.hgetall(userRoomsKey(userId));\n } catch {\n // Presence outage \u2014 observability shouldn't bring down the caller.\n return [];\n }\n const fields = Object.keys(raw);\n if (fields.length === 0) { return []; }\n\n const staleSessions: string[] = [];\n const parsed: Array<{ sessionId: string; entry: UserRoomEntry }> = [];\n for (const sessionId of fields) {\n try {\n parsed.push({ sessionId, entry: JSON.parse(raw[sessionId]) as UserRoomEntry });\n } catch {\n // Corrupt JSON \u2014 index drift. Removable even without reconcile.\n staleSessions.push(sessionId);\n }\n }\n\n if (!reconcile) {\n return parsed.map(({ sessionId, entry }) => ({ sessionId, ...entry }));\n }\n\n // One batch lookup for the K roomIds we care about. K = entries\n // surviving the JSON.parse stage, not cluster size.\n const live = parsed.length > 0\n ? await findRooms(parsed.map((p) => p.entry.roomId))\n : new Map<string, MatchmakerRoomLike>();\n\n const result: UserSessionInfo[] = [];\n for (const { sessionId, entry } of parsed) {\n const room = live.get(entry.roomId);\n if (!room) {\n // Matchmaker doesn't know this roomId anymore \u2014 stale entry\n // from a crashed process. Drop it (and remove if requested).\n staleSessions.push(sessionId);\n continue;\n }\n const info: UserSessionInfo = { sessionId, ...entry };\n if (room.processId !== undefined) { info.processId = room.processId; }\n result.push(info);\n }\n\n if (removeStale && staleSessions.length > 0) {\n const key = userRoomsKey(userId);\n void Promise.all(\n staleSessions.map((s) => presence.hdel(key, s)),\n ).catch(() => { /* presence outage, swallow */ });\n }\n\n return result;\n}\n"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/utils/Utils.cjs
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/Utils.ts"],
|
|
4
4
|
"sourcesContent": ["import { nanoid } from 'nanoid';\nimport { type RoomException, type RoomMethodName } from '../errors/RoomExceptions.ts';\n\nimport { debugAndPrintError, debugMatchMaking } from '../Debug.ts';\n\nexport type Type<T> = new (...args: any[]) => T;\nexport type MethodName<T> = string & {\n [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never\n}[keyof T];\n\n/**\n * Utility type that extracts the return type of a method or the type of a property\n * from a given class/object type.\n *\n * - If the key is a method, returns the awaited return type of that method\n * - If the key is a property, returns the type of that property\n */\nexport type ExtractMethodOrPropertyType<\n TClass,\n TKey extends keyof TClass\n> = TClass[TKey] extends (...args: any[]) => infer R\n ? Awaited<R>\n : TClass[TKey];\n\n/**\n * Return type of `remoteRoomCall()`.\n *\n * Resolves to the method's awaited return type (or the property's type) when\n * the method name was captured as a literal type. Falls back to `any` when it\n * wasn't \u2014 e.g. `remoteRoomCall<MyRoom>(...)` with only the room type given:\n * TypeScript applies the `TMethod` default instead of inferring the literal\n * once an explicit type argument list is present (microsoft/TypeScript#26242).\n * Pass both type arguments (`remoteRoomCall<MyRoom, 'myMethod'>`) for a\n * precise return type.\n */\nexport type RemoteRoomCallReturn<\n TRoom,\n TMethod extends keyof TRoom\n> = keyof TRoom extends TMethod\n ? any\n : ExtractMethodOrPropertyType<TRoom, TMethod>;\n\n// remote room call timeouts\nexport const REMOTE_ROOM_SHORT_TIMEOUT = Number(process.env.COLYSEUS_PRESENCE_SHORT_TIMEOUT || 2000);\nexport const MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME = Number(process.env.COLYSEUS_MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME || 0.5);\n\n/**\n * The well-known `Symbol.metadata` (TC39 decorator metadata), falling back to the\n * registered symbol on runtimes that don't expose it globally yet. `@colyseus/schema`\n * stores each class's field metadata under this key \u2014 read it via\n * `instance.constructor[$METADATA]`. Shared so every reader resolves the IDENTICAL\n * symbol (InputBuffer's field-name walk, Rewind's field-index lookup).\n */\nexport const $METADATA: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for(\"Symbol.metadata\");\n\nexport function generateId(length: number = 9) {\n return nanoid(length);\n}\n\nexport function getBearerToken(authHeader: string) {\n return (authHeader && authHeader.startsWith(\"Bearer \") && authHeader.substring(7, authHeader.length)) || undefined;\n}\n\n// nodemon sends SIGUSR2 before reloading\n// (https://github.com/remy/nodemon#controlling-shutdown-of-your-script)\n//\nconst signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGUSR2'];\n\nexport function registerGracefulShutdown(callback: (err?: Error) => void) {\n /**\n * Gracefully shutdown on uncaught errors\n */\n process.on('uncaughtException', (err) => {\n debugAndPrintError(err);\n callback(err);\n });\n\n signals.forEach((signal) =>\n process.once(signal, () => callback()));\n}\n\nexport function retry<T = any>(\n cb: Function,\n maxRetries: number = 3,\n errorWhiteList: any[] = [],\n retries: number = 0,\n) {\n return new Promise<T>((resolve, reject) => {\n cb()\n .then(resolve)\n .catch((e: any) => {\n if (\n errorWhiteList.indexOf(e.constructor) !== -1 &&\n retries++ < maxRetries\n ) {\n setTimeout(() => {\n debugMatchMaking(\"retrying due to error (error: %s, retries: %s, maxRetries: %s)\", e.message, retries, maxRetries);\n retry<T>(cb, maxRetries, errorWhiteList, retries).\n then(resolve).\n catch((e2) => reject(e2));\n }, Math.floor(Math.random() * Math.pow(2, retries) * 400));\n\n } else {\n reject(e);\n }\n });\n });\n}\n\nexport function spliceOne(arr: any[], index: number): boolean {\n // manually splice availableRooms array\n // http://jsperf.com/manual-splice\n if (index === -1 || index >= arr.length) {\n return false;\n }\n\n const len = arr.length - 1;\n for (let i = index; i < len; i++) {\n arr[i] = arr[i + 1];\n }\n\n arr.length = len;\n return true;\n}\n\nexport class Deferred<T = any> {\n public promise: Promise<T>;\n\n public resolve: Function;\n public reject: Function;\n\n constructor(promise?: Promise<T>) {\n this.promise = promise ?? new Promise<T>((resolve, reject) => {\n this.resolve = resolve;\n this.reject = reject;\n });\n }\n\n public then(onFulfilled?: (value: T) => any, onRejected?: (reason: any) => any) {\n return this.promise.then(onFulfilled, onRejected);\n }\n\n public catch(func: (value: any) => any) {\n return this.promise.catch(func);\n }\n\n static reject (reason?: any) {\n return new Deferred(Promise.reject(reason));\n }\n\n static resolve<T = any>(value?: T) {\n return new Deferred<T>(Promise.resolve(value));\n }\n\n}\n\nexport function merge(a: any, ...objs: any[]): any {\n for (let i = 0, len = objs.length; i < len; i++) {\n const b = objs[i];\n for (const key in b) {\n if (b.hasOwnProperty(key)) {\n a[key] = b[key];\n }\n }\n }\n return a;\n}\n\nexport function wrapTryCatch(\n method: Function,\n onError: (error: RoomException, methodName: RoomMethodName) => void,\n exceptionClass: Type<RoomException>,\n methodName: RoomMethodName,\n rethrow: boolean = false,\n ...additionalErrorArgs: any[]\n) {\n return (...args: any[]) => {\n try {\n const result = method(...args);\n if (typeof (result?.catch) === \"function\") {\n return result.catch((e: Error) => {\n onError(new exceptionClass(e, e.message, ...args, ...additionalErrorArgs), methodName);\n if (rethrow) { throw e; }\n });\n }\n return result;\n } catch (e: any) {\n onError(new exceptionClass(e, e.message, ...args, ...additionalErrorArgs), methodName);\n if (rethrow) { throw e; }\n }\n };\n}\n\n/**\n * Dynamically import a module using either require() or import()\n * based on the current module system (CJS vs ESM).\n *\n * This avoids double-loading packages when running in mixed ESM/CJS environments.\n * Errors are silently caught - await the promise and handle errors at usage site.\n */\nexport function dynamicImport<T = any>(moduleName: string): Promise<T> {\n // __dirname exists in CJS but not in ESM\n if (\n typeof __dirname !== 'undefined' &&\n // @ts-ignore\n typeof (Bun) === 'undefined' // prevent bun from loading CJS modules\n ) {\n // CJS context - use require()\n try {\n return Promise.resolve(require(moduleName));\n } catch (e: any) {\n // If the error is not a MODULE_NOT_FOUND error, reject with the error.\n if (e.code !== 'MODULE_NOT_FOUND') {\n return Promise.reject(e);\n }\n return Promise.resolve(undefined);\n }\n } else {\n // ESM context - use import()\n const promise = import(/* @vite-ignore */ moduleName);\n promise.catch(() => {}); // prevent unhandled rejection warnings\n return promise;\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/nanoevents.ts"],
|
|
4
4
|
"sourcesContent": ["export const createNanoEvents = () => ({\n emit(event: string, ...args: any[]) {\n for (\n let callbacks = this.events[event] || [],\n i = 0,\n length = callbacks.length;\n i < length;\n i++\n ) {\n callbacks[i](...args)\n }\n },\n // null-prototype: event names are client-supplied (message types), and on a\n // plain object \"__proto__\" / \"constructor\" / \"toString\" resolve to inherited\n // members instead of missing (colyseus/colyseus#951)\n events: Object.create(null) as { [event: string]: Array<(...args: any[]) => void> },\n on(event: string, cb: (...args: any[]) => void) {\n ;(this.events[event] ||= []).push(cb)\n return () => {\n this.events[event] = this.events[event]?.filter(i => cb !== i)\n }\n }\n })"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAO,IAAM,mBAAmB,OAAO;AAAA,EACnC,KAAK,UAAkB,MAAa;AAClC,aACM,YAAY,KAAK,OAAO,KAAK,KAAK,CAAC,GACrC,IAAI,GACJ,SAAS,UAAU,QACrB,IAAI,QACJ,KACA;AACA,gBAAU,CAAC,EAAE,GAAG,IAAI;AAAA,IACtB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAIA,QAAQ,uBAAO,OAAO,IAAI;AAAA,EAC1B,GAAG,OAAe,IAA8B;AAC9C;AAAC,KAAC,KAAK,OAAO,KAAK,MAAM,CAAC,GAAG,KAAK,EAAE;AACpC,WAAO,MAAM;AACX,WAAK,OAAO,KAAK,IAAI,KAAK,OAAO,KAAK,GAAG,OAAO,OAAK,OAAO,CAAC;AAAA,IAC/D;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colyseus/core",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.5",
|
|
4
4
|
"description": "Multiplayer Framework for Node.js.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"input": "./src/index.ts",
|
|
@@ -52,14 +52,14 @@
|
|
|
52
52
|
"msgpackr": "^2.0.1",
|
|
53
53
|
"nanoid": "^3.3.11",
|
|
54
54
|
"@colyseus/better-call": "^1.3.1",
|
|
55
|
-
"@colyseus/
|
|
56
|
-
"@colyseus/
|
|
55
|
+
"@colyseus/shared-types": "^0.18.1",
|
|
56
|
+
"@colyseus/greeting-banner": "^4.0.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@colyseus/schema": "^5.0.8",
|
|
60
60
|
"express": "^5.0.0",
|
|
61
61
|
"vitest": "^3.1.1",
|
|
62
|
-
"@colyseus/redis-driver": "^0.18.
|
|
62
|
+
"@colyseus/redis-driver": "^0.18.2",
|
|
63
63
|
"@colyseus/redis-presence": "^0.18.2",
|
|
64
64
|
"@colyseus/tools": "^0.18.2"
|
|
65
65
|
},
|
|
@@ -68,9 +68,9 @@
|
|
|
68
68
|
"@pm2/io": "^6.1.0",
|
|
69
69
|
"express": "^4.16.0 || ^5.0.0",
|
|
70
70
|
"zod": "^4.1.12",
|
|
71
|
+
"@colyseus/ws-transport": "^0.18.1",
|
|
71
72
|
"@colyseus/auth": "^0.18.1",
|
|
72
|
-
"@colyseus/better-call": "^1.3.1"
|
|
73
|
-
"@colyseus/ws-transport": "^0.18.1"
|
|
73
|
+
"@colyseus/better-call": "^1.3.1"
|
|
74
74
|
},
|
|
75
75
|
"peerDependenciesMeta": {
|
|
76
76
|
"@colyseus/auth": {
|
package/src/MatchMaker.ts
CHANGED
|
@@ -37,6 +37,16 @@ const handlers: {[id: string]: RegisteredHandler} = Object.create(null);
|
|
|
37
37
|
const rooms: {[roomId: string]: Room} = {};
|
|
38
38
|
const events = new EventEmitter();
|
|
39
39
|
|
|
40
|
+
// A room isn't gone when 'dispose' fires — that only *starts* `Room.#_dispose()`,
|
|
41
|
+
// which awaits the user's async `onDispose()`. `roomCount` is decremented there,
|
|
42
|
+
// so this covers the window until 'disconnect' (emitted once `#_dispose()`
|
|
43
|
+
// settles). Invariant: roomCount + disposingRoomCount = rooms not yet fully gone.
|
|
44
|
+
let disposingRoomCount = 0;
|
|
45
|
+
|
|
46
|
+
function hasActiveRooms() {
|
|
47
|
+
return stats.local.roomCount > 0 || disposingRoomCount > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export let publicAddress: string;
|
|
41
51
|
export let processId: string;
|
|
42
52
|
export let presence: Presence;
|
|
@@ -103,6 +113,7 @@ export async function setup(
|
|
|
103
113
|
publicAddress = _publicAddress || getDefaultPublicAddress();
|
|
104
114
|
|
|
105
115
|
stats.reset(false);
|
|
116
|
+
disposingRoomCount = 0;
|
|
106
117
|
|
|
107
118
|
// ensure processId is set
|
|
108
119
|
if (!processId) { processId = generateId(); }
|
|
@@ -648,11 +659,14 @@ export async function handleCreateRoom(roomName: string, clientOptions: ClientOp
|
|
|
648
659
|
room['_events'].removeAllListeners('metadata-change');
|
|
649
660
|
}
|
|
650
661
|
|
|
662
|
+
// this room's `onDispose()` has settled
|
|
663
|
+
disposingRoomCount--;
|
|
664
|
+
|
|
651
665
|
//
|
|
652
666
|
// emit "no active rooms" event when there are no more rooms in this process
|
|
653
667
|
// (used during graceful shutdown)
|
|
654
668
|
//
|
|
655
|
-
if (
|
|
669
|
+
if (!hasActiveRooms()) {
|
|
656
670
|
events.emit('no-active-rooms');
|
|
657
671
|
}
|
|
658
672
|
});
|
|
@@ -713,7 +727,7 @@ async function lockAndDisposeAll(): Promise<any> {
|
|
|
713
727
|
}
|
|
714
728
|
|
|
715
729
|
const noActiveRooms = new Deferred();
|
|
716
|
-
if (
|
|
730
|
+
if (!hasActiveRooms()) {
|
|
717
731
|
// no active rooms to dispose
|
|
718
732
|
noActiveRooms.resolve();
|
|
719
733
|
|
|
@@ -820,7 +834,7 @@ export async function hotReload(): Promise<void> {
|
|
|
820
834
|
|
|
821
835
|
// Lock all rooms and trigger default onBeforeShutdown (dev mode impl).
|
|
822
836
|
const noActiveRooms = new Deferred();
|
|
823
|
-
if (
|
|
837
|
+
if (!hasActiveRooms()) {
|
|
824
838
|
noActiveRooms.resolve();
|
|
825
839
|
} else {
|
|
826
840
|
events.once('no-active-rooms', () => noActiveRooms.resolve());
|
|
@@ -1205,6 +1219,9 @@ async function disposeRoom(roomName: string, room: Room) {
|
|
|
1205
1219
|
driver.remove(room['_listing'].roomId);
|
|
1206
1220
|
stats.local.roomCount--;
|
|
1207
1221
|
|
|
1222
|
+
// `onDispose()` is still pending — released on 'disconnect'
|
|
1223
|
+
disposingRoomCount++;
|
|
1224
|
+
|
|
1208
1225
|
// decrease amount of rooms this process is handling
|
|
1209
1226
|
if (state !== MatchMakerState.SHUTTING_DOWN) {
|
|
1210
1227
|
stats.persist();
|
package/src/Room.ts
CHANGED
|
@@ -56,6 +56,9 @@ export {
|
|
|
56
56
|
|
|
57
57
|
const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
|
|
58
58
|
const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
|
|
59
|
+
|
|
60
|
+
// Shared so the unarmed `_flushUnreliable` call site stays monomorphic.
|
|
61
|
+
const NOOP = () => {};
|
|
59
62
|
const noneSerializer = new NoneSerializer();
|
|
60
63
|
|
|
61
64
|
/** Shared `enqueueRaw` options routing a frame onto `_pendingFrames` to ride the
|
|
@@ -276,6 +279,45 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
276
279
|
#_patchRate: number;
|
|
277
280
|
#_patchInterval: NodeJS.Timeout;
|
|
278
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Frequency to flush `@unreliable` state fields, in milliseconds.
|
|
284
|
+
*
|
|
285
|
+
* Those fields never ride a state patch — they go out over the transport's
|
|
286
|
+
* unreliable channel (a WebTransport datagram), so a dropped frame costs one
|
|
287
|
+
* stale value instead of stalling the reliable stream behind a retransmit.
|
|
288
|
+
* Setting this decouples them from {@link patchRate}, which is the point:
|
|
289
|
+
* 60Hz movement over a 20Hz structural patch.
|
|
290
|
+
*
|
|
291
|
+
* KNOWN COST of a rate faster than {@link patchRate}: an entity's ADD travels
|
|
292
|
+
* the reliable channel, so datagrams sent between patches can reference a
|
|
293
|
+
* refId the client hasn't been told about yet. Those frames are skipped by the
|
|
294
|
+
* client's decoder — safe (the ref graph can't desync, since `@unreliable` is
|
|
295
|
+
* primitives-only) but each one logs `"refId" not found`, and that entity's
|
|
296
|
+
* first value lands one mutation later. Measured at `patchRate/this` reports
|
|
297
|
+
* per mid-session spawn. Leave this unset and the flush rides
|
|
298
|
+
* {@link broadcastPatch}, which ships the ADD first and avoids it entirely.
|
|
299
|
+
*
|
|
300
|
+
* Requires a transport with a datagram channel — today only
|
|
301
|
+
* `@colyseus/h3-transport` (WebTransport), which is **experimental**. Every
|
|
302
|
+
* WebSocket transport lacks one, and those clients are skipped entirely (the
|
|
303
|
+
* room warns once), so `@unreliable` fields keep their join-time value there.
|
|
304
|
+
*
|
|
305
|
+
* @default null — flush alongside every {@link broadcastPatch}, and only when
|
|
306
|
+
* the state actually declares an `@unreliable` field.
|
|
307
|
+
*/
|
|
308
|
+
public unreliablePatchRate: number | null = null;
|
|
309
|
+
#_unreliablePatchRate: number | null = null;
|
|
310
|
+
#_unreliablePatchInterval: NodeJS.Timeout;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The unreliable flush, called unconditionally at the end of every
|
|
314
|
+
* {@link broadcastPatch}. Stays {@link NOOP} unless the state actually
|
|
315
|
+
* declares an `@unreliable` field, so a room that never uses the channel
|
|
316
|
+
* pays an empty call the engine inlines away — and there is no second
|
|
317
|
+
* entry point that could drift from `broadcastPatch()`.
|
|
318
|
+
*/
|
|
319
|
+
private _flushUnreliable: () => void = NOOP;
|
|
320
|
+
|
|
279
321
|
/**
|
|
280
322
|
* Maximum number of messages a client can send to the server per second.
|
|
281
323
|
* If a client sends more messages than this, it will be disconnected.
|
|
@@ -420,6 +462,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
420
462
|
this.#_state = this.state;
|
|
421
463
|
this.#_autoDispose = this.autoDispose;
|
|
422
464
|
this.#_patchRate = this.patchRate;
|
|
465
|
+
this.#_unreliablePatchRate = this.unreliablePatchRate;
|
|
423
466
|
this.#_maxClients = this.maxClients;
|
|
424
467
|
|
|
425
468
|
Object.defineProperties(this, {
|
|
@@ -436,6 +479,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
436
479
|
}
|
|
437
480
|
this._serializer.reset(newState);
|
|
438
481
|
this.#_state = newState;
|
|
482
|
+
this._armUnreliablePatches();
|
|
439
483
|
},
|
|
440
484
|
},
|
|
441
485
|
|
|
@@ -479,6 +523,15 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
479
523
|
}
|
|
480
524
|
},
|
|
481
525
|
},
|
|
526
|
+
|
|
527
|
+
unreliablePatchRate: {
|
|
528
|
+
enumerable: true,
|
|
529
|
+
get: () => this.#_unreliablePatchRate,
|
|
530
|
+
set: (milliseconds: number | null) => {
|
|
531
|
+
this.#_unreliablePatchRate = milliseconds;
|
|
532
|
+
this._armUnreliablePatches();
|
|
533
|
+
},
|
|
534
|
+
},
|
|
482
535
|
});
|
|
483
536
|
|
|
484
537
|
// set patch interval, now with the setter
|
|
@@ -1300,6 +1353,12 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1300
1353
|
this._inputController !== undefined ? { sNow } : undefined,
|
|
1301
1354
|
);
|
|
1302
1355
|
|
|
1356
|
+
// Flush `@unreliable` fields over the datagram channel, after the reliable
|
|
1357
|
+
// patch so a spawn's ADD is already on the wire when the datagram mutating
|
|
1358
|
+
// it goes out. No-op unless the state declares such a field, and skipped
|
|
1359
|
+
// when `unreliablePatchRate` gave the flush its own timer.
|
|
1360
|
+
this._flushUnreliable();
|
|
1361
|
+
|
|
1303
1362
|
// Deliver any per-client `afterNextPatch` frames as standalone frames right
|
|
1304
1363
|
// after the patch (never coalesced into it). Iterates only the clients that
|
|
1305
1364
|
// staged frames this cycle (`#pendingFrameClients`), never the full list.
|
|
@@ -1321,6 +1380,64 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1321
1380
|
return hasChanges;
|
|
1322
1381
|
}
|
|
1323
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* Encode and send the `@unreliable` state fields over each client's
|
|
1385
|
+
* unreliable channel. Those fields never appear in a {@link broadcastPatch}
|
|
1386
|
+
* frame, and clients on a transport without a datagram channel are skipped.
|
|
1387
|
+
*
|
|
1388
|
+
* Driven automatically once the state declares an `@unreliable` field — right
|
|
1389
|
+
* after each {@link broadcastPatch}, or on its own timer when
|
|
1390
|
+
* {@link unreliablePatchRate} is set. Public so a room driving
|
|
1391
|
+
* `broadcastPatch()` by hand can drive this by hand too.
|
|
1392
|
+
*/
|
|
1393
|
+
public broadcastUnreliablePatch() {
|
|
1394
|
+
if (!this.state) {
|
|
1395
|
+
return false;
|
|
1396
|
+
}
|
|
1397
|
+
return this._serializer.applyUnreliablePatches?.(this.clients) ?? false;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Decide how the unreliable channel is driven — and, for the rooms that never
|
|
1402
|
+
* touch it, decide to not drive it at all. Runs when the state is assigned
|
|
1403
|
+
* (the serializer knows by then whether any `@unreliable` field exists) and
|
|
1404
|
+
* whenever {@link unreliablePatchRate} changes.
|
|
1405
|
+
*
|
|
1406
|
+
* A room whose state declares no `@unreliable` field leaves
|
|
1407
|
+
* {@link _flushUnreliable} at {@link NOOP}, so its patch tick costs exactly
|
|
1408
|
+
* what it did before this feature existed.
|
|
1409
|
+
*/
|
|
1410
|
+
private _armUnreliablePatches() {
|
|
1411
|
+
if (this.#_unreliablePatchInterval) {
|
|
1412
|
+
clearInterval(this.#_unreliablePatchInterval);
|
|
1413
|
+
this.#_unreliablePatchInterval = undefined;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const armed = this._serializer?.hasUnreliableFields === true;
|
|
1417
|
+
const rate = this.#_unreliablePatchRate;
|
|
1418
|
+
const dedicated = armed && rate !== null && rate !== 0;
|
|
1419
|
+
|
|
1420
|
+
// Default mode flushes from the patch itself rather than an independent
|
|
1421
|
+
// timer of the same period, which would put the datagram ahead of a spawn's
|
|
1422
|
+
// ADD about half the time.
|
|
1423
|
+
const inline = armed && !dedicated;
|
|
1424
|
+
|
|
1425
|
+
this._flushUnreliable = inline
|
|
1426
|
+
? () => { this.broadcastUnreliablePatch(); }
|
|
1427
|
+
: NOOP;
|
|
1428
|
+
|
|
1429
|
+
if (dedicated) {
|
|
1430
|
+
this.#_unreliablePatchInterval = setInterval(() => this.broadcastUnreliablePatch(), rate);
|
|
1431
|
+
|
|
1432
|
+
} else if (inline && !this.#_patchRate) {
|
|
1433
|
+
// patchRate 0/null means no patch tick to piggyback on.
|
|
1434
|
+
logger.warn(
|
|
1435
|
+
"@colyseus/core: state has @unreliable fields but patchRate is disabled —" +
|
|
1436
|
+
" set `room.unreliablePatchRate` to flush them, or they will never update."
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1324
1441
|
/**
|
|
1325
1442
|
* Register a message handler for a specific message type.
|
|
1326
1443
|
* This method is used to handle messages sent by clients to the room.
|
|
@@ -2078,6 +2195,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
2078
2195
|
this.#_patchInterval = undefined;
|
|
2079
2196
|
}
|
|
2080
2197
|
|
|
2198
|
+
if (this.#_unreliablePatchInterval) {
|
|
2199
|
+
clearInterval(this.#_unreliablePatchInterval);
|
|
2200
|
+
this.#_unreliablePatchInterval = undefined;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2081
2203
|
if (this._simulationInterval) {
|
|
2082
2204
|
clearInterval(this._simulationInterval);
|
|
2083
2205
|
this._simulationInterval = undefined;
|
package/src/Transport.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { StateView } from '@colyseus/schema';
|
|
|
8
8
|
import type { InputDecoder } from '@colyseus/schema/input';
|
|
9
9
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
|
-
import {
|
|
11
|
+
import { debugAndPrintError } from './Debug.ts';
|
|
12
|
+
import { getBearerToken, spliceOne } from './utils/Utils.ts';
|
|
12
13
|
import { ServerError } from './errors/ServerError.ts';
|
|
13
14
|
|
|
14
15
|
import type { Room } from './Room.ts';
|
|
@@ -46,12 +47,129 @@ export abstract class Transport {
|
|
|
46
47
|
public bindRouter?(router: Router): void;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Intercepts an incoming WebSocket upgrade request, before the handshake.
|
|
52
|
+
*
|
|
53
|
+
* Return a `Response` to answer the request instead of upgrading it. Return
|
|
54
|
+
* nothing to upgrade as usual. The handler may be async, and the handshake waits
|
|
55
|
+
* for it to resolve.
|
|
56
|
+
*
|
|
57
|
+
* `context` is the same shape `onAuth()` receives, read-only here: mutating it
|
|
58
|
+
* does not carry over to `onAuth()`.
|
|
59
|
+
*
|
|
60
|
+
* Not supported by `H3Transport`: WebTransport has no upgrade handshake.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* new uWebSocketsTransport({
|
|
65
|
+
* beforeUpgrade: async (request, context) => {
|
|
66
|
+
* if (await isBanned(context.ip)) {
|
|
67
|
+
* return new Response(null, { status: 403 });
|
|
68
|
+
* }
|
|
69
|
+
* }
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export type BeforeUpgradeHandler = (
|
|
74
|
+
request: Request,
|
|
75
|
+
context: Readonly<AuthContext>,
|
|
76
|
+
) => Response | void | Promise<Response | void>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Invokes a `beforeUpgrade` handler, resolving with the `Response` to send
|
|
80
|
+
* instead of upgrading, or `undefined` to proceed with the upgrade.
|
|
81
|
+
*
|
|
82
|
+
* Every transport goes through here, so a handler written against one keeps
|
|
83
|
+
* working on the others. Never rejects: uWebSockets.js aborts the process on an
|
|
84
|
+
* upgrade handler that yields without responding, and on the other transports a
|
|
85
|
+
* raw socket left behind leaks a connection.
|
|
86
|
+
*
|
|
87
|
+
* @internal
|
|
88
|
+
*/
|
|
89
|
+
export async function runBeforeUpgrade(
|
|
90
|
+
handler: BeforeUpgradeHandler,
|
|
91
|
+
url: string, // path, optionally including the query string
|
|
92
|
+
context: AuthContext,
|
|
93
|
+
): Promise<Response | undefined> {
|
|
94
|
+
let request: Request;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const host = context.headers.get('host') || 'localhost';
|
|
98
|
+
request = new Request(`http://${host}${url}`, { headers: context.headers });
|
|
99
|
+
|
|
100
|
+
} catch (e: any) {
|
|
101
|
+
// a `Host` header that isn't a valid authority fails to parse as a URL
|
|
102
|
+
debugAndPrintError(e);
|
|
103
|
+
return new Response(null, { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
return (await handler(request, context)) ?? undefined;
|
|
108
|
+
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
debugAndPrintError(e);
|
|
111
|
+
return new Response(null, { status: 500 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Headers as the transport has them: uWebSockets.js and Node give a plain record. */
|
|
116
|
+
type RawHeaders = Headers | Record<string, string | undefined>;
|
|
117
|
+
|
|
118
|
+
const readHeader = (headers: RawHeaders, name: string) =>
|
|
119
|
+
(headers instanceof Headers) ? headers.get(name) : headers[name];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Builds the context passed to `beforeUpgrade` and `onAuth`.
|
|
123
|
+
*
|
|
124
|
+
* Every transport goes through here, so the context is identical everywhere,
|
|
125
|
+
* down to how the client address is resolved. `headers` is materialized on
|
|
126
|
+
* first read: a connection nobody inspects pays nothing for the conversion.
|
|
127
|
+
*
|
|
128
|
+
* @internal
|
|
129
|
+
*/
|
|
130
|
+
export function createAuthContext(options: {
|
|
131
|
+
headers: RawHeaders,
|
|
132
|
+
token?: string | null,
|
|
133
|
+
remoteAddress?: string,
|
|
134
|
+
req?: any,
|
|
135
|
+
}): AuthContext {
|
|
136
|
+
const source = options.headers;
|
|
137
|
+
let headers: Headers | undefined;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
token: options.token ?? getBearerToken(readHeader(source, 'authorization')),
|
|
141
|
+
ip: resolveClientIp(source, options.remoteAddress),
|
|
142
|
+
req: options.req,
|
|
143
|
+
get headers() {
|
|
144
|
+
return headers ??= (source instanceof Headers)
|
|
145
|
+
? source
|
|
146
|
+
: new Headers(source as Record<string, string>);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A single address, resolved the same way on every transport: `x-forwarded-for`
|
|
153
|
+
* carries the whole proxy chain, and only its first entry is the client.
|
|
154
|
+
*/
|
|
155
|
+
function resolveClientIp(headers: RawHeaders, remoteAddress?: string): string | undefined {
|
|
156
|
+
// an empty header counts as absent
|
|
157
|
+
const firstHop = (name: string) => readHeader(headers, name)?.split(',')[0].trim() || undefined;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
firstHop('x-real-ip') ??
|
|
161
|
+
firstHop('x-forwarded-for') ??
|
|
162
|
+
firstHop('x-client-ip') ??
|
|
163
|
+
(remoteAddress || undefined)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
49
167
|
export type AuthContext = {
|
|
50
168
|
token?: string,
|
|
169
|
+
/** Undefined when no proxy header carries it and the transport has no peer address. */
|
|
170
|
+
ip: string | undefined;
|
|
51
171
|
headers: Headers,
|
|
52
|
-
|
|
53
|
-
// FIXME: each transport may have its own specific properties.
|
|
54
|
-
// "req" only applies to WebSocketTransport.
|
|
172
|
+
/** Only set on the HTTP matchmaking request, where it is the `Request` itself. */
|
|
55
173
|
req?: any;
|
|
56
174
|
};
|
|
57
175
|
|
|
@@ -136,6 +254,16 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
|
|
|
136
254
|
raw(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
|
|
137
255
|
enqueueRaw(data: Uint8Array | Buffer, options?: ISendOptions): void;
|
|
138
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Send raw bytes over the transport's UNRELIABLE channel — no delivery,
|
|
259
|
+
* ordering, or duplication guarantee. Used for `@unreliable` state patches.
|
|
260
|
+
*
|
|
261
|
+
* Absent on transports with no datagram channel (every WebSocket transport).
|
|
262
|
+
* Its presence IS the capability check — callers feature-detect rather than
|
|
263
|
+
* reading a separate flag, and skip clients that can't receive.
|
|
264
|
+
*/
|
|
265
|
+
rawUnreliable?(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
|
|
266
|
+
|
|
139
267
|
/**
|
|
140
268
|
* Send a type of message to the client. Messages are encoded with MsgPack and can hold any
|
|
141
269
|
* JSON-serializable data structure.
|
package/src/index.ts
CHANGED
|
@@ -56,7 +56,7 @@ export * from './matchmaker/LocalDriver/LocalDriver.ts';
|
|
|
56
56
|
export { initializeRoomCache } from './matchmaker/driver.ts';
|
|
57
57
|
|
|
58
58
|
// Transport
|
|
59
|
-
export { type Client, type ClientPrivate, type AuthContext, ClientState, ClientArray, Transport, type ISendOptions, connectClientToRoom, enqueueClientRaw } from './Transport.ts';
|
|
59
|
+
export { type Client, type ClientPrivate, type AuthContext, ClientState, ClientArray, Transport, type ISendOptions, type BeforeUpgradeHandler, runBeforeUpgrade, createAuthContext, connectClientToRoom, enqueueClientRaw } from './Transport.ts';
|
|
60
60
|
|
|
61
61
|
// Presence
|
|
62
62
|
export { type Presence } from './presence/Presence.ts';
|
package/src/input/RoomInput.ts
CHANGED
|
@@ -21,6 +21,20 @@ import { debugAndPrintError } from '../Debug.ts';
|
|
|
21
21
|
*/
|
|
22
22
|
const _inputReflectionCache = new WeakMap<Function, Uint8Array>();
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Rebuild one `k`-length series of the unreliable stamp block: `newest` is the
|
|
26
|
+
* absolute anchor, and each wire delta walks one slot older
|
|
27
|
+
* (`out[i] = out[i+1] − Δ`). Mirrors the SDK's `_writeSeriesDeltas`.
|
|
28
|
+
*/
|
|
29
|
+
function readSeriesDeltas(buffer: Buffer, it: Iterator, k: number, newest: number): number[] {
|
|
30
|
+
const out = new Array<number>(k);
|
|
31
|
+
out[k - 1] = newest;
|
|
32
|
+
for (let i = k - 2; i >= 0; i--) {
|
|
33
|
+
out[i] = out[i + 1] - decode.number(buffer, it);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
/**
|
|
25
39
|
* Runtime behind {@link InputAPI}. A class, not a per-`define()` object literal:
|
|
26
40
|
* literal (and `defineProperty`) accessors carry their closure identity in the
|
|
@@ -272,11 +286,70 @@ export class RoomInput {
|
|
|
272
286
|
}
|
|
273
287
|
|
|
274
288
|
/** Decode a `ROOM_INPUT_UNRELIABLE` redundancy ring — each slot carries its
|
|
275
|
-
* framework seq (base seq + position) for ring dedupe, no user seqField.
|
|
276
|
-
|
|
289
|
+
* framework seq (base seq + position) for ring dedupe, no user seqField.
|
|
290
|
+
*
|
|
291
|
+
* With the TIMED bit, a self-contained lag-comp stamp block precedes the ring:
|
|
292
|
+
*
|
|
293
|
+
* [varint k][uint32 newest][varint Δ]×(k−1)
|
|
294
|
+
* [uint16 rdNewest][varint Δrd]×(k−1) ← BOTH mode only
|
|
295
|
+
*
|
|
296
|
+
* One stamp per slot, because a packet carries k inputs sampled at k
|
|
297
|
+
* different instants. The anchor is absolute and the deltas never leave the
|
|
298
|
+
* packet, so — unlike the reliable channel's running baseline — no amount of
|
|
299
|
+
* loss or reordering can desync it, and an input recovered redundantly from a
|
|
300
|
+
* later packet still arrives with its own instant. Stamps are paired
|
|
301
|
+
* positionally with `decodeAll`'s oldest→newest yields; a `k` that disagrees
|
|
302
|
+
* with the decoded slot count means a malformed packet, so the stamps are
|
|
303
|
+
* dropped rather than misapplied (inputs still land, read live). */
|
|
304
|
+
decodeUnreliable(client: ClientPrivate, buffer: Buffer, modifiers: number): void {
|
|
277
305
|
if (!client._inputDecoder) { return; }
|
|
306
|
+
|
|
307
|
+
const it: Iterator = { offset: 1 };
|
|
308
|
+
let stamps: number[] | undefined;
|
|
309
|
+
let renderDeltas: number[] | undefined;
|
|
310
|
+
|
|
311
|
+
if (modifiers & ProtocolModifier.TIMED) {
|
|
312
|
+
this.#resolveWireModes();
|
|
313
|
+
try {
|
|
314
|
+
const k = decode.number(buffer, it);
|
|
315
|
+
stamps = readSeriesDeltas(buffer, it, k, decode.uint32(buffer, it));
|
|
316
|
+
// BOTH mode trails the renderDelta series in the same shape, so each
|
|
317
|
+
// slot keeps the latency term it was actually sampled with.
|
|
318
|
+
if (this.#stampReckon && this.#stampRender) {
|
|
319
|
+
renderDeltas = readSeriesDeltas(buffer, it, k, decode.uint16(buffer, it));
|
|
320
|
+
}
|
|
321
|
+
} catch (e: any) {
|
|
322
|
+
debugAndPrintError(e);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let i = 0;
|
|
278
328
|
try {
|
|
279
|
-
client._inputDecoder.decodeAll(buffer.subarray(
|
|
329
|
+
const count = client._inputDecoder.decodeAll(buffer.subarray(it.offset), (_inst, seq) => {
|
|
330
|
+
// Positional pairing — `decodeAll` yields oldest→newest, the order the
|
|
331
|
+
// block was written in.
|
|
332
|
+
const slot = i++;
|
|
333
|
+
const stamp = stamps?.[slot] ?? 0;
|
|
334
|
+
let renderTime = 0, reckonTime = 0;
|
|
335
|
+
if (stamp > 0) {
|
|
336
|
+
if (this.#stampReckon && this.#stampRender) {
|
|
337
|
+
const rd = renderDeltas?.[slot] ?? 0;
|
|
338
|
+
reckonTime = stamp;
|
|
339
|
+
renderTime = stamp > rd ? stamp - rd : 0;
|
|
340
|
+
} else if (this.#stampReckon) {
|
|
341
|
+
reckonTime = stamp;
|
|
342
|
+
} else {
|
|
343
|
+
renderTime = stamp;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
this.capture(client, renderTime, reckonTime, seq);
|
|
347
|
+
});
|
|
348
|
+
if (stamps !== undefined && count !== stamps.length) {
|
|
349
|
+
debugAndPrintError(new Error(
|
|
350
|
+
`@colyseus/core: unreliable input stamp block declared ${stamps.length} slots, decoded ${count}`
|
|
351
|
+
));
|
|
352
|
+
}
|
|
280
353
|
} catch (e: any) {
|
|
281
354
|
debugAndPrintError(e);
|
|
282
355
|
return;
|