@colyseus/core 0.18.0 → 0.18.1
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/MatchMaker.cjs +3 -7
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.mjs +4 -8
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +3 -3
- package/build/Protocol.cjs.map +1 -1
- package/build/Protocol.d.ts +3 -3
- package/build/Protocol.mjs +3 -3
- package/build/Protocol.mjs.map +1 -1
- package/build/Rewind.cjs +385 -0
- package/build/Rewind.cjs.map +7 -0
- package/build/Rewind.d.ts +291 -0
- package/build/Rewind.mjs +359 -0
- package/build/Rewind.mjs.map +7 -0
- package/build/Room.cjs +280 -281
- package/build/Room.cjs.map +3 -3
- package/build/Room.d.ts +185 -81
- package/build/Room.mjs +286 -286
- package/build/Room.mjs.map +2 -2
- package/build/RoomMessages.cjs +265 -0
- package/build/RoomMessages.cjs.map +7 -0
- package/build/RoomMessages.d.ts +49 -0
- package/build/RoomMessages.mjs +240 -0
- package/build/RoomMessages.mjs.map +7 -0
- package/build/Server.cjs +88 -31
- package/build/Server.cjs.map +2 -2
- package/build/Server.d.ts +36 -0
- package/build/Server.mjs +88 -31
- package/build/Server.mjs.map +2 -2
- package/build/Transport.cjs +26 -0
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +69 -2
- package/build/Transport.mjs +25 -0
- package/build/Transport.mjs.map +2 -2
- package/build/errors/RoomExceptions.cjs +7 -4
- package/build/errors/RoomExceptions.cjs.map +2 -2
- package/build/errors/RoomExceptions.d.ts +15 -3
- package/build/errors/RoomExceptions.mjs +5 -3
- package/build/errors/RoomExceptions.mjs.map +2 -2
- package/build/index.cjs +19 -2
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +7 -4
- package/build/index.mjs +17 -4
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +439 -16
- package/build/input/InputBuffer.cjs.map +2 -2
- package/build/input/InputBuffer.d.ts +186 -96
- package/build/input/InputBuffer.mjs +436 -15
- package/build/input/InputBuffer.mjs.map +2 -2
- package/build/input/RoomInput.cjs +306 -0
- package/build/input/RoomInput.cjs.map +7 -0
- package/build/input/RoomInput.d.ts +91 -0
- package/build/input/RoomInput.mjs +287 -0
- package/build/input/RoomInput.mjs.map +7 -0
- package/build/input/types.cjs +18 -0
- package/build/input/types.cjs.map +7 -0
- package/build/input/types.d.ts +476 -0
- package/build/input/types.mjs +0 -0
- package/build/input/types.mjs.map +7 -0
- package/build/rooms/QueueRoom.cjs +1 -1
- package/build/rooms/QueueRoom.cjs.map +2 -2
- package/build/rooms/QueueRoom.mjs +1 -1
- package/build/rooms/QueueRoom.mjs.map +2 -2
- package/build/router/default_routes.cjs +4 -1
- package/build/router/default_routes.cjs.map +2 -2
- package/build/router/default_routes.mjs +4 -1
- package/build/router/default_routes.mjs.map +2 -2
- package/build/router/index.cjs +6 -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 +37 -2
- package/build/router/node.cjs.map +2 -2
- package/build/router/node.d.ts +10 -0
- package/build/router/node.mjs +35 -1
- package/build/router/node.mjs.map +2 -2
- package/build/serializer/NoneSerializer.cjs +2 -2
- package/build/serializer/NoneSerializer.cjs.map +2 -2
- package/build/serializer/NoneSerializer.d.ts +3 -3
- package/build/serializer/NoneSerializer.mjs +2 -2
- package/build/serializer/NoneSerializer.mjs.map +2 -2
- package/build/serializer/SchemaSerializer.cjs +44 -14
- package/build/serializer/SchemaSerializer.cjs.map +2 -2
- package/build/serializer/SchemaSerializer.d.ts +3 -3
- package/build/serializer/SchemaSerializer.mjs +46 -16
- package/build/serializer/SchemaSerializer.mjs.map +2 -2
- package/build/serializer/Serializer.cjs.map +1 -1
- package/build/serializer/Serializer.d.ts +12 -2
- package/build/utils/DevMode.cjs +14 -14
- package/build/utils/DevMode.cjs.map +2 -2
- package/build/utils/DevMode.d.ts +0 -2
- package/build/utils/DevMode.mjs +14 -12
- package/build/utils/DevMode.mjs.map +2 -2
- package/build/utils/Env.cjs +8 -4
- package/build/utils/Env.cjs.map +3 -3
- package/build/utils/Env.mjs +8 -4
- package/build/utils/Env.mjs.map +2 -2
- package/build/utils/Utils.cjs +3 -0
- package/build/utils/Utils.cjs.map +2 -2
- package/build/utils/Utils.d.ts +8 -0
- package/build/utils/Utils.mjs +2 -0
- package/build/utils/Utils.mjs.map +2 -2
- package/package.json +11 -11
- package/src/MatchMaker.ts +7 -9
- package/src/Protocol.ts +3 -3
- package/src/Rewind.ts +572 -0
- package/src/Room.ts +409 -448
- package/src/RoomMessages.ts +342 -0
- package/src/Server.ts +112 -38
- package/src/Transport.ts +97 -2
- package/src/errors/RoomExceptions.ts +18 -4
- package/src/index.ts +13 -2
- package/src/input/InputBuffer.ts +464 -107
- package/src/input/RoomInput.ts +337 -0
- package/src/input/types.ts +503 -0
- package/src/rooms/QueueRoom.ts +1 -1
- package/src/router/default_routes.ts +6 -1
- package/src/router/index.ts +6 -1
- package/src/router/node.ts +44 -0
- package/src/serializer/NoneSerializer.ts +3 -3
- package/src/serializer/SchemaSerializer.ts +110 -19
- package/src/serializer/Serializer.ts +13 -2
- package/src/utils/DevMode.ts +18 -13
- package/src/utils/Env.ts +12 -4
- package/src/utils/Utils.ts +9 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { decode, type Iterator } from '@colyseus/schema';
|
|
2
|
+
import { unpack } from 'msgpackr';
|
|
3
|
+
|
|
4
|
+
import { CloseCode, ErrorCode, Protocol, ResponseStatus, type MessageContext } from '@colyseus/shared-types';
|
|
5
|
+
import { getMessageBytes } from './Protocol.ts';
|
|
6
|
+
import { createNanoEvents } from './utils/nanoevents.ts';
|
|
7
|
+
import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';
|
|
8
|
+
import { isDevMode } from './utils/DevMode.ts';
|
|
9
|
+
import { debugMessage, debugAndPrintError } from './Debug.ts';
|
|
10
|
+
import { OnMessageException } from './errors/RoomExceptions.ts';
|
|
11
|
+
import type { Client, ClientPrivate } from './Transport.ts';
|
|
12
|
+
import type { Room } from './Room.ts';
|
|
13
|
+
|
|
14
|
+
/** Normalize a thrown value into the `{ name, message, code? }` shape echoed in a
|
|
15
|
+
* ROOM_RESPONSE error reply. */
|
|
16
|
+
function toResponseError(e: any): { name: string; message: string; code?: any } {
|
|
17
|
+
if (e instanceof Error) {
|
|
18
|
+
const code = (e as any).code;
|
|
19
|
+
return (code !== undefined)
|
|
20
|
+
? { name: e.name, message: e.message, code }
|
|
21
|
+
: { name: e.name, message: e.message };
|
|
22
|
+
}
|
|
23
|
+
return { name: "Error", message: String(e) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The handler's terminal action — NOT the wire ResponseStatus: `none` and
|
|
27
|
+
// `resolved` both reply OK (plain return value vs `ctx.resolve(value)`), a
|
|
28
|
+
// distinction a single OK can't carry; ERROR is never a handler decision
|
|
29
|
+
// (thrown/no-handler). Projected onto ResponseStatus once, in onRequest's reply.
|
|
30
|
+
const OUTCOME_NONE = 0, OUTCOME_REJECTED = 1, OUTCOME_RESOLVED = 2;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Runtime {@link MessageContext} for a {@link Protocol.ROOM_REQUEST} dispatch.
|
|
34
|
+
* `reject`/`resolve` record the decision on the ctx; `onRequest` reads it after the
|
|
35
|
+
* handler returns, so a bare side-effecting call works as well as `return
|
|
36
|
+
* ctx.reject(r)` (the branded return is compile-time only — the runtime return is
|
|
37
|
+
* unused).
|
|
38
|
+
*/
|
|
39
|
+
class DispatchContext implements MessageContext {
|
|
40
|
+
readonly id: number | undefined;
|
|
41
|
+
_outcome = OUTCOME_NONE;
|
|
42
|
+
_reason: any = undefined;
|
|
43
|
+
_value: any = undefined;
|
|
44
|
+
|
|
45
|
+
constructor(id: number | undefined) {
|
|
46
|
+
this.id = id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
reject(reason?: any): any {
|
|
50
|
+
this._outcome = OUTCOME_REJECTED;
|
|
51
|
+
this._reason = reason;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
resolve(value?: any): any {
|
|
56
|
+
this._outcome = OUTCOME_RESOLVED;
|
|
57
|
+
this._value = value;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shared no-op context for fire-and-forget `ROOM_DATA` (no reply channel, so
|
|
63
|
+
* `reject`/`resolve` go nowhere). `id` is `undefined`; zero per-message allocation. */
|
|
64
|
+
const SEND_CONTEXT: MessageContext = Object.freeze({
|
|
65
|
+
id: undefined,
|
|
66
|
+
reject: () => undefined as any,
|
|
67
|
+
resolve: () => undefined as any,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-room message-routing layer, owned by {@link Room}. Owns the user-message
|
|
72
|
+
* handler registry (`onMessage`/`onMessageBytes` + per-type validators) and
|
|
73
|
+
* decodes/dispatches the user-message wire frames (ROOM_DATA / ROOM_REQUEST /
|
|
74
|
+
* ROOM_DATA_BYTES). Room's `_onMessage` keeps the protocol/lifecycle frames
|
|
75
|
+
* (JOIN/PING/LEAVE/input) and calls one of these per frame, preserving the
|
|
76
|
+
* original dispatch order. Reads back into its Room only for `onUncaughtException`
|
|
77
|
+
* (handler wrapping) and `roomId`/`roomName` (logging).
|
|
78
|
+
*
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
81
|
+
export class RoomMessages {
|
|
82
|
+
private room: Room<any>;
|
|
83
|
+
|
|
84
|
+
/** Handler registry (nanoevents). Public so Room can re-expose it as
|
|
85
|
+
* `onMessageEvents` for @colyseus/playground introspection and
|
|
86
|
+
* @colyseus/testing handler-swapping. */
|
|
87
|
+
events = createNanoEvents();
|
|
88
|
+
|
|
89
|
+
/** Per-type StandardSchema validators. Public for the same reason as
|
|
90
|
+
* {@link events} (`onMessageValidators`). */
|
|
91
|
+
validators: { [type: string]: StandardSchemaV1 } = {};
|
|
92
|
+
|
|
93
|
+
constructor(room: Room<any>) {
|
|
94
|
+
this.room = room;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register a handler (body of {@link Room.onMessage}). Wraps the callback via
|
|
99
|
+
* the room's `onUncaughtException` when set, stores the validator, and returns
|
|
100
|
+
* the unbind closure.
|
|
101
|
+
*/
|
|
102
|
+
on(
|
|
103
|
+
_messageType: '*' | string | number,
|
|
104
|
+
_validationSchema: StandardSchemaV1 | ((...args: any[]) => void),
|
|
105
|
+
_callback?: (...args: any[]) => void,
|
|
106
|
+
): () => void {
|
|
107
|
+
const messageType = _messageType.toString();
|
|
108
|
+
|
|
109
|
+
const validationSchema = (typeof _callback === 'function')
|
|
110
|
+
? _validationSchema as StandardSchemaV1
|
|
111
|
+
: undefined;
|
|
112
|
+
|
|
113
|
+
const callback = (validationSchema === undefined)
|
|
114
|
+
? _validationSchema as (...args: any[]) => void
|
|
115
|
+
: _callback;
|
|
116
|
+
|
|
117
|
+
const removeListener = this.events.on(messageType, (this.room.onUncaughtException !== undefined)
|
|
118
|
+
? this.#wrapMessageHandler(callback, _messageType)
|
|
119
|
+
: callback);
|
|
120
|
+
|
|
121
|
+
if (validationSchema !== undefined) {
|
|
122
|
+
this.validators[messageType] = validationSchema;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// returns a method to unbind the callback
|
|
126
|
+
return () => {
|
|
127
|
+
removeListener();
|
|
128
|
+
if (this.events.events[messageType].length === 0) {
|
|
129
|
+
delete this.validators[messageType];
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wrap a user message handler so a sync throw or async rejection is reported to
|
|
136
|
+
* `room.onUncaughtException` as an {@link OnMessageException} carrying the correct
|
|
137
|
+
* `client` / `payload` / `type`. The handler is invoked with a different argument
|
|
138
|
+
* shape per dispatch path, so those fields are normalized here rather than relying
|
|
139
|
+
* on positional forwarding (which silently mis-mapped `type` once a context arg was
|
|
140
|
+
* added — see ROOM_DATA/ROOM_REQUEST passing `SEND_CONTEXT`/`ctx`):
|
|
141
|
+
*
|
|
142
|
+
* - typed handler (DATA / REQUEST / BYTES): `(client, payload, ctx?)` → `type` is
|
|
143
|
+
* the registered `_messageType`.
|
|
144
|
+
* - wildcard `'*'` handler: `(client, type, payload)` → `type` is
|
|
145
|
+
* the received message type.
|
|
146
|
+
*
|
|
147
|
+
* The error is swallowed (never rethrown) so a faulty handler can't break dispatch;
|
|
148
|
+
* the handler's return value (or promise) is passed through for the REQUEST path.
|
|
149
|
+
*/
|
|
150
|
+
#wrapMessageHandler(callback: (...args: any[]) => any, registeredType: '*' | string | number) {
|
|
151
|
+
const onError = this.room.onUncaughtException!.bind(this.room);
|
|
152
|
+
const isWildcard = (registeredType === '*');
|
|
153
|
+
|
|
154
|
+
return (...args: any[]): any => {
|
|
155
|
+
const report = (e: Error) => onError(
|
|
156
|
+
new OnMessageException(
|
|
157
|
+
e, e.message,
|
|
158
|
+
args[0], // client
|
|
159
|
+
isWildcard ? args[2] : args[1], // payload
|
|
160
|
+
isWildcard ? args[1] : registeredType, // type
|
|
161
|
+
),
|
|
162
|
+
'onMessage',
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const result = callback(...args);
|
|
167
|
+
return (typeof result?.catch === 'function') ? result.catch(report) : result;
|
|
168
|
+
} catch (e: any) {
|
|
169
|
+
report(e);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Dispatch a `ROOM_DATA` frame: decode type + msgpack payload, validate, emit. */
|
|
175
|
+
onData(client: Client & ClientPrivate, buffer: Buffer, it: Iterator): void {
|
|
176
|
+
const messageType = (decode.stringCheck(buffer, it))
|
|
177
|
+
? decode.string(buffer, it)
|
|
178
|
+
: decode.number(buffer, it);
|
|
179
|
+
|
|
180
|
+
let message;
|
|
181
|
+
try {
|
|
182
|
+
message = (buffer.byteLength > it.offset)
|
|
183
|
+
? unpack(buffer.subarray(it.offset, buffer.byteLength))
|
|
184
|
+
: undefined;
|
|
185
|
+
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.room.roomId);
|
|
186
|
+
|
|
187
|
+
// custom message validation
|
|
188
|
+
if (this.validators[messageType] !== undefined) {
|
|
189
|
+
message = standardValidate(this.validators[messageType], message);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
} catch (e: any) {
|
|
193
|
+
debugAndPrintError(e);
|
|
194
|
+
client.leave(CloseCode.WITH_ERROR);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.events.events[messageType]) {
|
|
199
|
+
this.events.emit(messageType as string, client, message, SEND_CONTEXT);
|
|
200
|
+
|
|
201
|
+
} else if (this.events.events['*']) {
|
|
202
|
+
this.events.emit('*', client, messageType, message);
|
|
203
|
+
|
|
204
|
+
} else {
|
|
205
|
+
this.#noHandler(client, messageType, message);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Dispatch a `ROOM_REQUEST` frame: same handlers as ROOM_DATA, but the client
|
|
210
|
+
* opted into a reply, so echo the `requestId` with the outcome. The handler's
|
|
211
|
+
* reply is decided by what it returns / does to `ctx`: `ctx.reject(reason)` →
|
|
212
|
+
* `REJECTED(reason)`, `ctx.resolve(value)` → `OK(value)`, a thrown error or
|
|
213
|
+
* missing handler → `ERROR`, else `OK(return)`. */
|
|
214
|
+
onRequest(client: Client & ClientPrivate, buffer: Buffer, it: Iterator): void {
|
|
215
|
+
const requestId = decode.number(buffer, it);
|
|
216
|
+
|
|
217
|
+
const messageType = (decode.stringCheck(buffer, it))
|
|
218
|
+
? decode.string(buffer, it)
|
|
219
|
+
: decode.number(buffer, it);
|
|
220
|
+
|
|
221
|
+
let message;
|
|
222
|
+
try {
|
|
223
|
+
message = (buffer.byteLength > it.offset)
|
|
224
|
+
? unpack(buffer.subarray(it.offset, buffer.byteLength))
|
|
225
|
+
: undefined;
|
|
226
|
+
debugMessage("request #%d: '%s' -> %j (roomId: %s)", requestId, messageType, message, this.room.roomId);
|
|
227
|
+
|
|
228
|
+
// custom message validation (shared with the ROOM_DATA path)
|
|
229
|
+
if (this.validators[messageType] !== undefined) {
|
|
230
|
+
message = standardValidate(this.validators[messageType], message);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
} catch (e: any) {
|
|
234
|
+
// Reply with an error so the caller's pending request resolves instead of timing out.
|
|
235
|
+
debugAndPrintError(e);
|
|
236
|
+
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Answered by the FIRST handler registered for the type (emit would discard
|
|
241
|
+
// returns); wildcard handlers have no response contract, so they're ineligible.
|
|
242
|
+
const handler = this.events.events[messageType as string]?.[0];
|
|
243
|
+
|
|
244
|
+
if (handler === undefined) {
|
|
245
|
+
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, {
|
|
246
|
+
name: "no_handler",
|
|
247
|
+
message: `room "${this.room.roomName}" has no onMessage("${messageType}") handler to answer this request.`,
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ctx = new DispatchContext(requestId);
|
|
253
|
+
|
|
254
|
+
// Sync handlers reply in this tick with no promise machinery; only a thenable
|
|
255
|
+
// return defers to the microtask queue. A sync throw (unwrapped handler) and an
|
|
256
|
+
// async rejection both fall to the ERROR reply. With onUncaughtException set the
|
|
257
|
+
// handler is wrapped (swallows its own errors), so it never throws / its promise
|
|
258
|
+
// resolves undefined, and the error reports there.
|
|
259
|
+
let response: any;
|
|
260
|
+
try {
|
|
261
|
+
response = handler(client, message, ctx);
|
|
262
|
+
if (response !== null && typeof response === 'object' && typeof response.then === 'function') {
|
|
263
|
+
response.then(
|
|
264
|
+
(resolved: any) => this.#finalizeRequest(client, requestId, ctx, resolved),
|
|
265
|
+
(e: any) => {
|
|
266
|
+
debugAndPrintError(e);
|
|
267
|
+
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
} catch (e: any) {
|
|
273
|
+
debugAndPrintError(e);
|
|
274
|
+
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.#finalizeRequest(client, requestId, ctx, response);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Finalize a request: project the handler's outcome onto a ROOM_RESPONSE reply —
|
|
282
|
+
* `ctx.reject` → REJECTED(reason), `ctx.resolve(value)` → OK(value), else OK(return). */
|
|
283
|
+
#finalizeRequest(client: Client, requestId: number, ctx: DispatchContext, response: any): void {
|
|
284
|
+
if (ctx._outcome === OUTCOME_REJECTED) {
|
|
285
|
+
this.#replyToRequest(client, requestId, ResponseStatus.REJECTED, ctx._reason);
|
|
286
|
+
} else if (ctx._outcome === OUTCOME_RESOLVED) {
|
|
287
|
+
this.#replyToRequest(client, requestId, ResponseStatus.OK, ctx._value);
|
|
288
|
+
} else {
|
|
289
|
+
this.#replyToRequest(client, requestId, ResponseStatus.OK, response);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Dispatch a `ROOM_DATA_BYTES` frame: raw bytes routed to `_$b`-prefixed handlers. */
|
|
294
|
+
onDataBytes(client: Client & ClientPrivate, buffer: Buffer, it: Iterator): void {
|
|
295
|
+
const messageType = (decode.stringCheck(buffer, it))
|
|
296
|
+
? decode.string(buffer, it)
|
|
297
|
+
: decode.number(buffer, it);
|
|
298
|
+
|
|
299
|
+
let message: any = buffer.subarray(it.offset, buffer.byteLength);
|
|
300
|
+
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.room.roomId);
|
|
301
|
+
|
|
302
|
+
const bytesMessageType = `_$b${messageType}`;
|
|
303
|
+
|
|
304
|
+
// custom message validation
|
|
305
|
+
try {
|
|
306
|
+
if (this.validators[bytesMessageType] !== undefined) {
|
|
307
|
+
message = standardValidate(this.validators[bytesMessageType], message);
|
|
308
|
+
}
|
|
309
|
+
} catch (e: any) {
|
|
310
|
+
debugAndPrintError(e);
|
|
311
|
+
client.leave(CloseCode.WITH_ERROR);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (this.events.events[bytesMessageType]) {
|
|
316
|
+
this.events.emit(bytesMessageType, client, message);
|
|
317
|
+
|
|
318
|
+
} else if (this.events.events['*']) {
|
|
319
|
+
this.events.emit('*', client, messageType, message);
|
|
320
|
+
|
|
321
|
+
} else {
|
|
322
|
+
this.#noHandler(client, messageType, message);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Emit a ROOM_RESPONSE reply immediately. */
|
|
327
|
+
#replyToRequest(client: Client, requestId: number, status: ResponseStatus, payload?: any): void {
|
|
328
|
+
debugMessage("response #%d: status=%d -> %j (roomId: %s)", requestId, status, payload, this.room.roomId);
|
|
329
|
+
client.enqueueRaw(getMessageBytes[Protocol.ROOM_RESPONSE](requestId, status, payload));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** No handler for the type: error the client in dev, drop it in production. */
|
|
333
|
+
#noHandler(client: Client, messageType: string | number, _message: unknown): void {
|
|
334
|
+
const errorMessage = `room onMessage for "${messageType}" not registered.`;
|
|
335
|
+
debugMessage(`${errorMessage} (roomId: ${this.room.roomId})`);
|
|
336
|
+
if (isDevMode) {
|
|
337
|
+
client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
|
|
338
|
+
} else {
|
|
339
|
+
client.leave(CloseCode.WITH_ERROR, errorMessage);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/Server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { greet } from "@colyseus/greeting-banner";
|
|
2
2
|
import type express from 'express';
|
|
3
|
+
import type { Server as HttpServer } from 'node:http';
|
|
3
4
|
|
|
4
5
|
import { debugAndPrintError } from './Debug.ts';
|
|
5
6
|
import * as matchMaker from './MatchMaker.ts';
|
|
@@ -9,13 +10,12 @@ import { type OnCreateOptions, Room } from './Room.ts';
|
|
|
9
10
|
import { Deferred, registerGracefulShutdown, dynamicImport, type Type } from './utils/Utils.ts';
|
|
10
11
|
|
|
11
12
|
import type { Presence } from "./presence/Presence.ts";
|
|
12
|
-
import { LocalPresence } from './presence/LocalPresence.ts';
|
|
13
|
-
import { LocalDriver } from './matchmaker/LocalDriver/LocalDriver.ts';
|
|
14
13
|
|
|
15
14
|
import { setTransport, Transport } from './Transport.ts';
|
|
16
15
|
import { logger, setLogger } from './Logger.ts';
|
|
17
16
|
import { setDevMode, isDevMode } from './utils/DevMode.ts';
|
|
18
17
|
import { type Router, bindRouterToTransport, createRouter } from './router/index.ts';
|
|
18
|
+
import { prereadRequestBodies } from './router/node.ts';
|
|
19
19
|
import { type SDKTypes as SharedSDKTypes } from '@colyseus/shared-types';
|
|
20
20
|
import { getDefaultRouter } from './router/default_routes.ts';
|
|
21
21
|
|
|
@@ -141,8 +141,6 @@ export class Server<
|
|
|
141
141
|
|
|
142
142
|
setDevMode(options.devMode === true);
|
|
143
143
|
|
|
144
|
-
this.presence = options.presence || new LocalPresence();
|
|
145
|
-
this.driver = options.driver || new LocalDriver();
|
|
146
144
|
this.options = options;
|
|
147
145
|
this.greet = greet;
|
|
148
146
|
|
|
@@ -150,12 +148,18 @@ export class Server<
|
|
|
150
148
|
|
|
151
149
|
this.attach(options);
|
|
152
150
|
|
|
151
|
+
// Pass options.presence/driver through as-is (possibly undefined).
|
|
152
|
+
// matchMaker.setup() falls back to getDefaultPresence/getDefaultDriver,
|
|
153
|
+
// which auto-select RedisPresence/RedisDriver on Colyseus Cloud.
|
|
153
154
|
matchMaker.setup(
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
options.presence,
|
|
156
|
+
options.driver,
|
|
156
157
|
options.publicAddress,
|
|
157
158
|
options.selectProcessIdToCreateRoom,
|
|
158
|
-
)
|
|
159
|
+
).then(() => {
|
|
160
|
+
this.presence = matchMaker.presence;
|
|
161
|
+
this.driver = matchMaker.driver;
|
|
162
|
+
});
|
|
159
163
|
|
|
160
164
|
if (gracefullyShutdown) {
|
|
161
165
|
registerGracefulShutdown((err) => this.gracefullyShutdown(true, err));
|
|
@@ -182,20 +186,7 @@ export class Server<
|
|
|
182
186
|
* @param listeningListener
|
|
183
187
|
*/
|
|
184
188
|
public async listen(port: number | string, hostname?: string, backlog?: number, listeningListener?: Function) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (beforeListen) { await beforeListen(); }
|
|
188
|
-
if (database) { await database.boot(); }
|
|
189
|
-
|
|
190
|
-
await this._applyRouterDefaults();
|
|
191
|
-
|
|
192
|
-
if (express) {
|
|
193
|
-
await this._onTransportReady;
|
|
194
|
-
if (this.transport.getExpressApp) {
|
|
195
|
-
const expressApp = await this.transport.getExpressApp();
|
|
196
|
-
await express(expressApp);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
189
|
+
await this._bootServices();
|
|
199
190
|
|
|
200
191
|
//
|
|
201
192
|
// if Colyseus Cloud is detected, use @colyseus/tools to listen
|
|
@@ -240,26 +231,12 @@ export class Server<
|
|
|
240
231
|
await this._onTransportReady;
|
|
241
232
|
|
|
242
233
|
return new Promise<void>((resolve, reject) => {
|
|
243
|
-
// TODO: refactor me!
|
|
244
|
-
// set transport globally, to be used by matchmaking route
|
|
245
|
-
setTransport(this.transport);
|
|
246
|
-
|
|
247
234
|
this.transport.listen(port, hostname, backlog, (err) => {
|
|
248
235
|
if (this.transport.server) {
|
|
249
236
|
this.transport.server.on('error', (err) => reject(err));
|
|
250
237
|
}
|
|
251
238
|
|
|
252
|
-
|
|
253
|
-
if (!this.router) {
|
|
254
|
-
this.router = getDefaultRouter() as unknown as Routes;
|
|
255
|
-
|
|
256
|
-
} else {
|
|
257
|
-
// make sure default routes are included
|
|
258
|
-
// https://github.com/Bekacru/better-call/pull/67
|
|
259
|
-
this.router = this.router.extend({ ...getDefaultRouter().endpoints }) as unknown as Routes;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
bindRouterToTransport(this.transport, this.router, this.options.express !== undefined);
|
|
239
|
+
this.bindRoutes();
|
|
263
240
|
|
|
264
241
|
if (listeningListener) {
|
|
265
242
|
listeningListener(err);
|
|
@@ -275,6 +252,82 @@ export class Server<
|
|
|
275
252
|
});
|
|
276
253
|
}
|
|
277
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Prepare matchmaking and HTTP routes, then return the underlying
|
|
257
|
+
* `http.Server` *without* binding to a port.
|
|
258
|
+
*
|
|
259
|
+
* Use this on serverless platforms that consume an exported HTTP server
|
|
260
|
+
* instead of a listening one. Vercel, for example, drives the request handler
|
|
261
|
+
* of a default-exported server (its Express/Hono WebSocket examples use
|
|
262
|
+
* `export default server`), whereas calling `listen()` there selects Vercel's
|
|
263
|
+
* "captured server" path, which does not invoke Express-style app handlers.
|
|
264
|
+
*
|
|
265
|
+
* The transport must be created with an `http.Server` so it can be exported.
|
|
266
|
+
* Vercel additionally needs `package.json` `"main"` pointing at the entrypoint:
|
|
267
|
+
*
|
|
268
|
+
* ```ts
|
|
269
|
+
* import { createServer } from "node:http";
|
|
270
|
+
* const httpServer = createServer();
|
|
271
|
+
* const server = new Server({
|
|
272
|
+
* transport: new WebSocketTransport({ server: httpServer }),
|
|
273
|
+
* express: (app) => { app.get("/hello", (req, res) => res.json({ ok: true })); },
|
|
274
|
+
* });
|
|
275
|
+
* server.define("my_room", MyRoom);
|
|
276
|
+
* export default await server.serverless(); // no listen()
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
public async serverless(): Promise<HttpServer> {
|
|
280
|
+
await this._bootServices();
|
|
281
|
+
|
|
282
|
+
// transport (and its HTTP server) must be ready before binding routes
|
|
283
|
+
await this._onTransportReady;
|
|
284
|
+
|
|
285
|
+
const server = this.transport.server as HttpServer | undefined;
|
|
286
|
+
if (!server) {
|
|
287
|
+
throw new Error("serverless() requires a transport backed by an http.Server (pass `{ server }` to the transport).");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.bindRoutes();
|
|
291
|
+
|
|
292
|
+
// When the server is consumed via `export default` (rather than listen()),
|
|
293
|
+
// the request body must be available synchronously — buffer it up-front so
|
|
294
|
+
// `req.body` is set before the router runs.
|
|
295
|
+
prereadRequestBodies(server);
|
|
296
|
+
|
|
297
|
+
await matchMaker.accept(this.options.isStandaloneMatchMaker);
|
|
298
|
+
|
|
299
|
+
if (this.greet) {
|
|
300
|
+
greet();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return server;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Set the active transport globally and bind the matchmaking/HTTP routes.
|
|
308
|
+
* Shared by {@link listen} and {@link serverless}.
|
|
309
|
+
*
|
|
310
|
+
* `setTransport()` runs here rather than before `transport.listen()`: the
|
|
311
|
+
* global transport is only read by the matchmaking route handler, which can't
|
|
312
|
+
* run until `bindRouterToTransport()` below registers the route.
|
|
313
|
+
*/
|
|
314
|
+
private bindRoutes() {
|
|
315
|
+
// set transport globally, to be used by the matchmaking route
|
|
316
|
+
setTransport(this.transport);
|
|
317
|
+
|
|
318
|
+
// default router is used if no router is provided
|
|
319
|
+
if (!this.router) {
|
|
320
|
+
this.router = getDefaultRouter() as unknown as Routes;
|
|
321
|
+
|
|
322
|
+
} else {
|
|
323
|
+
// make sure default routes are included
|
|
324
|
+
// https://github.com/Bekacru/better-call/pull/67
|
|
325
|
+
this.router = this.router.extend({ ...getDefaultRouter().endpoints }) as unknown as Routes;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
bindRouterToTransport(this.transport, this.router, this.options.express !== undefined);
|
|
329
|
+
}
|
|
330
|
+
|
|
278
331
|
/**
|
|
279
332
|
* Define a new type of room for matchmaking.
|
|
280
333
|
*
|
|
@@ -332,8 +385,8 @@ export class Server<
|
|
|
332
385
|
await matchMaker.gracefullyShutdown();
|
|
333
386
|
|
|
334
387
|
this.transport.shutdown();
|
|
335
|
-
this.presence
|
|
336
|
-
await this.driver
|
|
388
|
+
this.presence?.shutdown();
|
|
389
|
+
await this.driver?.shutdown();
|
|
337
390
|
|
|
338
391
|
// custom "after shutdown" method
|
|
339
392
|
await this.onShutdownCallback();
|
|
@@ -387,6 +440,27 @@ export class Server<
|
|
|
387
440
|
this.onBeforeShutdownCallback = callback;
|
|
388
441
|
}
|
|
389
442
|
|
|
443
|
+
// Boot sequence shared by listen() and serverless(): user `beforeListen`
|
|
444
|
+
// hook, database boot, framework router defaults (auth/db endpoints), then
|
|
445
|
+
// the express app. serverless() needs this too — its exported server still
|
|
446
|
+
// serves the express callback and auth/database routes.
|
|
447
|
+
private async _bootServices(): Promise<void> {
|
|
448
|
+
const { beforeListen, database, express } = this.options;
|
|
449
|
+
|
|
450
|
+
if (beforeListen) { await beforeListen(); }
|
|
451
|
+
if (database) { await database.boot(); }
|
|
452
|
+
|
|
453
|
+
await this._applyRouterDefaults();
|
|
454
|
+
|
|
455
|
+
if (express) {
|
|
456
|
+
await this._onTransportReady;
|
|
457
|
+
if (this.transport.getExpressApp) {
|
|
458
|
+
const expressApp = await this.transport.getExpressApp();
|
|
459
|
+
await express(expressApp);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
390
464
|
// Extend the user's router with framework-contributed endpoints. An explicit
|
|
391
465
|
// `auth` option wins over the database's `applyRouterDefaults` so a non-DB
|
|
392
466
|
// setup can still auto-mount @colyseus/auth, and a DB user can opt out.
|
package/src/Transport.ts
CHANGED
|
@@ -19,6 +19,11 @@ export function getTransport() { return _transport; }
|
|
|
19
19
|
|
|
20
20
|
export abstract class Transport {
|
|
21
21
|
public protocol?: string;
|
|
22
|
+
/** Self-signed cert SHA-256 hash (byte array), surfaced to clients in the
|
|
23
|
+
* matchmake response so a WebTransport client can pin it via
|
|
24
|
+
* `serverCertificateHashes`. Set by transports that generate their own cert
|
|
25
|
+
* (h3). Undefined for transports using a CA-trusted cert. */
|
|
26
|
+
public fingerprint?: number[];
|
|
22
27
|
public server?: http.Server | https.Server;
|
|
23
28
|
|
|
24
29
|
public abstract listen(port?: number | string, hostname?: string, backlog?: number, listeningListener?: Function): this;
|
|
@@ -186,7 +191,6 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
|
|
|
186
191
|
export interface ClientPrivate {
|
|
187
192
|
readyState: number; // TODO: remove readyState on version 1.0.0. Use only "state" instead.
|
|
188
193
|
_enqueuedMessages?: any[];
|
|
189
|
-
_afterNextPatchQueue: Array<[string | number | Client, ArrayLike<any>]>;
|
|
190
194
|
_joinedAt: number; // "elapsedTime" when the client joined the room.
|
|
191
195
|
|
|
192
196
|
/**
|
|
@@ -217,7 +221,7 @@ export interface ClientPrivate {
|
|
|
217
221
|
* once at join (when the Room called `defineInput()`), so the public API
|
|
218
222
|
* call is a Map lookup + property read with no per-call allocation.
|
|
219
223
|
*/
|
|
220
|
-
_inputAccessor?: import('./input/
|
|
224
|
+
_inputAccessor?: import('./input/types.ts').InputAccessor;
|
|
221
225
|
|
|
222
226
|
/**
|
|
223
227
|
* Used for rate limiting ROOM_INPUT_* packets via maxInputsPerSecond,
|
|
@@ -225,6 +229,90 @@ export interface ClientPrivate {
|
|
|
225
229
|
*/
|
|
226
230
|
_numInputsLastSecond?: number;
|
|
227
231
|
_lastInputTime?: number;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `performance.now()` recorded when the most recent ROOM_INPUT_* packet
|
|
235
|
+
* from this client was received. Drives the per-recipient `lastTReceived`
|
|
236
|
+
* field of the {@link ProtocolModifier.TIMED} state prefix.
|
|
237
|
+
*
|
|
238
|
+
* `0` until the client has sent its first input.
|
|
239
|
+
*/
|
|
240
|
+
_lastInputReceivedAt?: number;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Monotonic count of *reliable* inputs successfully received from this
|
|
244
|
+
* client. Echoed back in the TIMED prefix as `lastInputSeq` so the
|
|
245
|
+
* client can correlate to its own send-time table and compute RTT.
|
|
246
|
+
* Stays at the default `0` until the client sends its first reliable
|
|
247
|
+
* input.
|
|
248
|
+
*
|
|
249
|
+
* Only ROOM_INPUT_RELIABLE bumps this — unreliable's redundant-ring
|
|
250
|
+
* pattern would double-count.
|
|
251
|
+
*/
|
|
252
|
+
_receivedInputCount?: number;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Running baseline for the DELTA-CODED lag-comp stamp on ROOM_INPUT_RELIABLE
|
|
256
|
+
* frames (the {@link ProtocolModifier.TIMED} prefix). Each frame carries only
|
|
257
|
+
* the signed change from the previous stamp; this accumulates them back into
|
|
258
|
+
* the absolute timeline value. Zeroed on (re)connect alongside the SDK's own
|
|
259
|
+
* baseline so the first delta after a reset is absolute. `0` until allocated.
|
|
260
|
+
*/
|
|
261
|
+
_reckonBaseline?: number;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @internal Per-client raw frames staged to ride out right AFTER this client's
|
|
265
|
+
* next state patch — per-client `afterNextPatch` messages. Lazily allocated.
|
|
266
|
+
* Pushed by {@link enqueueClientRaw} (the `afterNextPatch` path), flushed as
|
|
267
|
+
* standalone frames after the patch by {@link Room._flushPendingClientFrames}.
|
|
268
|
+
* Room-level `broadcast` `afterNextPatch` uses the Room's own queue instead, not
|
|
269
|
+
* this buffer.
|
|
270
|
+
*/
|
|
271
|
+
_pendingFrames?: Uint8Array[];
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @internal Back-reference to the Room's "clients with staged frames" list,
|
|
275
|
+
* shared by reference at join. {@link enqueueClientRaw} pushes the client here
|
|
276
|
+
* on its first staged frame of a cycle, so the after-patch flush iterates just
|
|
277
|
+
* those clients rather than scanning every client.
|
|
278
|
+
*/
|
|
279
|
+
_pendingFrameClients?: Array<Client & ClientPrivate>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The framework-level send path shared by every transport's `enqueueRaw` — the
|
|
284
|
+
* single source of truth so each transport implements only the wire-level `raw`.
|
|
285
|
+
* Routes a raw frame by where it should go:
|
|
286
|
+
*
|
|
287
|
+
* - `afterNextPatch` → stage onto the per-client {@link ClientPrivate._pendingFrames}
|
|
288
|
+
* buffer, sent as a standalone frame right AFTER the next state patch; a
|
|
289
|
+
* no-allocation push into a reused array. The client announces itself to the
|
|
290
|
+
* Room's {@link ClientPrivate._pendingFrameClients} list on its first staged
|
|
291
|
+
* frame of a cycle.
|
|
292
|
+
* - before JOIN → buffer in `_enqueuedMessages` until the JOIN_ROOM handshake flushes.
|
|
293
|
+
* - otherwise → send now via the transport's `raw`.
|
|
294
|
+
*
|
|
295
|
+
* @internal
|
|
296
|
+
*/
|
|
297
|
+
export function enqueueClientRaw(
|
|
298
|
+
client: Client & ClientPrivate,
|
|
299
|
+
data: Uint8Array | Buffer,
|
|
300
|
+
options?: ISendOptions,
|
|
301
|
+
): void {
|
|
302
|
+
if (options?.afterNextPatch) {
|
|
303
|
+
let frames = client._pendingFrames;
|
|
304
|
+
if (frames === undefined) { frames = client._pendingFrames = []; }
|
|
305
|
+
if (frames.length === 0) { client._pendingFrameClients?.push(client); } // first frame this cycle
|
|
306
|
+
frames.push(data);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (client.state !== ClientState.JOINED) {
|
|
310
|
+
// During `onJoin` / `onReconnect` the client can't register onMessage
|
|
311
|
+
// handlers yet — buffer until JOIN_ROOM has been sent.
|
|
312
|
+
client._enqueuedMessages?.push(data);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
client.raw(data, options);
|
|
228
316
|
}
|
|
229
317
|
|
|
230
318
|
export class ClientArray<C extends Client = Client> extends Array<C> {
|
|
@@ -236,6 +324,13 @@ export class ClientArray<C extends Client = Client> extends Array<C> {
|
|
|
236
324
|
*/
|
|
237
325
|
private _byId: Map<string, C> = new Map();
|
|
238
326
|
|
|
327
|
+
/** The client for `sessionId`, or `undefined` — O(1). The canonical per-session
|
|
328
|
+
* lookup (mirrors `room.inputs.get(sessionId)`). */
|
|
329
|
+
public get(sessionId: string): C | undefined {
|
|
330
|
+
return this._byId.get(sessionId);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** @deprecated Use {@link get}. */
|
|
239
334
|
public getById(sessionId: string): C | undefined {
|
|
240
335
|
return this._byId.get(sessionId);
|
|
241
336
|
}
|