@colyseus/core 0.17.40 → 0.17.42

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 CHANGED
@@ -40,7 +40,7 @@ export type ServerOptions = {
40
40
  *
41
41
  * For uWebSockets transport, this uses the uwebsockets-express module.
42
42
  */
43
- express?: (app: express.Application) => void,
43
+ express?: (app: express.Application) => Promise<void> | void,
44
44
 
45
45
  /**
46
46
  * Custom function to determine which process should handle room creation.
@@ -141,7 +141,7 @@ export class Server<
141
141
  // Initialize Express if callback is provided
142
142
  if (options.express && this.transport.getExpressApp) {
143
143
  const expressApp = await this.transport.getExpressApp();
144
- options.express(expressApp);
144
+ await options.express(expressApp);
145
145
  }
146
146
 
147
147
  // Resolve the promise when the transport is ready
@@ -370,6 +370,40 @@ export class Server<
370
370
  () => Promise.resolve()
371
371
  }
372
372
 
373
+ export type RoomDefinitions = Record<string, RegisteredHandler | Type<Room>>;
374
+
375
+ function isRegisteredHandler(value: RegisteredHandler | Type<Room>): value is RegisteredHandler {
376
+ return value instanceof RegisteredHandler || (
377
+ typeof(value) === "object" &&
378
+ value !== null &&
379
+ 'klass' in (value as object)
380
+ );
381
+ }
382
+
383
+ export function registerRoomDefinitions<T extends RoomDefinitions>(rooms: T): string[] {
384
+ const roomNames: string[] = [];
385
+
386
+ for (const [name, value] of Object.entries(rooms)) {
387
+ if (isRegisteredHandler(value)) {
388
+ value.name = name;
389
+ matchMaker.addRoomType(value);
390
+
391
+ } else {
392
+ matchMaker.defineRoomType(name, value);
393
+ }
394
+
395
+ roomNames.push(name);
396
+ }
397
+
398
+ return roomNames;
399
+ }
400
+
401
+ export function unregisterRoomDefinitions(roomNames: Iterable<string>) {
402
+ for (const roomName of roomNames) {
403
+ matchMaker.removeRoomType(roomName);
404
+ }
405
+ }
406
+
373
407
  export type DefineServerOptions<
374
408
  T extends Record<string, RegisteredHandler>,
375
409
  R extends Router
@@ -385,14 +419,21 @@ export function defineServer<
385
419
  options: DefineServerOptions<T, R>,
386
420
  ): Server<T, R> {
387
421
  const { rooms, routes, ...serverOptions } = options;
388
- const server = new Server<T, R>(serverOptions);
389
422
 
423
+ if (isDevMode) {
424
+ // In dev mode, the Vite plugin manages Server/matchMaker lifecycle.
425
+ // Return a config-only object — no Server instance, no matchMaker.setup().
426
+ return {
427
+ options: serverOptions,
428
+ router: routes,
429
+ '~rooms': rooms,
430
+ } as unknown as Server<T, R>;
431
+ }
432
+
433
+ const server = new Server<T, R>(serverOptions);
390
434
  server.router = routes;
391
435
 
392
- for (const [name, handler] of Object.entries(rooms)) {
393
- handler.name = name;
394
- matchMaker.addRoomType(handler);
395
- }
436
+ registerRoomDefinitions(rooms);
396
437
 
397
438
  return server;
398
439
  }
@@ -402,4 +443,4 @@ export function defineRoom<T extends Type<Room>>(
402
443
  defaultOptions?: Parameters<NonNullable<InstanceType<T>['onCreate']>>[0],
403
444
  ): RegisteredHandler<InstanceType<T>> {
404
445
  return new RegisteredHandler(roomKlass, defaultOptions) as unknown as RegisteredHandler<InstanceType<T>>;
405
- }
446
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export {
11
11
  } from '@colyseus/shared-types';
12
12
 
13
13
  // Core classes
14
- export { Server, defineRoom, defineServer, type ServerOptions, type SDKTypes } from './Server.ts';
14
+ export { Server, defineRoom, defineServer, registerRoomDefinitions, unregisterRoomDefinitions, type RoomDefinitions, type ServerOptions, type SDKTypes } from './Server.ts';
15
15
  export { Room, room, RoomInternalState, validate, type RoomOptions, type MessageHandlerWithFormat, type Messages, type ExtractRoomState, type ExtractRoomMetadata, type ExtractRoomClient } from './Room.ts';
16
16
  export { getMessageBytes } from './Protocol.ts';
17
17
  export { RegisteredHandler } from './matchmaker/RegisteredHandler.ts';
@@ -34,6 +34,7 @@ export {
34
34
  import * as matchMaker from './MatchMaker.ts';
35
35
  export { matchMaker };
36
36
  export { updateLobby, subscribeLobby } from './matchmaker/Lobby.ts';
37
+ export { createNodeMatchmakingMiddleware } from './router/node.ts';
37
38
 
38
39
  // Driver
39
40
  export * from './matchmaker/LocalDriver/LocalDriver.ts';
@@ -53,7 +54,7 @@ export { SchemaSerializer } from './serializer/SchemaSerializer.ts';
53
54
  // Utilities
54
55
  export { Clock, Delayed };
55
56
  export { generateId, Deferred, spliceOne, getBearerToken, dynamicImport } from './utils/Utils.ts';
56
- export { isDevMode } from './utils/DevMode.ts';
57
+ export { isDevMode, setDevMode } from './utils/DevMode.ts';
57
58
 
58
59
  // IPC
59
60
  export { subscribeIPC, requestFromIPC } from './IPC.ts';
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Raw Node.js adapter for Colyseus matchmaking routes used by `colyseus/vite`.
3
+ *
4
+ * This file exists specifically so the Vite plugin can share Vite's dev HTTP
5
+ * server while still exposing the Colyseus `/matchmake/*` endpoints.
6
+ *
7
+ * Keep the matchmaking behavior itself in `router/default_routes.ts` and use
8
+ * this file only as the thin raw Node/Express adapter around it.
9
+ */
10
+ import type http from 'http';
11
+ import { URL } from 'url';
12
+ import * as matchMaker from '../MatchMaker.ts';
13
+ import { setResponse } from '@colyseus/better-call/node';
14
+ import { postMatchmakeMethod } from './default_routes.ts';
15
+
16
+ function readBody(req: http.IncomingMessage): Promise<any> {
17
+ return new Promise((resolve, reject) => {
18
+ let data = '';
19
+
20
+ req.on('data', (chunk: Buffer | string) => {
21
+ data += chunk.toString();
22
+ });
23
+ req.on('end', () => resolve(data ? JSON.parse(data) : {}));
24
+ req.on('error', reject);
25
+ });
26
+ }
27
+
28
+ function getCorsHeaders(req: http.IncomingMessage, headers?: Headers): Record<string, string> {
29
+ return {
30
+ ...matchMaker.controller.DEFAULT_CORS_HEADERS,
31
+ ...matchMaker.controller.getCorsHeaders(headers),
32
+ };
33
+ }
34
+
35
+ export function createNodeMatchmakingMiddleware() {
36
+ return async (
37
+ req: http.IncomingMessage,
38
+ res: http.ServerResponse,
39
+ next: () => void,
40
+ ) => {
41
+ const url = new URL(req.url || '/', 'http://localhost');
42
+ const isMatchmakeRoute = url.pathname.startsWith(`/${matchMaker.controller.matchmakeRoute}/`);
43
+
44
+ if (!isMatchmakeRoute) {
45
+ next();
46
+ return;
47
+ }
48
+
49
+ const headers = new Headers(req.headers as Record<string, string>);
50
+ const corsHeaders = getCorsHeaders(req, headers);
51
+
52
+ if (req.method === 'OPTIONS') {
53
+ res.writeHead(204, corsHeaders);
54
+ res.end();
55
+ return;
56
+ }
57
+
58
+ if (req.method !== 'POST') {
59
+ next();
60
+ return;
61
+ }
62
+
63
+ const match = url.pathname.match(/^\/matchmake\/(\w+)\/(.+)/);
64
+ if (!match) {
65
+ next();
66
+ return;
67
+ }
68
+
69
+ const [, method, roomName] = match;
70
+
71
+ try {
72
+ const response = await postMatchmakeMethod({
73
+ params: { method, roomName },
74
+ body: await readBody(req),
75
+ headers: req.headers as Record<string, string>,
76
+ request: { headers } as any,
77
+ asResponse: true,
78
+ });
79
+
80
+ await setResponse(res, response);
81
+
82
+ } catch {
83
+ // Endpoint-level failures are returned as Response when `asResponse` is true.
84
+ // Any thrown error here is unexpected, so let the next middleware decide.
85
+ next();
86
+ }
87
+ };
88
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { type Schema, MapSchema, ArraySchema, SetSchema, CollectionSchema, $childType } from '@colyseus/schema';
3
+ import { type Schema, MapSchema, ArraySchema, SetSchema, CollectionSchema, $childType, $changes } from '@colyseus/schema';
4
4
  import { logger } from '../Logger.ts';
5
5
  import { debugAndPrintError, debugDevMode } from '../Debug.ts';
6
6
  import { getLocalRoomById, handleCreateRoom, presence, remoteRoomCall } from '../MatchMaker.ts';
@@ -44,19 +44,33 @@ export async function reloadFromCache() {
44
44
  logger.debug(`📋 room '${roomId}' state =>`, rawState);
45
45
 
46
46
  (recreatedRoom.state as Schema).restore(rawState);
47
+
48
+ // Restore the encoder's nextUniqueId so refIds increase
49
+ // monotonically across HMR cycles. Without this, restore()
50
+ // always produces the same refIds (0,1,2,3...) and onJoin()
51
+ // always assigns the same next refIds (4,5...), causing the
52
+ // client decoder to reuse stale instances on the 2nd+ cycle.
53
+ if (roomHistory.nextRefId !== undefined) {
54
+ const encoderRoot = recreatedRoom.state[$changes]?.root;
55
+ if (encoderRoot && roomHistory.nextRefId > encoderRoot['nextUniqueId']) {
56
+ encoderRoot['nextUniqueId'] = roomHistory.nextRefId;
57
+ }
58
+ }
47
59
  } catch (e: any) {
48
60
  debugAndPrintError(`❌ couldn't restore room '${roomId}' state:\n${e.stack}`);
49
61
  }
50
62
  }
51
63
 
52
- // Reserve seats for clients from cached history
64
+ // Reserve seats for clients from cached history.
65
+ // Skip entries without a reconnectionToken — these are stale
66
+ // seats from allowReconnection() where the client already left
67
+ // (e.g. page refresh). Restoring them would block room disposal.
53
68
  if (roomHistory.clients) {
54
69
  for (const clientData of roomHistory.clients) {
55
- // TODO: need to restore each client's StateView as well
56
- // reserve seat for 20 seconds
57
70
  const { sessionId, reconnectionToken } = clientData;
58
- console.log("reserving seat for client", { sessionId, reconnectionToken });
59
- await remoteRoomCall(recreatedRoomListing.roomId, '_reserveSeat', [sessionId, {}, {}, 20, false, reconnectionToken]);
71
+ if (!reconnectionToken) { continue; }
72
+ // TODO: need to restore each client's StateView as well
73
+ await remoteRoomCall(recreatedRoomListing.roomId, '_reserveSeat', [sessionId, {}, {}, recreatedRoom.seatReservationTimeout, false, reconnectionToken]);
60
74
  }
61
75
  }
62
76
 
@@ -86,6 +100,15 @@ export async function cacheRoomHistory(rooms: { [roomId: string]: Room }) {
86
100
 
87
101
  if (room.state) {
88
102
  roomHistory["state"] = JSON.stringify(room.state);
103
+
104
+ // Cache the encoder's nextUniqueId so it can be restored.
105
+ // This ensures refIds increase monotonically across HMR cycles,
106
+ // preventing the client decoder from reusing stale refs that
107
+ // happen to have the same refId as newly created instances.
108
+ const encoderRoot = room.state[$changes]?.root;
109
+ if (encoderRoot) {
110
+ roomHistory["nextRefId"] = encoderRoot['nextUniqueId'];
111
+ }
89
112
  }
90
113
 
91
114
  // cache active clients with their reconnection tokens
@@ -190,8 +190,8 @@ export function dynamicImport<T = any>(moduleName: string): Promise<T> {
190
190
  }
191
191
  } else {
192
192
  // ESM context - use import()
193
- const promise = import(moduleName);
193
+ const promise = import(/* @vite-ignore */ moduleName);
194
194
  promise.catch(() => {}); // prevent unhandled rejection warnings
195
195
  return promise;
196
196
  }
197
- }
197
+ }