@colyseus/core 0.18.4 → 0.18.6
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 +48 -1
- package/build/router/index.cjs.map +3 -3
- package/build/router/index.d.ts +6 -2
- package/build/router/index.mjs +47 -1
- package/build/router/index.mjs.map +2 -2
- 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 +5 -5
- 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/router/index.ts +72 -3
- package/src/serializer/SchemaSerializer.ts +146 -1
- package/src/serializer/Serializer.ts +17 -0
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;
|
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,8 +1,7 @@
|
|
|
1
1
|
import { createEndpoint } from "@colyseus/better-call";
|
|
2
2
|
import { createRouter } from "./index.ts";
|
|
3
3
|
import * as matchMaker from "../MatchMaker.ts";
|
|
4
|
-
import {
|
|
5
|
-
import { getTransport } from "../Transport.ts";
|
|
4
|
+
import { createAuthContext, getTransport } from "../Transport.ts";
|
|
6
5
|
|
|
7
6
|
export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName", { method: "POST" }, async (ctx) => {
|
|
8
7
|
// do not accept matchmaking requests if already shutting down
|
|
@@ -21,12 +20,7 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
|
|
|
21
20
|
method,
|
|
22
21
|
roomName,
|
|
23
22
|
clientOptions,
|
|
24
|
-
{
|
|
25
|
-
token: getBearerToken(ctx.request.headers.get('authorization')),
|
|
26
|
-
headers: ctx.request.headers,
|
|
27
|
-
ip: requestHeaders.get('x-forwarded-for') ?? requestHeaders.get('x-client-ip') ?? requestHeaders.get('x-real-ip'),
|
|
28
|
-
req: ctx.request as any,
|
|
29
|
-
},
|
|
23
|
+
createAuthContext({ headers: requestHeaders, req: ctx.request }),
|
|
30
24
|
);
|
|
31
25
|
|
|
32
26
|
//
|
package/src/router/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type express from "express";
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
6
|
import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint, createMiddleware, APIError } from "@colyseus/better-call";
|
|
5
7
|
import { toNodeHandler, getRequest, setResponse } from "@colyseus/better-call/node";
|
|
@@ -250,8 +252,12 @@ export function dualModeEndpoints<E extends Record<string, Endpoint>>(
|
|
|
250
252
|
opts: {
|
|
251
253
|
/** Key in `endpoints` whose path is a catch-all. Excluded from `specificRouter` so it doesn't eat fall-through decisions. */
|
|
252
254
|
catchAllKey?: keyof E;
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
+
/** Endpoint-path prefix baked into `endpoints` (`''`, `'/monitor'`). Used by the default middleware to rebase path mounts. */
|
|
256
|
+
prefix?: string;
|
|
257
|
+
/** SPA dist dir — the default middleware dispatches catch-all requests only for files that exist here. */
|
|
258
|
+
staticDir?: string;
|
|
259
|
+
/** Build a custom express middleware given the pre-built routers + node handlers. Omit for the default SPA-panel middleware. */
|
|
260
|
+
buildMiddleware?: (helpers: DualModeHelpers) => ExpressMiddleware;
|
|
255
261
|
},
|
|
256
262
|
): ExpressMiddleware & E {
|
|
257
263
|
const fullRouter = createRouter(endpoints);
|
|
@@ -265,8 +271,71 @@ export function dualModeEndpoints<E extends Record<string, Endpoint>>(
|
|
|
265
271
|
const specificRouter = createRouter(specificEndpoints as E);
|
|
266
272
|
const specificHandler = toNodeHandler(specificRouter.handler) as NodeHandler;
|
|
267
273
|
|
|
268
|
-
const
|
|
274
|
+
const buildMiddleware = opts.buildMiddleware ?? panelMiddleware(opts.prefix ?? '', opts.staticDir);
|
|
275
|
+
const middleware = buildMiddleware({
|
|
269
276
|
specificRouter, specificHandler, fullRouter, fullHandler,
|
|
270
277
|
});
|
|
271
278
|
return Object.assign(middleware, endpoints) as ExpressMiddleware & E;
|
|
272
279
|
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Default express middleware for a single-prefix SPA panel (monitor,
|
|
283
|
+
* playground). Express and the endpoint map use different coordinates: a path
|
|
284
|
+
* mount strips its prefix into `req.baseUrl`, while the endpoints keep the
|
|
285
|
+
* configured `prefix` baked into their paths. This rebases every request onto
|
|
286
|
+
* the endpoint namespace, so the panel works at any mount path — `prefix` only
|
|
287
|
+
* matters in router mode and at root mounts.
|
|
288
|
+
*/
|
|
289
|
+
function panelMiddleware(prefix: string, staticDir?: string) {
|
|
290
|
+
const staticRoot = staticDir && path.resolve(staticDir);
|
|
291
|
+
return ({ specificRouter, specificHandler, fullHandler }: DualModeHelpers): ExpressMiddleware =>
|
|
292
|
+
(req, res, next) => {
|
|
293
|
+
const r = req as IncomingMessage & { baseUrl?: string; originalUrl?: string };
|
|
294
|
+
const raw = r.url ?? '';
|
|
295
|
+
const q = raw.indexOf('?');
|
|
296
|
+
const url = q < 0 ? raw : raw.slice(0, q);
|
|
297
|
+
const query = q < 0 ? '' : raw.slice(q);
|
|
298
|
+
|
|
299
|
+
// Path mounts arrive stripped (`req.url` is mount-relative); root and
|
|
300
|
+
// pathless mounts arrive unstripped and already match the endpoints.
|
|
301
|
+
const dispatchPath = r.baseUrl ? prefix + url : url;
|
|
302
|
+
|
|
303
|
+
// Canonicalize the index: the SPA references its assets relatively, so
|
|
304
|
+
// they only resolve from the trailing-slash URL. The browser's address
|
|
305
|
+
// is originalUrl — 302 (not 301: browsers cache 301s across remounts).
|
|
306
|
+
if (dispatchPath === prefix || dispatchPath === `${prefix}/`) {
|
|
307
|
+
const original = (r.originalUrl ?? raw).split('?')[0]!;
|
|
308
|
+
if (r.method === 'GET' && !original.endsWith('/')) {
|
|
309
|
+
res.writeHead(302, { location: `${original}/${query}` });
|
|
310
|
+
res.end();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const dispatch = (handler: NodeHandler) => {
|
|
316
|
+
// better-call's getRequest resolves the path as baseUrl + url
|
|
317
|
+
const wrapped = Object.create(req, {
|
|
318
|
+
url: { value: dispatchPath + query, enumerable: true, configurable: true },
|
|
319
|
+
baseUrl: { value: '', enumerable: true, configurable: true },
|
|
320
|
+
originalUrl: { value: dispatchPath + query, enumerable: true, configurable: true },
|
|
321
|
+
});
|
|
322
|
+
handler(wrapped as any, res).catch(next);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const route = specificRouter.findRoute(r.method ?? 'GET', dispatchPath);
|
|
326
|
+
// rou3 normalizes trailing slashes in findRoute but processRequest
|
|
327
|
+
// exact-matches — only trust the hit when the slashes agree.
|
|
328
|
+
if (route && (!route.data?.path?.endsWith('/') || dispatchPath.endsWith('/'))) {
|
|
329
|
+
return dispatch(specificHandler);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Asset request — only delegate when the file exists on disk, so the
|
|
333
|
+
// catch-all's SPA fallback can't mask sibling express routes.
|
|
334
|
+
if (r.method !== 'GET' || !staticRoot || !dispatchPath.startsWith(prefix)) { return next(); }
|
|
335
|
+
const rel = dispatchPath.slice(prefix.length).replace(/^\/+/, '');
|
|
336
|
+
if (!rel || rel.includes('..')) { return next(); }
|
|
337
|
+
const filePath = path.resolve(staticRoot, rel);
|
|
338
|
+
if (!filePath.startsWith(staticRoot + path.sep)) { return next(); }
|
|
339
|
+
fs.stat(filePath).then((stat) => stat.isFile() ? dispatch(fullHandler) : next()).catch(() => next());
|
|
340
|
+
};
|
|
341
|
+
}
|