@colyseus/core 0.17.42 → 0.18.0

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.
Files changed (90) hide show
  1. package/build/MatchMaker.cjs +19 -6
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.d.ts +10 -0
  4. package/build/MatchMaker.mjs +18 -6
  5. package/build/MatchMaker.mjs.map +2 -2
  6. package/build/Protocol.cjs +102 -37
  7. package/build/Protocol.cjs.map +2 -2
  8. package/build/Protocol.d.ts +33 -2
  9. package/build/Protocol.mjs +102 -37
  10. package/build/Protocol.mjs.map +2 -2
  11. package/build/Room.cjs +296 -19
  12. package/build/Room.cjs.map +3 -3
  13. package/build/Room.d.ts +186 -3
  14. package/build/Room.mjs +303 -21
  15. package/build/Room.mjs.map +3 -3
  16. package/build/RoomPlugin.cjs +252 -0
  17. package/build/RoomPlugin.cjs.map +7 -0
  18. package/build/RoomPlugin.d.ts +271 -0
  19. package/build/RoomPlugin.mjs +220 -0
  20. package/build/RoomPlugin.mjs.map +7 -0
  21. package/build/Server.cjs +40 -7
  22. package/build/Server.cjs.map +2 -2
  23. package/build/Server.d.ts +25 -0
  24. package/build/Server.mjs +41 -8
  25. package/build/Server.mjs.map +2 -2
  26. package/build/Transport.cjs +38 -2
  27. package/build/Transport.cjs.map +2 -2
  28. package/build/Transport.d.ts +40 -4
  29. package/build/Transport.mjs +38 -2
  30. package/build/Transport.mjs.map +2 -2
  31. package/build/index.cjs +11 -2
  32. package/build/index.cjs.map +2 -2
  33. package/build/index.d.ts +2 -1
  34. package/build/index.mjs +12 -2
  35. package/build/index.mjs.map +2 -2
  36. package/build/input/InputBuffer.cjs +113 -0
  37. package/build/input/InputBuffer.cjs.map +7 -0
  38. package/build/input/InputBuffer.d.ts +136 -0
  39. package/build/input/InputBuffer.mjs +86 -0
  40. package/build/input/InputBuffer.mjs.map +7 -0
  41. package/build/internal.cjs +61 -0
  42. package/build/internal.cjs.map +7 -0
  43. package/build/internal.d.ts +9 -0
  44. package/build/internal.mjs +29 -0
  45. package/build/internal.mjs.map +7 -0
  46. package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
  47. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
  48. package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
  49. package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
  50. package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
  51. package/build/matchmaker/driver.cjs.map +1 -1
  52. package/build/matchmaker/driver.d.ts +12 -0
  53. package/build/matchmaker/driver.mjs.map +1 -1
  54. package/build/presence/LocalPresence.d.ts +1 -1
  55. package/build/rooms/LobbyRoom.cjs +8 -10
  56. package/build/rooms/LobbyRoom.cjs.map +2 -2
  57. package/build/rooms/LobbyRoom.d.ts +4 -3
  58. package/build/rooms/LobbyRoom.mjs +8 -10
  59. package/build/rooms/LobbyRoom.mjs.map +2 -2
  60. package/build/rooms/RelayRoom.cjs +12 -16
  61. package/build/rooms/RelayRoom.cjs.map +2 -2
  62. package/build/rooms/RelayRoom.d.ts +32 -11
  63. package/build/rooms/RelayRoom.mjs +10 -16
  64. package/build/rooms/RelayRoom.mjs.map +2 -2
  65. package/build/router/index.cjs +65 -4
  66. package/build/router/index.cjs.map +2 -2
  67. package/build/router/index.d.ts +30 -6
  68. package/build/router/index.mjs +66 -6
  69. package/build/router/index.mjs.map +3 -3
  70. package/build/utils/UserSessionIndex.cjs +162 -0
  71. package/build/utils/UserSessionIndex.cjs.map +7 -0
  72. package/build/utils/UserSessionIndex.d.ts +166 -0
  73. package/build/utils/UserSessionIndex.mjs +130 -0
  74. package/build/utils/UserSessionIndex.mjs.map +7 -0
  75. package/package.json +19 -14
  76. package/src/MatchMaker.ts +40 -6
  77. package/src/Protocol.ts +130 -59
  78. package/src/Room.ts +475 -22
  79. package/src/RoomPlugin.ts +563 -0
  80. package/src/Server.ts +72 -11
  81. package/src/Transport.ts +76 -8
  82. package/src/index.ts +10 -1
  83. package/src/input/InputBuffer.ts +192 -0
  84. package/src/internal.ts +46 -0
  85. package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
  86. package/src/matchmaker/driver.ts +13 -0
  87. package/src/rooms/LobbyRoom.ts +12 -8
  88. package/src/rooms/RelayRoom.ts +9 -15
  89. package/src/router/index.ts +112 -11
  90. package/src/utils/UserSessionIndex.ts +311 -0
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Room plugins.
3
+ *
4
+ * A plugin is a composable extension to a Room — it can declare message
5
+ * handlers, attach lifecycle hooks, and expose public methods that the
6
+ * room can call (`this.plugins.<key>.someMethod()`). The framework
7
+ * instantiates one plugin per room, sets `this.room` after the room is
8
+ * constructed, then merges the plugin's `messages` with the room's own
9
+ * and wires its lifecycle hooks alongside the room's.
10
+ *
11
+ * Example: a tiny chat plugin contributing a single message handler:
12
+ *
13
+ * class ChatPlugin extends RoomPlugin {
14
+ * private history: string[] = [];
15
+ * constructor(private opts: { historyLimit: number }) { super(); }
16
+ *
17
+ * messages = {
18
+ * chat: (client: Client, msg: { text: string }) => {
19
+ * this.history.push(msg.text);
20
+ * if (this.history.length > this.opts.historyLimit) this.history.shift();
21
+ * this.room.broadcast('chat', { from: client.userId, text: msg.text });
22
+ * },
23
+ * };
24
+ *
25
+ * // Public — callable as `this.plugins.chat.getHistory()` from the room
26
+ * getHistory(): readonly string[] { return this.history; }
27
+ * }
28
+ *
29
+ * Use via `definePlugins`:
30
+ *
31
+ * class MyRoom extends Room<{ state: MyState }> {
32
+ * plugins = definePlugins({
33
+ * chat: new ChatPlugin({ historyLimit: 100 }),
34
+ * });
35
+ * }
36
+ */
37
+ import type { Room } from './Room.ts';
38
+ import type { AuthContext, Client } from './Transport.ts';
39
+ import type { Messages } from '@colyseus/shared-types';
40
+
41
+ /**
42
+ * Ordering hint for a plugin's lifecycle hook relative to the room's own
43
+ * hook. Sensible per-hook defaults are applied when omitted (see
44
+ * `Room.__init` for the exact ordering policy).
45
+ *
46
+ * onCreate / onAuth / onJoin → plugins run BEFORE room (guards + setup)
47
+ * onLeave / onDispose → plugins run AFTER room (capture final state)
48
+ */
49
+ export interface RoomPluginOrder {
50
+ onCreate?: 'before' | 'after';
51
+ onAuth?: 'before' | 'after';
52
+ onJoin?: 'before' | 'after';
53
+ onLeave?: 'before' | 'after';
54
+ onDispose?: 'before' | 'after';
55
+ }
56
+
57
+ /**
58
+ * Base class for room plugins. Subclass to define a plugin; the framework
59
+ * sets `this.room` after the host room is fully constructed.
60
+ *
61
+ * Don't access `this.room` from the plugin's constructor — it hasn't been
62
+ * wired yet. Everything room-dependent goes in `onCreate` / `onJoin` /
63
+ * etc. or in public methods that are called from the room post-init.
64
+ *
65
+ * @typeParam This - The Room subclass this plugin is attached to. Narrow
66
+ * for schema-driven plugins that need a specific state shape, e.g.
67
+ * `class PhysicsPlugin extends RoomPlugin<Room<{ state: PhysicsContract }>>`.
68
+ */
69
+ export abstract class RoomPlugin<This extends Room = Room> {
70
+ /**
71
+ * Live room reference. Wired by the framework at __init, AFTER the
72
+ * room's own construction — accessing it from the plugin's
73
+ * constructor throws.
74
+ */
75
+ protected readonly room!: This;
76
+
77
+ /**
78
+ * Canonical key for the plugin when registered via `definePlugins([...])`.
79
+ * Declared on the subclass with `as const` so the literal type flows
80
+ * into `room.plugins.<key>`:
81
+ *
82
+ * class ChatPlugin extends RoomPlugin {
83
+ * readonly pluginName = 'chat' as const;
84
+ * }
85
+ *
86
+ * Optional under the keyed-record form (`definePlugins({ chat: ... })`),
87
+ * required under the array form. Must stay `public` so `extends` /
88
+ * `keyof` can see the literal — those are blind to protected from
89
+ * outside the class. End-user autocomplete hides it via `Omit` in
90
+ * `definePlugins`'s return type.
91
+ *
92
+ * For multi-instance use, accept the name at construction:
93
+ *
94
+ * readonly pluginName: string;
95
+ * constructor(name = 'chat') { super(); this.pluginName = name; }
96
+ */
97
+ readonly pluginName?: string;
98
+
99
+ /**
100
+ * Declarative message handlers — merged into the room's `messages` at
101
+ * __init. Conflict against the room's own key: room wins. Conflict
102
+ * between two plugins: throws at __init.
103
+ */
104
+ protected messages?: Messages<This>;
105
+
106
+ /** Optional per-hook ordering vs the room's own hook. */
107
+ protected order?: RoomPluginOrder;
108
+
109
+ // Lifecycle hooks — override any subset. `protected` so they don't
110
+ // leak into `this.plugins.<key>.onJoin(...)` autocomplete; subclass
111
+ // overrides must keep `protected` (TS widens to public silently
112
+ // otherwise).
113
+ protected onCreate?(options: any): void | Promise<void>;
114
+ protected onAuth?(client: Client, options: any, context: AuthContext): void | Promise<void>;
115
+ protected onJoin?(client: Client, options?: any): void | Promise<void>;
116
+ protected onLeave?(client: Client, code?: number): void | Promise<void>;
117
+ protected onDispose?(): void | Promise<void>;
118
+ }
119
+
120
+ /**
121
+ * Plugin-class constructor type used by the `dependencies` static
122
+ * declaration. Constrained to zero-arg constructors because the
123
+ * framework auto-instantiates missing dependencies with no
124
+ * configuration — plugins that need options can't be auto-included
125
+ * and must be registered explicitly in `definePlugins({...})`.
126
+ */
127
+ export type RoomPluginClass = new () => RoomPlugin<any>;
128
+
129
+ /**
130
+ * Static `dependencies` declaration. List other plugin classes this
131
+ * plugin needs alongside it; the framework auto-instantiates any
132
+ * missing ones at room construction time. Transitive deps are
133
+ * resolved recursively. Cycles throw at class-init.
134
+ *
135
+ * Example:
136
+ * class UniqueSessionPlugin extends RoomPlugin {
137
+ * static dependencies: PluginDependencies = [TrackUserSessionsPlugin];
138
+ * }
139
+ */
140
+ export type PluginDependencies = ReadonlyArray<RoomPluginClass>;
141
+
142
+ /**
143
+ * Define a Room's plugin record. The framework wires plugins at
144
+ * `__init` — first construct of the class computes the layout
145
+ * (cached on the constructor) and installs hook wrappers on the
146
+ * prototype. The `const T` modifier preserves literal types so
147
+ * `this.plugins.<key>.method()` autocompletes against each plugin's
148
+ * specific subclass.
149
+ */
150
+ // Pulls each plugin's `pluginName` literal as the record key. Subclass
151
+ // must narrow with `as const` — plain `string` resolves to `never`
152
+ // and the entry is dropped (callers see a TS error on
153
+ // `plugins.<thatKey>`).
154
+ type ExtractPluginName<P> = P extends { pluginName: infer K }
155
+ ? (K extends string ? K : never)
156
+ : never;
157
+
158
+ /** Hide `pluginName` from end-user autocomplete on `this.plugins.<key>`. */
159
+ type PluginPublicSurface<P> = Omit<P, 'pluginName'>;
160
+
161
+ type PluginsArrayToRecord<T extends readonly RoomPlugin<any>[]> = {
162
+ [P in T[number] as ExtractPluginName<P>]: PluginPublicSurface<P>;
163
+ };
164
+
165
+ /**
166
+ * Array form (recommended). Each plugin declares its own canonical
167
+ * key via `readonly pluginName = '...' as const`; the framework
168
+ * turns the array into a typed record so `plugins.<pluginName>`
169
+ * autocompletes the right instance type.
170
+ *
171
+ * class GameRoom extends Room {
172
+ * plugins = definePlugins([
173
+ * new ChatPlugin(), // pluginName: 'chat'
174
+ * new UniqueSessionPlugin({ max: 1 }), // pluginName: 'uniqueSession'
175
+ * ]);
176
+ * }
177
+ * this.plugins.chat.send('hi');
178
+ *
179
+ * Throws at runtime if any plugin is missing `pluginName` or if two
180
+ * plugins resolve to the same key.
181
+ */
182
+ export function definePlugins<const T extends readonly RoomPlugin<any>[]>(
183
+ plugins: T,
184
+ ): PluginsArrayToRecord<T>;
185
+
186
+ /**
187
+ * Record form (legacy / multi-instance escape hatch). The caller
188
+ * chooses the key per-room. Useful when registering two instances of
189
+ * the same plugin class without configuring `pluginName` per
190
+ * instance — e.g. `{ adminChat: new ChatPlugin(), playerChat: new ChatPlugin() }`.
191
+ */
192
+ export function definePlugins<const T extends Record<string, RoomPlugin<any>>>(
193
+ plugins: T,
194
+ ): { [K in keyof T]: PluginPublicSurface<T[K]> };
195
+
196
+ export function definePlugins(plugins: any): any {
197
+ if (!Array.isArray(plugins)) { return plugins; }
198
+ const out: Record<string, RoomPlugin> = {};
199
+ for (const p of plugins) {
200
+ const key = p['pluginName'];
201
+ if (typeof key !== 'string' || key.length === 0) {
202
+ throw new Error(
203
+ `[Room] plugin ${p.constructor.name} is missing a 'pluginName' field. ` +
204
+ `Declare \`readonly pluginName = '<key>' as const\` on the class, ` +
205
+ `or register it via the keyed-record form \`definePlugins({ <key>: <plugin> })\`.`,
206
+ );
207
+ }
208
+ if (out[key]) {
209
+ throw new Error(
210
+ `[Room] two plugins resolve to pluginName "${key}". Configure one of ` +
211
+ `them via constructor argument, or use the keyed-record form to disambiguate.`,
212
+ );
213
+ }
214
+ out[key] = p;
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Lifecycle hook keys recognized by the framework — used internally to
221
+ * separate "framework-recognized methods" from "user-defined public
222
+ * methods" when wiring a plugin into a room. Exported for the test
223
+ * harness; downstream code should not need it.
224
+ */
225
+ export const PLUGIN_LIFECYCLE_KEYS = ['onCreate', 'onAuth', 'onJoin', 'onLeave', 'onDispose'] as const;
226
+ export type PluginLifecycleKey = (typeof PLUGIN_LIFECYCLE_KEYS)[number];
227
+
228
+ /**
229
+ * Test helper — attach a stub or fake room to a plugin so its methods
230
+ * and lifecycle hooks can be exercised in isolation without spinning up
231
+ * a real Colyseus server.
232
+ *
233
+ * const plugin = new ChatPlugin({ historyLimit: 5 });
234
+ * const room = attachToTestRoom(plugin, { broadcast: sinon.spy() });
235
+ * await plugin.messages!.chat!.call(plugin, { userId: 'u1' }, { text: 'hi' });
236
+ *
237
+ * The second arg is shallow-merged onto the stub so tests only declare
238
+ * the room properties they actually exercise. Returns the room stub for
239
+ * post-call assertions.
240
+ */
241
+ export function attachToTestRoom<This extends Room, R extends Partial<This>>(
242
+ plugin: RoomPlugin<This>,
243
+ roomStub: R = {} as R,
244
+ ): R {
245
+ (plugin as any).room = roomStub;
246
+ return roomStub;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Layout machinery — used by Room.__init to set plugins up once per class.
251
+ // Lives in this file (rather than Room.ts) so the plugin-related types and
252
+ // helpers stay co-located. These are framework internals; callers outside
253
+ // `@colyseus/core` should not depend on them.
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Precomputed plugin layout for a Room subclass — populated on first
258
+ * construct, cached on the constructor. Hook wrappers are installed
259
+ * on the prototype in the same pass (see `installPluginHookWrappers`).
260
+ *
261
+ * @internal
262
+ */
263
+ export interface PluginLayout {
264
+ /** Per-hook participation: which plugin keys run before/after the room's own hook. */
265
+ hooks: Record<PluginLifecycleKey, { before: string[]; after: string[] }>;
266
+ /** Message key → plugin key. Conflict detection ran when this was built. */
267
+ messageOwners: Map<string, string>;
268
+ /** Sentinel-keyed plugin classes to instantiate per room. */
269
+ autoDeps: Array<{ key: string; ctor: RoomPluginClass }>;
270
+ }
271
+
272
+ /** Sentinel prefix for framework-instantiated deps. The colon prevents
273
+ * collisions with any JS identifier the user could use as a key. */
274
+ const DEP_KEY_PREFIX = '__dep:';
275
+
276
+ /**
277
+ * Default before/after policy for lifecycle hooks vs the room's own
278
+ * hook. Plugins can override per-hook via the `order` field on the
279
+ * plugin instance.
280
+ *
281
+ * onCreate / onAuth / onJoin → plugins run BEFORE room (guards + setup)
282
+ * onLeave / onDispose → plugins run AFTER room (capture final state)
283
+ *
284
+ * @internal
285
+ */
286
+ export const DEFAULT_PLUGIN_ORDER: Record<PluginLifecycleKey, 'before' | 'after'> = {
287
+ onCreate: 'before',
288
+ onAuth: 'before',
289
+ onJoin: 'before',
290
+ onLeave: 'after',
291
+ onDispose: 'after',
292
+ };
293
+
294
+ /**
295
+ * Walk the room's plugin instances and produce the lifecycle + message
296
+ * layout for the Room class. Called once per Room subclass — on the
297
+ * first construct — and the result is cached on the constructor. Throws
298
+ * on duplicate message keys (named both plugin keys) so the failure is
299
+ * visible at class-init rather than at first message dispatch.
300
+ *
301
+ * Returns `null` when no plugins participate in any hook AND none
302
+ * declare a message — distinguishes "computed, nothing to do" from
303
+ * "not computed yet" in the `__pluginLayout` cache.
304
+ *
305
+ * @internal
306
+ */
307
+ export function computePluginLayout(plugins: Record<string, RoomPlugin>): PluginLayout | null {
308
+ // Resolve `static dependencies` closures over the user's record.
309
+ // Returns an expanded list (user + auto-deps), the auto-dep
310
+ // class table for per-room instantiation, and a unified view of
311
+ // entries to iterate when computing hooks / message owners.
312
+ const resolved = resolveDependencies(plugins);
313
+
314
+ const hooks = {} as Record<PluginLifecycleKey, { before: string[]; after: string[] }>;
315
+ let anyHook = false;
316
+
317
+ for (const hook of PLUGIN_LIFECYCLE_KEYS) {
318
+ const before: string[] = [];
319
+ const after: string[] = [];
320
+ for (const { key, plugin } of resolved.entries) {
321
+ if (typeof plugin[hook] !== 'function') { continue; }
322
+ // Bracket access — `order` and `messages` are protected; TS
323
+ // skips visibility checks on indexed access.
324
+ const order = plugin['order']?.[hook] ?? DEFAULT_PLUGIN_ORDER[hook];
325
+ (order === 'before' ? before : after).push(key);
326
+ anyHook = true;
327
+ }
328
+ hooks[hook] = { before, after };
329
+ }
330
+
331
+ const messageOwners = new Map<string, string>();
332
+ for (const { key, plugin } of resolved.entries) {
333
+ const messages = plugin['messages'];
334
+ if (!messages) { continue; }
335
+ for (const messageKey of Object.keys(messages)) {
336
+ const prior = messageOwners.get(messageKey);
337
+ if (prior !== undefined) {
338
+ throw new Error(
339
+ `[Room] message key "${messageKey}" declared by multiple plugins: ` +
340
+ `"${prior}" and "${key}". Resolve by giving one of them a ` +
341
+ `different key, or override on the room's own \`messages\`.`,
342
+ );
343
+ }
344
+ messageOwners.set(messageKey, key);
345
+ }
346
+ }
347
+
348
+ if (!anyHook && messageOwners.size === 0 && resolved.autoDeps.length === 0) { return null; }
349
+ return { hooks, messageOwners, autoDeps: resolved.autoDeps };
350
+ }
351
+
352
+ /**
353
+ * Walk every plugin's `static dependencies` recursively, instantiating
354
+ * missing classes (zero-arg only). Throws on cycles. Auto-deps are
355
+ * keyed `__dep:<ClassName>` in the returned entries.
356
+ */
357
+ function resolveDependencies(plugins: Record<string, RoomPlugin>): {
358
+ entries: Array<{ key: string; plugin: RoomPlugin }>;
359
+ autoDeps: Array<{ key: string; ctor: RoomPluginClass }>;
360
+ } {
361
+ const entries: Array<{ key: string; plugin: RoomPlugin }> = [];
362
+ for (const [key, plugin] of Object.entries(plugins)) {
363
+ entries.push({ key, plugin });
364
+ }
365
+
366
+ const present = new Set<RoomPluginClass>();
367
+ for (const { plugin } of entries) {
368
+ present.add(plugin.constructor as RoomPluginClass);
369
+ }
370
+
371
+ const autoDeps: Array<{ key: string; ctor: RoomPluginClass }> = [];
372
+ const visiting = new Set<RoomPluginClass>();
373
+ const path: string[] = [];
374
+
375
+ function walk(depCtor: RoomPluginClass): void {
376
+ if (present.has(depCtor)) { return; }
377
+ if (visiting.has(depCtor)) {
378
+ const cycle = [...path, depCtor.name].join(' → ');
379
+ throw new Error(`[Room] plugin dependency cycle: ${cycle}`);
380
+ }
381
+ visiting.add(depCtor);
382
+ path.push(depCtor.name);
383
+
384
+ // Recurse into deeper deps first → topological order in `entries`.
385
+ const deeper = (depCtor as any).dependencies as PluginDependencies | undefined;
386
+ if (Array.isArray(deeper)) {
387
+ for (const inner of deeper) { walk(inner); }
388
+ }
389
+
390
+ let instance: RoomPlugin;
391
+ try { instance = new depCtor(); }
392
+ catch (err: any) {
393
+ throw new Error(
394
+ `[Room] auto-included plugin "${depCtor.name}" must be ` +
395
+ `constructible with no arguments. If it needs options, ` +
396
+ `register it explicitly in definePlugins({...}). ` +
397
+ `(cause: ${err?.message ?? err})`,
398
+ );
399
+ }
400
+ const key = DEP_KEY_PREFIX + depCtor.name;
401
+ entries.push({ key, plugin: instance });
402
+ autoDeps.push({ key, ctor: depCtor });
403
+ present.add(depCtor);
404
+
405
+ visiting.delete(depCtor);
406
+ path.pop();
407
+ }
408
+
409
+ // Snapshot before mutating — transitive deps are handled recursively
410
+ // inside `walk()`.
411
+ const userEntriesSnapshot = entries.slice();
412
+ for (const { plugin } of userEntriesSnapshot) {
413
+ const deps = (plugin.constructor as any).dependencies as PluginDependencies | undefined;
414
+ if (!Array.isArray(deps)) { continue; }
415
+ for (const depCtor of deps) { walk(depCtor); }
416
+ }
417
+
418
+ return { entries, autoDeps };
419
+ }
420
+
421
+ /**
422
+ * Install one wrapper per participating hook on the Room subclass's
423
+ * prototype. The wrapper closes over plugin KEYS (resolved to refs at
424
+ * call time) and invokes the captured original room hook between the
425
+ * before/after plugin runs.
426
+ *
427
+ * @internal
428
+ */
429
+ export function installPluginHookWrappers(
430
+ ctor: { prototype: any },
431
+ layout: PluginLayout | null,
432
+ ): void {
433
+ if (layout === null) { return; }
434
+ const proto = ctor.prototype;
435
+ for (const hook of PLUGIN_LIFECYCLE_KEYS) {
436
+ const { before, after } = layout.hooks[hook];
437
+ if (before.length === 0 && after.length === 0) { continue; }
438
+
439
+ const original = proto[hook] as Function | undefined;
440
+ proto[hook] = async function (
441
+ this: { plugins?: Record<string, RoomPlugin>; _autoPlugins?: Record<string, RoomPlugin> },
442
+ ...args: any[]
443
+ ) {
444
+ // User plugins live on `this.plugins`, auto-deps (sentinel-keyed)
445
+ // on `this._autoPlugins` — kept separate so user types stay clean.
446
+ const lookup = (k: string): RoomPlugin =>
447
+ (k.startsWith(DEP_KEY_PREFIX) ? this._autoPlugins![k] : this.plugins![k]);
448
+ for (const k of before) {
449
+ const p = lookup(k);
450
+ await (p[hook] as Function).call(p, ...args);
451
+ }
452
+ let result: unknown;
453
+ if (original) { result = await original.apply(this, args); }
454
+ for (const k of after) {
455
+ const p = lookup(k);
456
+ await (p[hook] as Function).call(p, ...args);
457
+ }
458
+ return result;
459
+ };
460
+ }
461
+ }
462
+
463
+ /** Structural shape used by `setupRoomPlugins` — avoids a Room import
464
+ * so the dependency graph stays one-way (Room → RoomPlugin). */
465
+ interface RoomPluginHost {
466
+ plugins?: Record<string, RoomPlugin<any>>;
467
+ _autoPlugins?: Record<string, RoomPlugin<any>>;
468
+ messages?: Record<string, Function> | any;
469
+ constructor: { __pluginLayout?: PluginLayout | null; prototype: any };
470
+ }
471
+
472
+ /**
473
+ * Wire a Room instance's plugins. Once-per-class layout (hook
474
+ * participation, message owners, dep resolution) is cached on the
475
+ * constructor; per-instance work attaches `room` refs, instantiates
476
+ * auto-deps, and merges plugin messages.
477
+ *
478
+ * @internal
479
+ */
480
+ export function setupRoomPlugins(room: RoomPluginHost): void {
481
+ const plugins = room.plugins!;
482
+ const layout = resolveOrComputeLayout(room.constructor, plugins);
483
+
484
+ attachRoomReference(room, plugins);
485
+
486
+ if (layout && layout.autoDeps.length > 0) {
487
+ room._autoPlugins = instantiateAutoDeps(room, layout);
488
+ }
489
+
490
+ if (layout && layout.messageOwners.size > 0) {
491
+ mergePluginMessages(room, layout);
492
+ }
493
+
494
+ Object.freeze(plugins);
495
+ if (room._autoPlugins) { Object.freeze(room._autoPlugins); }
496
+ }
497
+
498
+ /**
499
+ * Read cached layout for this subclass, or compute + install on
500
+ * first construct. `hasOwnProperty` so a subclass that redeclares
501
+ * `plugins` doesn't inherit the parent's wrapping.
502
+ */
503
+ function resolveOrComputeLayout(
504
+ ctor: RoomPluginHost['constructor'],
505
+ plugins: Record<string, RoomPlugin>,
506
+ ): PluginLayout | null | undefined {
507
+ if (Object.prototype.hasOwnProperty.call(ctor, '__pluginLayout')) {
508
+ return ctor.__pluginLayout;
509
+ }
510
+ const layout = computePluginLayout(plugins);
511
+ installPluginHookWrappers(ctor, layout);
512
+ ctor.__pluginLayout = layout;
513
+ return layout;
514
+ }
515
+
516
+ /** Wire `plugin.room = room` on every user-explicit plugin. */
517
+ function attachRoomReference(
518
+ room: RoomPluginHost,
519
+ plugins: Record<string, RoomPlugin>,
520
+ ): void {
521
+ for (const plugin of Object.values(plugins)) {
522
+ (plugin as any).room = room;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Build `_autoPlugins` — one fresh instance per `static dependencies`
528
+ * entry. Kept separate from `room.plugins` so sentinel keys
529
+ * (`__dep:<ClassName>`) don't leak into the user's typed view.
530
+ */
531
+ function instantiateAutoDeps(
532
+ room: RoomPluginHost,
533
+ layout: PluginLayout,
534
+ ): Record<string, RoomPlugin> {
535
+ const auto: Record<string, RoomPlugin> = {};
536
+ for (const { key, ctor: depCtor } of layout.autoDeps) {
537
+ const instance = new depCtor();
538
+ (instance as any).room = room;
539
+ auto[key] = instance;
540
+ }
541
+ return auto;
542
+ }
543
+
544
+ /**
545
+ * Copy plugin message handlers into `room.messages` (room's own key
546
+ * wins; plugin-vs-plugin conflicts already threw at layout time).
547
+ */
548
+ function mergePluginMessages(
549
+ room: RoomPluginHost,
550
+ layout: PluginLayout,
551
+ ): void {
552
+ const plugins = room.plugins!;
553
+ for (const [messageKey, pluginKey] of layout.messageOwners) {
554
+ if (room.messages?.[messageKey]) { continue; }
555
+ const source = pluginKey.startsWith(DEP_KEY_PREFIX)
556
+ ? room._autoPlugins![pluginKey]
557
+ : plugins[pluginKey];
558
+ const handler = source['messages']?.[messageKey];
559
+ if (handler !== undefined) {
560
+ (room.messages ??= {} as any)[messageKey] = handler;
561
+ }
562
+ }
563
+ }
package/src/Server.ts CHANGED
@@ -15,7 +15,7 @@ import { LocalDriver } from './matchmaker/LocalDriver/LocalDriver.ts';
15
15
  import { setTransport, Transport } from './Transport.ts';
16
16
  import { logger, setLogger } from './Logger.ts';
17
17
  import { setDevMode, isDevMode } from './utils/DevMode.ts';
18
- import { type Router, bindRouterToTransport } from './router/index.ts';
18
+ import { type Router, bindRouterToTransport, createRouter } from './router/index.ts';
19
19
  import { type SDKTypes as SharedSDKTypes } from '@colyseus/shared-types';
20
20
  import { getDefaultRouter } from './router/default_routes.ts';
21
21
 
@@ -33,6 +33,29 @@ export type ServerOptions = {
33
33
  */
34
34
  beforeListen?: () => Promise<void> | void,
35
35
 
36
+ /**
37
+ * Booted before `matchMaker.accept()`. Structural so `@colyseus/core` keeps
38
+ * no runtime dep on `@colyseus/database` — any `{ boot(): Promise<void> }`
39
+ * works. The optional `applyRouterDefaults` is invoked after boot with the
40
+ * user's router so the database can contribute endpoints (e.g. auth routes).
41
+ */
42
+ database?: {
43
+ boot(): Promise<void>;
44
+ applyRouterDefaults?(router: Router): Router | Promise<Router>;
45
+ },
46
+
47
+ /**
48
+ * Mount `@colyseus/auth` routes into the router. Pass an options object
49
+ * (forwarded to `auth.endpoints(...)`) to wire it explicitly — useful when
50
+ * `database` is not in use. `false` disables auto-mounting even if a
51
+ * `database` is present and would have provided defaults.
52
+ */
53
+ auth?: false | {
54
+ settings?: Record<string, any>;
55
+ oauth?: boolean | { cookieSecret?: string };
56
+ prefix?: string;
57
+ },
58
+
36
59
  /**
37
60
  * Optional callback to configure Express routes.
38
61
  * When provided, the transport layer will initialize an Express-compatible app
@@ -104,6 +127,12 @@ export class Server<
104
127
 
105
128
  private _originalRoomOnMessage: typeof Room.prototype['_onMessage'] | null = null;
106
129
 
130
+ // Implicit default for callers that omit explicit Server reference — e.g.
131
+ // `playground()` reads `Server.current.router.endpoints` at request time.
132
+ // Last-construction wins; multi-server setups should reference instances
133
+ // explicitly.
134
+ static current: Server<any, any> | undefined;
135
+
107
136
  constructor(options: ServerOptions = {}) {
108
137
  const {
109
138
  gracefullyShutdown = true,
@@ -117,6 +146,8 @@ export class Server<
117
146
  this.options = options;
118
147
  this.greet = greet;
119
148
 
149
+ (Server as { current: Server<any, any> | undefined }).current = this as Server<any, any>;
150
+
120
151
  this.attach(options);
121
152
 
122
153
  matchMaker.setup(
@@ -137,14 +168,8 @@ export class Server<
137
168
 
138
169
  public async attach(options: ServerOptions) {
139
170
  this.transport = options.transport || await this.getDefaultTransport(options);
140
-
141
- // Initialize Express if callback is provided
142
- if (options.express && this.transport.getExpressApp) {
143
- const expressApp = await this.transport.getExpressApp();
144
- await options.express(expressApp);
145
- }
146
-
147
- // Resolve the promise when the transport is ready
171
+ // `options.express` runs in `listen()` after `database.boot()` so user
172
+ // code reading `database.auth.settings` etc. finds services instantiated.
148
173
  this._onTransportReady.resolve(this.transport);
149
174
  }
150
175
 
@@ -157,8 +182,19 @@ export class Server<
157
182
  * @param listeningListener
158
183
  */
159
184
  public async listen(port: number | string, hostname?: string, backlog?: number, listeningListener?: Function) {
160
- if (this.options.beforeListen) {
161
- await this.options.beforeListen();
185
+ const { beforeListen, database, express } = this.options;
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
+ }
162
198
  }
163
199
 
164
200
  //
@@ -351,6 +387,31 @@ export class Server<
351
387
  this.onBeforeShutdownCallback = callback;
352
388
  }
353
389
 
390
+ // Extend the user's router with framework-contributed endpoints. An explicit
391
+ // `auth` option wins over the database's `applyRouterDefaults` so a non-DB
392
+ // setup can still auto-mount @colyseus/auth, and a DB user can opt out.
393
+ private async _applyRouterDefaults(): Promise<void> {
394
+ const { auth, database } = this.options;
395
+ const wantsAuth = auth !== undefined && auth !== false;
396
+ const wantsDatabase = auth === undefined && !!database?.applyRouterDefaults;
397
+ if (!wantsAuth && !wantsDatabase) { return; }
398
+
399
+ // Boot an empty router if the user didn't pass `routes` — the framework's
400
+ // default routes get layered on later in the `transport.listen()` callback.
401
+ this.router ??= createRouter({}) as unknown as Routes;
402
+
403
+ if (wantsAuth) {
404
+ const authMod: any = await dynamicImport('@colyseus/auth').catch(() => undefined);
405
+ const endpointsFn = authMod?.auth?.endpoints ?? authMod?.default?.auth?.endpoints;
406
+ if (typeof endpointsFn === 'function') {
407
+ this.router = (this.router as Router).extend(endpointsFn(auth)) as Routes;
408
+ }
409
+ } else {
410
+ const updated = await database!.applyRouterDefaults!(this.router as Router);
411
+ if (updated) { this.router = updated as Routes; }
412
+ }
413
+ }
414
+
354
415
  protected async getDefaultTransport(options: any): Promise<Transport> {
355
416
  try {
356
417
  const module = await dynamicImport('@colyseus/ws-transport');