@colyseus/core 0.18.3 → 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 +13 -5
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.d.ts +10 -4
- package/build/MatchMaker.mjs +12 -5
- 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 +113 -3
- package/build/Room.cjs.map +2 -2
- package/build/Room.d.ts +58 -1
- package/build/Room.mjs +112 -3
- package/build/Room.mjs.map +2 -2
- package/build/RoomMessages.cjs +3 -1
- package/build/RoomMessages.cjs.map +2 -2
- package/build/RoomMessages.d.ts +3 -1
- package/build/RoomMessages.mjs +2 -1
- package/build/RoomMessages.mjs.map +2 -2
- 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 +4 -8
- package/build/router/default_routes.cjs.map +2 -2
- package/build/router/default_routes.mjs +4 -9
- package/build/router/default_routes.mjs.map +2 -2
- package/build/router/index.cjs +7 -1
- package/build/router/index.cjs.map +2 -2
- package/build/router/index.mjs +6 -1
- package/build/router/index.mjs.map +2 -2
- package/build/router/node.cjs +23 -2
- package/build/router/node.cjs.map +2 -2
- package/build/router/node.mjs +22 -2
- package/build/router/node.mjs.map +2 -2
- 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 +2 -2
- package/build/utils/Utils.d.ts +12 -0
- package/build/utils/Utils.mjs.map +2 -2
- package/build/utils/nanoevents.cjs +5 -1
- package/build/utils/nanoevents.cjs.map +2 -2
- package/build/utils/nanoevents.d.ts +3 -1
- package/build/utils/nanoevents.mjs +4 -1
- package/build/utils/nanoevents.mjs.map +2 -2
- package/package.json +8 -7
- package/src/MatchMaker.ts +53 -15
- package/src/Room.ts +145 -4
- package/src/RoomMessages.ts +2 -1
- 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 +4 -9
- package/src/router/index.ts +7 -0
- package/src/router/node.ts +26 -2
- package/src/serializer/SchemaSerializer.ts +146 -1
- package/src/serializer/Serializer.ts +17 -0
- package/src/utils/Utils.ts +18 -0
- package/src/utils/nanoevents.ts +4 -1
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.
|
|
@@ -1778,7 +1895,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1778
1895
|
* Allow the specified client to reconnect into the room. Must be used inside `onLeave()` method.
|
|
1779
1896
|
* If seconds is provided, the reconnection is going to be cancelled after the provided amount of seconds.
|
|
1780
1897
|
*
|
|
1781
|
-
* @param
|
|
1898
|
+
* @param previousClient - The client that is allowed to reconnect into the room.
|
|
1782
1899
|
* @param seconds - The time in seconds that the client is allowed to reconnect into the room.
|
|
1783
1900
|
*
|
|
1784
1901
|
* @returns Deferred<Client> - The differed is a promise like type.
|
|
@@ -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;
|
|
@@ -2170,7 +2292,13 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
2170
2292
|
client.ref.removeListener('close', client.ref['onleave']);
|
|
2171
2293
|
|
|
2172
2294
|
// only effectively close connection when "onLeave" is fulfilled
|
|
2173
|
-
|
|
2295
|
+
const ref = client.ref;
|
|
2296
|
+
this._onLeave(client, closeCode).then(() => {
|
|
2297
|
+
// skip if a successful reconnection has transplanted a new ref (#950)
|
|
2298
|
+
if (client.ref === ref) {
|
|
2299
|
+
(client as any).leave(closeCode, reason);
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2174
2302
|
}
|
|
2175
2303
|
|
|
2176
2304
|
private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
|
|
@@ -2208,14 +2336,27 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
2208
2336
|
}
|
|
2209
2337
|
}
|
|
2210
2338
|
|
|
2339
|
+
//
|
|
2340
|
+
// A successful reconnection has already replaced this client: the replacement
|
|
2341
|
+
// owns the 'leave' accounting from here on.
|
|
2342
|
+
//
|
|
2343
|
+
// This must be checked before looking up `_reconnections`: allowReconnection()
|
|
2344
|
+
// reassigns `previousClient.reconnectionToken` to the replacement's token, so a
|
|
2345
|
+
// late-resuming _onLeave() would otherwise attach a second #_onAfterLeave() to
|
|
2346
|
+
// the replacement's pending reconnection and decrement ccu twice for one join.
|
|
2347
|
+
//
|
|
2348
|
+
// @ts-ignore (client.state may be modified at onLeave())
|
|
2349
|
+
if (client.state === ClientState.RECONNECTED) {
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2211
2353
|
// check for manual "reconnection" flow
|
|
2212
2354
|
if (this._reconnections[client.reconnectionToken]) {
|
|
2213
2355
|
this._reconnections[client.reconnectionToken][1].catch(async () => {
|
|
2214
2356
|
await this.#_onAfterLeave(client, code, method === this.onDrop);
|
|
2215
2357
|
});
|
|
2216
2358
|
|
|
2217
|
-
|
|
2218
|
-
} else if (client.state !== ClientState.RECONNECTED) {
|
|
2359
|
+
} else {
|
|
2219
2360
|
await this.#_onAfterLeave(client, code, method === this.onDrop);
|
|
2220
2361
|
}
|
|
2221
2362
|
}
|
package/src/RoomMessages.ts
CHANGED
|
@@ -88,7 +88,8 @@ export class RoomMessages {
|
|
|
88
88
|
|
|
89
89
|
/** Per-type StandardSchema validators. Public for the same reason as
|
|
90
90
|
* {@link events} (`onMessageValidators`). */
|
|
91
|
-
|
|
91
|
+
// null-prototype: keyed by client-supplied message type (colyseus/colyseus#951)
|
|
92
|
+
validators: { [type: string]: StandardSchemaV1 } = Object.create(null);
|
|
92
93
|
|
|
93
94
|
constructor(room: Room<any>) {
|
|
94
95
|
this.room = room;
|
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;
|
package/src/matchmaker/driver.ts
CHANGED
|
@@ -188,9 +188,10 @@ export interface MatchMakerDriver {
|
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* Empty the room cache. Used for testing purposes only.
|
|
191
|
+
* May be asynchronous — callers must await it.
|
|
191
192
|
* @internal Do not call this method yourself.
|
|
192
193
|
*/
|
|
193
|
-
clear(): void
|
|
194
|
+
clear(): void | Promise<void>;
|
|
194
195
|
|
|
195
196
|
/**
|
|
196
197
|
* Boot the room cache medium (if available).
|
|
@@ -199,6 +200,7 @@ export interface MatchMakerDriver {
|
|
|
199
200
|
|
|
200
201
|
/**
|
|
201
202
|
* Dispose the connection of the room cache medium.
|
|
203
|
+
* May be asynchronous — callers must await it.
|
|
202
204
|
*/
|
|
203
|
-
shutdown(): void
|
|
205
|
+
shutdown(): void | Promise<void>;
|
|
204
206
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createEndpoint
|
|
1
|
+
import { createEndpoint } from "@colyseus/better-call";
|
|
2
|
+
import { createRouter } from "./index.ts";
|
|
2
3
|
import * as matchMaker from "../MatchMaker.ts";
|
|
3
|
-
import {
|
|
4
|
-
import { getTransport } from "../Transport.ts";
|
|
4
|
+
import { createAuthContext, getTransport } from "../Transport.ts";
|
|
5
5
|
|
|
6
6
|
export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName", { method: "POST" }, async (ctx) => {
|
|
7
7
|
// do not accept matchmaking requests if already shutting down
|
|
@@ -20,12 +20,7 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
|
|
|
20
20
|
method,
|
|
21
21
|
roomName,
|
|
22
22
|
clientOptions,
|
|
23
|
-
{
|
|
24
|
-
token: getBearerToken(ctx.request.headers.get('authorization')),
|
|
25
|
-
headers: ctx.request.headers,
|
|
26
|
-
ip: requestHeaders.get('x-forwarded-for') ?? requestHeaders.get('x-client-ip') ?? requestHeaders.get('x-real-ip'),
|
|
27
|
-
req: ctx.request as any,
|
|
28
|
-
},
|
|
23
|
+
createAuthContext({ headers: requestHeaders, req: ctx.request }),
|
|
29
24
|
);
|
|
30
25
|
|
|
31
26
|
//
|
package/src/router/index.ts
CHANGED
|
@@ -146,11 +146,18 @@ export function createRouter<
|
|
|
146
146
|
E extends Record<string, Endpoint>,
|
|
147
147
|
Config extends RouterConfig
|
|
148
148
|
>(endpoints: E, config: Config = {} as Config) {
|
|
149
|
+
const onError = config?.onError;
|
|
149
150
|
return createBetterCallRouter({ ...endpoints }, {
|
|
150
151
|
// better-call's /api/reference page dumps the full API surface
|
|
151
152
|
// unauthenticated — opt back in by passing `openapi` explicitly.
|
|
152
153
|
openapi: { disabled: true },
|
|
153
154
|
...config,
|
|
155
|
+
// Otherwise a malformed body is a 500 plus a stack trace on stderr: log
|
|
156
|
+
// noise any anonymous client can trigger at will. Matched on the message
|
|
157
|
+
// because `onError` receives no request context to test against.
|
|
158
|
+
onError: async (error: unknown) => (error instanceof SyntaxError && error.message.includes('JSON'))
|
|
159
|
+
? Response.json({ error: 'malformed request body' }, { status: 400 })
|
|
160
|
+
: await onError?.(error),
|
|
154
161
|
});
|
|
155
162
|
}
|
|
156
163
|
|
package/src/router/node.ts
CHANGED
|
@@ -13,14 +13,28 @@ import * as matchMaker from '../MatchMaker.ts';
|
|
|
13
13
|
import { setResponse } from '@colyseus/better-call/node';
|
|
14
14
|
import { postMatchmakeMethod } from './default_routes.ts';
|
|
15
15
|
|
|
16
|
+
/** Matchmaking options are small — the cap only stops unbounded buffering. */
|
|
17
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
const badRequest = (status: number, message: string) =>
|
|
20
|
+
Object.assign(new Error(message), { status });
|
|
21
|
+
|
|
16
22
|
function readBody(req: http.IncomingMessage): Promise<any> {
|
|
17
23
|
return new Promise((resolve, reject) => {
|
|
18
24
|
let data = '';
|
|
19
25
|
|
|
20
26
|
req.on('data', (chunk: Buffer | string) => {
|
|
21
27
|
data += chunk.toString();
|
|
28
|
+
if (data.length > MAX_BODY_SIZE) {
|
|
29
|
+
reject(badRequest(413, 'request body too large'));
|
|
30
|
+
req.destroy();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
// JSON.parse throws on a later tick — uncaught here it kills the process.
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
try { resolve(data ? JSON.parse(data) : {}); }
|
|
36
|
+
catch { reject(badRequest(400, 'malformed JSON body')); }
|
|
22
37
|
});
|
|
23
|
-
req.on('end', () => resolve(data ? JSON.parse(data) : {}));
|
|
24
38
|
req.on('error', reject);
|
|
25
39
|
});
|
|
26
40
|
}
|
|
@@ -112,10 +126,20 @@ export function createNodeMatchmakingMiddleware() {
|
|
|
112
126
|
|
|
113
127
|
const [, method, roomName] = match;
|
|
114
128
|
|
|
129
|
+
let body: any;
|
|
130
|
+
try {
|
|
131
|
+
body = await readBody(req);
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
// answer here — next() would report a misleading 404 for a bad body
|
|
134
|
+
res.writeHead(e.status ?? 400, { ...corsHeaders, 'content-type': 'application/json' });
|
|
135
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
try {
|
|
116
140
|
const response = await postMatchmakeMethod({
|
|
117
141
|
params: { method, roomName },
|
|
118
|
-
body
|
|
142
|
+
body,
|
|
119
143
|
headers: req.headers as Record<string, string>,
|
|
120
144
|
request: { headers } as any,
|
|
121
145
|
asResponse: true,
|