@colyseus/core 0.17.0 → 0.17.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/src/Server.ts ADDED
@@ -0,0 +1,325 @@
1
+ import greeting from "@colyseus/greeting-banner";
2
+
3
+ import { debugAndPrintError } from './Debug.ts';
4
+ import * as matchMaker from './MatchMaker.ts';
5
+ import { RegisteredHandler } from './matchmaker/RegisteredHandler.ts';
6
+
7
+ import { type OnCreateOptions, Room } from './Room.ts';
8
+ import { registerGracefulShutdown, type Type } from './utils/Utils.ts';
9
+
10
+ import type { Presence } from "./presence/Presence.ts";
11
+ import { LocalPresence } from './presence/LocalPresence.ts';
12
+ import { LocalDriver } from './matchmaker/LocalDriver/LocalDriver.ts';
13
+
14
+ import { Transport } from './Transport.ts';
15
+ import { logger, setLogger } from './Logger.ts';
16
+ import { setDevMode, isDevMode } from './utils/DevMode.ts';
17
+ import { bindRouterToServer, type Router } from './router/index.ts';
18
+ import { getDefaultRouter } from "./router/default_routes.ts";
19
+
20
+ export type ServerOptions = {
21
+ publicAddress?: string,
22
+ presence?: Presence,
23
+ driver?: matchMaker.MatchMakerDriver,
24
+ transport?: Transport,
25
+ gracefullyShutdown?: boolean,
26
+ logger?: any;
27
+
28
+ /**
29
+ * Custom function to determine which process should handle room creation.
30
+ * Default: assign new rooms the process with least amount of rooms created
31
+ */
32
+ selectProcessIdToCreateRoom?: matchMaker.SelectProcessIdCallback;
33
+
34
+ /**
35
+ * If enabled, rooms are going to be restored in the server-side upon restart,
36
+ * clients are going to automatically re-connect when server reboots.
37
+ *
38
+ * Beware of "schema mismatch" issues. When updating Schema structures and
39
+ * reloading existing data, you may see "schema mismatch" errors in the
40
+ * client-side.
41
+ *
42
+ * (This operation is costly and should not be used in a production
43
+ * environment)
44
+ */
45
+ devMode?: boolean,
46
+
47
+ /**
48
+ * Display greeting message on server start.
49
+ * Default: true
50
+ */
51
+ greet?: boolean,
52
+ };
53
+
54
+ /**
55
+ * Exposed types for the client-side SDK.
56
+ */
57
+ export interface SDKTypes<
58
+ RoomTypes extends Record<string, RegisteredHandler> = any,
59
+ Routes extends Router = any
60
+ > {
61
+ '~rooms': RoomTypes;
62
+ '~routes': Routes;
63
+ }
64
+
65
+ export class Server<
66
+ RoomTypes extends Record<string, RegisteredHandler> = any,
67
+ Routes extends Router = any
68
+ > implements SDKTypes<RoomTypes, Routes> {
69
+ '~rooms': RoomTypes;
70
+ '~routes': Routes;
71
+
72
+ public transport: Transport;
73
+ public router: Routes;
74
+ public options: ServerOptions;
75
+
76
+ protected presence: Presence;
77
+ protected driver: matchMaker.MatchMakerDriver;
78
+
79
+ protected port: number;
80
+ protected greet: boolean;
81
+
82
+ private _originalRoomOnMessage: typeof Room.prototype['_onMessage'] | null = null;
83
+
84
+ constructor(options: ServerOptions = {}) {
85
+ const {
86
+ gracefullyShutdown = true,
87
+ greet = true
88
+ } = options;
89
+
90
+ setDevMode(options.devMode === true);
91
+
92
+ this.presence = options.presence || new LocalPresence();
93
+ this.driver = options.driver || new LocalDriver();
94
+ this.options = options;
95
+ this.greet = greet;
96
+
97
+ this.attach(options);
98
+
99
+ matchMaker.setup(
100
+ this.presence,
101
+ this.driver,
102
+ options.publicAddress,
103
+ options.selectProcessIdToCreateRoom,
104
+ );
105
+
106
+ if (gracefullyShutdown) {
107
+ registerGracefulShutdown((err) => this.gracefullyShutdown(true, err));
108
+ }
109
+
110
+ if (options.logger) {
111
+ setLogger(options.logger);
112
+ }
113
+ }
114
+
115
+ public attach(options: ServerOptions) {
116
+ this.transport = options.transport || this.getDefaultTransport(options);
117
+ delete options.transport;
118
+ }
119
+
120
+ /**
121
+ * Bind the server into the port specified.
122
+ *
123
+ * @param port
124
+ * @param hostname
125
+ * @param backlog
126
+ * @param listeningListener
127
+ */
128
+ public async listen(port: number, hostname?: string, backlog?: number, listeningListener?: Function) {
129
+ this.port = port;
130
+
131
+ //
132
+ // Make sure matchmaker is ready before accepting connections
133
+ // (isDevMode: matchmaker may take extra milliseconds to restore the rooms)
134
+ //
135
+ await matchMaker.accept();
136
+
137
+ /**
138
+ * Greetings!
139
+ */
140
+ if (this.greet) {
141
+ console.log(greeting);
142
+ }
143
+
144
+ return new Promise<void>((resolve, reject) => {
145
+ this.transport.listen(port, hostname, backlog, (err) => {
146
+ const server = this.transport.server;
147
+
148
+ // default router is used if no router is provided
149
+ if (!this.router) {
150
+ this.router = getDefaultRouter() as unknown as Routes;
151
+
152
+ } else {
153
+ // make sure default routes are included
154
+ // https://github.com/Bekacru/better-call/pull/67
155
+ this.router = this.router.extend({ ...getDefaultRouter().endpoints }) as unknown as Routes;
156
+ }
157
+
158
+ if (server) {
159
+ server.on('error', (err) => reject(err));
160
+ bindRouterToServer(server, this.router);
161
+ }
162
+
163
+ if (listeningListener) {
164
+ listeningListener(err);
165
+ }
166
+
167
+ if (err) {
168
+ reject(err);
169
+
170
+ } else {
171
+ resolve();
172
+ }
173
+ });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Define a new type of room for matchmaking.
179
+ *
180
+ * @param name public room identifier for match-making.
181
+ * @param roomClass Room class definition
182
+ * @param defaultOptions default options for `onCreate`
183
+ */
184
+ public define<T extends Type<Room>>(
185
+ roomClass: T,
186
+ defaultOptions?: OnCreateOptions<T>,
187
+ ): RegisteredHandler
188
+ public define<T extends Type<Room>>(
189
+ name: string,
190
+ roomClass: T,
191
+ defaultOptions?: OnCreateOptions<T>,
192
+ ): RegisteredHandler
193
+ public define<T extends Type<Room>>(
194
+ nameOrHandler: string | T,
195
+ handlerOrOptions: T | OnCreateOptions<T>,
196
+ defaultOptions?: OnCreateOptions<T>,
197
+ ): RegisteredHandler {
198
+ const name = (typeof(nameOrHandler) === "string")
199
+ ? nameOrHandler
200
+ : nameOrHandler.name;
201
+
202
+ const roomClass = (typeof(nameOrHandler) === "string")
203
+ ? handlerOrOptions
204
+ : nameOrHandler;
205
+
206
+ const options = (typeof(nameOrHandler) === "string")
207
+ ? defaultOptions
208
+ : handlerOrOptions;
209
+
210
+ return matchMaker.defineRoomType(name, roomClass, options);
211
+ }
212
+
213
+ /**
214
+ * Remove a room definition from matchmaking.
215
+ * This method does not destroy any room. It only dissallows matchmaking
216
+ */
217
+ public removeRoomType(name: string): void {
218
+ matchMaker.removeRoomType(name);
219
+ }
220
+
221
+ public async gracefullyShutdown(exit: boolean = true, err?: Error) {
222
+ if (matchMaker.state === matchMaker.MatchMakerState.SHUTTING_DOWN) {
223
+ return;
224
+ }
225
+
226
+ try {
227
+ // custom "before shutdown" method
228
+ await this.onBeforeShutdownCallback();
229
+
230
+ // this is going to lock all rooms and wait for them to be disposed
231
+ await matchMaker.gracefullyShutdown();
232
+
233
+ this.transport.shutdown();
234
+ this.presence.shutdown();
235
+ await this.driver.shutdown();
236
+
237
+ // custom "after shutdown" method
238
+ await this.onShutdownCallback();
239
+
240
+ } catch (e) {
241
+ debugAndPrintError(`error during shutdown: ${e}`);
242
+
243
+ } finally {
244
+ if (exit) {
245
+ process.exit((err && !isDevMode) ? 1 : 0);
246
+ }
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Add simulated latency between client and server.
252
+ * @param milliseconds round trip latency in milliseconds.
253
+ */
254
+ public simulateLatency(milliseconds: number) {
255
+ if (milliseconds > 0) {
256
+ logger.warn(`📶️❗ Colyseus latency simulation enabled → ${milliseconds}ms latency for round trip.`);
257
+ } else {
258
+ logger.warn(`📶️❗ Colyseus latency simulation disabled.`);
259
+ }
260
+
261
+ const halfwayMS = (milliseconds / 2);
262
+ this.transport.simulateLatency(halfwayMS);
263
+
264
+ if (this._originalRoomOnMessage == null) {
265
+ this._originalRoomOnMessage = Room.prototype['_onMessage'];
266
+ }
267
+
268
+ const originalOnMessage = this._originalRoomOnMessage;
269
+
270
+ Room.prototype['_onMessage'] = milliseconds <= Number.EPSILON ? originalOnMessage : function (this: Room, client, buffer) {
271
+ // uWebSockets.js: duplicate buffer because it is cleared at native layer before the timeout.
272
+ const cachedBuffer = Buffer.from(buffer);
273
+ setTimeout(() => originalOnMessage.call(this, client, cachedBuffer), halfwayMS);
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Register a callback that is going to be executed before the server shuts down.
279
+ * @param callback
280
+ */
281
+ public onShutdown(callback: () => void | Promise<any>) {
282
+ this.onShutdownCallback = callback;
283
+ }
284
+
285
+ public onBeforeShutdown(callback: () => void | Promise<any>) {
286
+ this.onBeforeShutdownCallback = callback;
287
+ }
288
+
289
+ protected getDefaultTransport(_: any): Transport {
290
+ throw new Error("Please provide a 'transport' layer. Default transport not set.");
291
+ }
292
+
293
+ protected onShutdownCallback: () => void | Promise<any> =
294
+ () => Promise.resolve()
295
+
296
+ protected onBeforeShutdownCallback: () => void | Promise<any> =
297
+ () => Promise.resolve()
298
+ }
299
+
300
+ export function defineServer<
301
+ T extends Record<string, RegisteredHandler>,
302
+ R extends Router
303
+ >(
304
+ roomHandlers: T,
305
+ router?: R,
306
+ serverOptions?: ServerOptions
307
+ ): Server<T, R> {
308
+ const server = new Server<T, R>(serverOptions);
309
+
310
+ server.router = router;
311
+
312
+ for (const [name, handler] of Object.entries(roomHandlers)) {
313
+ handler.name = name;
314
+ matchMaker.addRoomType(handler);
315
+ }
316
+
317
+ return server;
318
+ }
319
+
320
+ export function defineRoom<T extends Type<Room>>(
321
+ roomKlass: T,
322
+ defaultOptions?: Parameters<NonNullable<InstanceType<T>['onCreate']>>[0],
323
+ ): RegisteredHandler<T> {
324
+ return new RegisteredHandler(roomKlass, defaultOptions);
325
+ }
package/src/Stats.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { MatchMakerState, presence, processId, state } from './MatchMaker.ts';
2
+
3
+ export type Stats = {
4
+ roomCount: number;
5
+ ccu: number;
6
+ }
7
+
8
+ export let local: Stats = {
9
+ roomCount: 0,
10
+ ccu: 0,
11
+ };
12
+
13
+ export async function fetchAll() {
14
+ // TODO: cache this value to avoid querying too often
15
+ const allStats: Array<Stats & { processId: string }> = [];
16
+ const allProcesses = await presence.hgetall(getRoomCountKey());
17
+
18
+ for (let remoteProcessId in allProcesses) {
19
+ if (remoteProcessId === processId) {
20
+ allStats.push({ processId, roomCount: local.roomCount, ccu: local.ccu, });
21
+
22
+ } else {
23
+ const [roomCount, ccu] = allProcesses[remoteProcessId].split(',').map(Number);
24
+ allStats.push({ processId: remoteProcessId, roomCount, ccu });
25
+ }
26
+ }
27
+
28
+ return allStats;
29
+ }
30
+
31
+ let lastPersisted = 0;
32
+ let persistTimeout = undefined;
33
+ const persistInterval = 1000;
34
+
35
+ export function persist(forceNow: boolean = false) {
36
+ // skip if shutting down
37
+ if (state === MatchMakerState.SHUTTING_DOWN) {
38
+ return;
39
+ }
40
+
41
+ /**
42
+ * Avoid persisting more than once per second.
43
+ */
44
+ const now = Date.now();
45
+
46
+ if (forceNow || (now - lastPersisted > persistInterval)) {
47
+ lastPersisted = now;
48
+ return presence.hset(getRoomCountKey(), processId, `${local.roomCount},${local.ccu}`);
49
+
50
+ } else {
51
+ clearTimeout(persistTimeout);
52
+ persistTimeout = setTimeout(persist, persistInterval);
53
+ }
54
+ }
55
+
56
+ export function reset(_persist: boolean = true) {
57
+ local.roomCount = 0;
58
+ local.ccu = 0;
59
+
60
+ if (_persist) {
61
+ lastPersisted = 0;
62
+ clearTimeout(persistTimeout);
63
+ persist();
64
+ }
65
+
66
+ //
67
+ // Attach local metrics to PM2 (if available)
68
+ //
69
+ import('@pm2/io').then((io) => {
70
+ io.default.metric({ id: 'app/stats/ccu', name: 'ccu', value: () => local.ccu });
71
+ io.default.metric({ id: 'app/stats/roomcount', name: 'roomcount', value: () => local.roomCount });
72
+ }).catch(() => { });
73
+ }
74
+
75
+ export function excludeProcess(_processId: string) {
76
+ return presence.hdel(getRoomCountKey(), _processId);
77
+ }
78
+
79
+ export async function getGlobalCCU() {
80
+ const allStats = await fetchAll();
81
+ return allStats.reduce((prev, next) => prev + next.ccu, 0);
82
+ }
83
+
84
+ /**
85
+ * Auto-persist every minute.
86
+ */
87
+ let autoPersistInterval = undefined;
88
+
89
+ export function setAutoPersistInterval() {
90
+ const interval = 60 * 1000;// 1 minute
91
+
92
+ autoPersistInterval = setInterval(() => {
93
+ const now = Date.now();
94
+
95
+ if (now - lastPersisted > interval) {
96
+ persist();
97
+ }
98
+ }, interval);
99
+ }
100
+
101
+ export function clearAutoPersistInterval() {
102
+ clearInterval(autoPersistInterval);
103
+ }
104
+
105
+ function getRoomCountKey() {
106
+ return 'roomcount';
107
+ }
@@ -0,0 +1,207 @@
1
+ import * as http from 'http';
2
+ import * as https from 'https';
3
+
4
+ import { StateView } from '@colyseus/schema';
5
+ import { EventEmitter } from 'events';
6
+ import { spliceOne } from './utils/Utils.ts';
7
+ import { ServerError } from './errors/ServerError.ts';
8
+ import { ErrorCode } from './Protocol.ts';
9
+ import type { Room } from './Room.ts';
10
+
11
+ export abstract class Transport {
12
+ public protocol?: string;
13
+ public server?: http.Server | https.Server;
14
+
15
+ public abstract listen(port?: number, hostname?: string, backlog?: number, listeningListener?: Function): this;
16
+ public abstract shutdown(): void;
17
+
18
+ public abstract simulateLatency(milliseconds: number): void;
19
+ }
20
+
21
+ export type AuthContext = {
22
+ token?: string,
23
+ headers: Headers,
24
+ ip: string | string[];
25
+ // FIXME: each transport may have its own specific properties.
26
+ // "req" only applies to WebSocketTransport.
27
+ req?: any;
28
+ };
29
+
30
+ export interface ISendOptions {
31
+ afterNextPatch?: boolean;
32
+ }
33
+
34
+ export const ClientState = { JOINING: 0, JOINED: 1, RECONNECTED: 2, LEAVING: 3, CLOSED: 4 } as const;
35
+ export type ClientState = (typeof ClientState)[keyof typeof ClientState];
36
+
37
+ // Helper types to extract properties from the Client type parameter
38
+ type ExtractClientUserData<T> = T extends { userData: infer U } ? U : T;
39
+ type ExtractClientAuth<T> = T extends { auth: infer A } ? A : any;
40
+ type ExtractClientMessages<T> = T extends { messages: infer M } ? M : any;
41
+
42
+ // Helper type to make message required when the message type demands it
43
+ export type MessageArgs<M, Options> =
44
+ unknown extends M ? [message?: M, options?: Options] : // Handle 'any' type (backwards compatibility)
45
+ [M] extends [never] ? [message?: M, options?: Options] :
46
+ [M] extends [void] ? [message?: M, options?: Options] :
47
+ [M] extends [undefined] ? [message?: M, options?: Options] :
48
+ undefined extends M ? [message?: M, options?: Options] :
49
+ [message: M, options?: Options];
50
+
51
+ /**
52
+ * The client instance from the server-side is responsible for the transport layer between the server and the client.
53
+ * It should not be confused with the Client from the client-side SDK, as they have completely different purposes!
54
+ * You operate on client instances from `this.clients`, `Room#onJoin()`, `Room#onLeave()` and `Room#onMessage()`.
55
+ *
56
+ * - This is the raw WebSocket connection coming from the `ws` package. There are more methods available which aren't
57
+ * encouraged to use along with Colyseus.
58
+ */
59
+ export interface Client<T extends { userData?: any, auth?: any, messages?: Record<string | number, any> } = any> {
60
+ '~messages': ExtractClientMessages<T>;
61
+
62
+ ref: EventEmitter;
63
+
64
+ /**
65
+ * @deprecated use `sessionId` instead.
66
+ */
67
+ id: string;
68
+
69
+ /**
70
+ * Unique id per session.
71
+ */
72
+ sessionId: string; // TODO: remove sessionId on version 1.0.0
73
+
74
+ /**
75
+ * Connection state
76
+ */
77
+ state: ClientState;
78
+
79
+ /**
80
+ * Optional: when using `@view()` decorator in your state properties, this will be the view instance for this client.
81
+ */
82
+ view?: StateView;
83
+
84
+ /**
85
+ * User-defined data can be attached to the Client instance through this variable.
86
+ * - Can be used to store custom data about the client's connection. userData is not synchronized with the client,
87
+ * and should be used only to keep player-specific with its connection.
88
+ */
89
+ userData?: ExtractClientUserData<T>;
90
+
91
+ /**
92
+ * auth data provided by your `onAuth`
93
+ */
94
+ auth?: ExtractClientAuth<T>;
95
+
96
+ /**
97
+ * Reconnection token used to re-join the room after onLeave + allowReconnection().
98
+ *
99
+ * IMPORTANT:
100
+ * This is not the full reconnection token the client provides for the server.
101
+ * The format provided by .reconnect() from the client-side must follow: "${roomId}:${reconnectionToken}"
102
+ */
103
+ reconnectionToken: string;
104
+
105
+ // TODO: move these to ClientPrivate
106
+ raw(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
107
+ enqueueRaw(data: Uint8Array | Buffer, options?: ISendOptions): void;
108
+
109
+ /**
110
+ * Send a type of message to the client. Messages are encoded with MsgPack and can hold any
111
+ * JSON-serializable data structure.
112
+ *
113
+ * @param type String or Number identifier the client SDK will use to receive this message
114
+ * @param message Message payload. (automatically encoded with msgpack.)
115
+ * @param options
116
+ */
117
+ send<K extends keyof this['~messages']>(
118
+ type: K,
119
+ ...args: MessageArgs<this['~messages'][K], ISendOptions>
120
+ ): void;
121
+
122
+ /**
123
+ * Send raw bytes to this specific client.
124
+ *
125
+ * @param type String or Number identifier the client SDK will use to receive this message
126
+ * @param bytes Raw byte array payload
127
+ * @param options
128
+ */
129
+ sendBytes(type: string | number, bytes: Buffer | Uint8Array, options?: ISendOptions): void;
130
+
131
+ /**
132
+ * Disconnect this client from the room.
133
+ *
134
+ * @param code Custom close code. Default value is 1000.
135
+ * @param data
136
+ * @see [Leave room](https://docs.colyseus.io/room#leave-room)
137
+ */
138
+ leave(code?: number, data?: string): void;
139
+
140
+ /**
141
+ * @deprecated Use .leave() instead.
142
+ */
143
+ close(code?: number, data?: string): void;
144
+
145
+ /**
146
+ * Triggers `onError` with specified code to the client-side.
147
+ *
148
+ * @param code
149
+ * @param message
150
+ */
151
+ error(code: number, message?: string): void;
152
+ }
153
+
154
+ /**
155
+ * Private properties of the Client instance.
156
+ * Only accessible internally by the framework, should not be encouraged/auto-completed for the user.
157
+ *
158
+ * TODO: refactor this.
159
+ * @private
160
+ */
161
+ export interface ClientPrivate {
162
+ readyState: number; // TODO: remove readyState on version 1.0.0. Use only "state" instead.
163
+ _enqueuedMessages?: any[];
164
+ _afterNextPatchQueue: Array<[string | number | Client, ArrayLike<any>]>;
165
+ _joinedAt: number; // "elapsedTime" when the client joined the room.
166
+
167
+ /**
168
+ * Used for rate limiting via maxMessagesPerSecond.
169
+ */
170
+ _numMessagesLastSecond?: number;
171
+ _lastMessageTime?: number;
172
+ }
173
+
174
+ export class ClientArray<C extends Client = Client> extends Array<C> {
175
+ public getById(sessionId: string): C | undefined {
176
+ return this.find((client) => client.sessionId === sessionId);
177
+ }
178
+
179
+ public delete(client: C): boolean {
180
+ return spliceOne(this, this.indexOf(client));
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Shared internal method to connect a Client into a Room.
186
+ * Validates seat reservation and joins the client to the room.
187
+ *
188
+ * @remarks
189
+ * **⚠️ This is an internal API and not intended for end-user use.**
190
+ *
191
+ * @internal
192
+ */
193
+ export async function connectClientToRoom(
194
+ room: Room | undefined,
195
+ client: Client & ClientPrivate,
196
+ authContext: AuthContext,
197
+ connectionOptions: {
198
+ reconnectionToken?: string;
199
+ skipHandshake?: boolean;
200
+ },
201
+ ): Promise<void> {
202
+ if (!room || !room.hasReservedSeat(client.sessionId, connectionOptions.reconnectionToken)) {
203
+ throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, 'seat reservation expired.');
204
+ }
205
+
206
+ await room['_onJoin'](client, authContext, connectionOptions);
207
+ }