@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/package.json +3 -4
- package/src/Debug.ts +37 -0
- package/src/IPC.ts +124 -0
- package/src/Logger.ts +30 -0
- package/src/MatchMaker.ts +1119 -0
- package/src/Protocol.ts +160 -0
- package/src/Room.ts +1797 -0
- package/src/Server.ts +325 -0
- package/src/Stats.ts +107 -0
- package/src/Transport.ts +207 -0
- package/src/errors/RoomExceptions.ts +141 -0
- package/src/errors/SeatReservationError.ts +5 -0
- package/src/errors/ServerError.ts +17 -0
- package/src/index.ts +81 -0
- package/src/matchmaker/Lobby.ts +68 -0
- package/src/matchmaker/LocalDriver/LocalDriver.ts +92 -0
- package/src/matchmaker/LocalDriver/Query.ts +94 -0
- package/src/matchmaker/RegisteredHandler.ts +172 -0
- package/src/matchmaker/controller.ts +64 -0
- package/src/matchmaker/driver.ts +191 -0
- package/src/presence/LocalPresence.ts +331 -0
- package/src/presence/Presence.ts +263 -0
- package/src/rooms/LobbyRoom.ts +135 -0
- package/src/rooms/RankedQueueRoom.ts +425 -0
- package/src/rooms/RelayRoom.ts +90 -0
- package/src/router/default_routes.ts +58 -0
- package/src/router/index.ts +43 -0
- package/src/serializer/NoneSerializer.ts +16 -0
- package/src/serializer/SchemaSerializer.ts +194 -0
- package/src/serializer/SchemaSerializerDebug.ts +148 -0
- package/src/serializer/Serializer.ts +9 -0
- package/src/utils/DevMode.ts +133 -0
- package/src/utils/StandardSchema.ts +20 -0
- package/src/utils/Utils.ts +169 -0
- package/src/utils/nanoevents.ts +20 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { Serializer } from './Serializer.ts';
|
|
2
|
+
import { type Client, ClientState } from '../Transport.ts';
|
|
3
|
+
|
|
4
|
+
import { type Iterator, Encoder, dumpChanges, Reflection, Schema, StateView } from '@colyseus/schema';
|
|
5
|
+
import { debugPatch } from '../Debug.ts';
|
|
6
|
+
import { Protocol } from '../Protocol.ts';
|
|
7
|
+
|
|
8
|
+
const SHARED_VIEW = {};
|
|
9
|
+
|
|
10
|
+
export class SchemaSerializer<T extends Schema> implements Serializer<T> {
|
|
11
|
+
public id = 'schema';
|
|
12
|
+
|
|
13
|
+
protected encoder: Encoder<T>;
|
|
14
|
+
protected hasFilters: boolean = false;
|
|
15
|
+
|
|
16
|
+
protected handshakeCache: Uint8Array;
|
|
17
|
+
|
|
18
|
+
// flag to avoid re-encoding full state if no changes were made
|
|
19
|
+
protected needFullEncode: boolean = true;
|
|
20
|
+
|
|
21
|
+
// TODO: make this optional. allocating a new buffer for each room may not be always necessary.
|
|
22
|
+
protected fullEncodeBuffer: Uint8Array = new Uint8Array(Encoder.BUFFER_SIZE);
|
|
23
|
+
protected fullEncodeCache: Uint8Array;
|
|
24
|
+
protected sharedOffsetCache: Iterator = { offset: 0 };
|
|
25
|
+
|
|
26
|
+
protected encodedViews: Map<StateView | typeof SHARED_VIEW, Uint8Array>;
|
|
27
|
+
|
|
28
|
+
public reset(newState: T & Schema) {
|
|
29
|
+
this.encoder = new Encoder(newState);
|
|
30
|
+
this.hasFilters = this.encoder.context.hasFilters;
|
|
31
|
+
|
|
32
|
+
// cache ROOM_STATE byte as part of the encoded buffer
|
|
33
|
+
this.fullEncodeBuffer[0] = Protocol.ROOM_STATE;
|
|
34
|
+
|
|
35
|
+
if (this.hasFilters) {
|
|
36
|
+
this.encodedViews = new Map();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public getFullState(client?: Client) {
|
|
41
|
+
if (this.needFullEncode || this.encoder.root.changes.next !== undefined) {
|
|
42
|
+
this.sharedOffsetCache = { offset: 1 };
|
|
43
|
+
this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer);
|
|
44
|
+
this.needFullEncode = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (this.hasFilters && client?.view) {
|
|
48
|
+
return this.encoder.encodeAllView(
|
|
49
|
+
client.view,
|
|
50
|
+
this.sharedOffsetCache.offset,
|
|
51
|
+
{ ...this.sharedOffsetCache },
|
|
52
|
+
this.fullEncodeBuffer
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
} else {
|
|
56
|
+
return this.fullEncodeCache;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public applyPatches(clients: Client[]) {
|
|
61
|
+
let numClients = clients.length;
|
|
62
|
+
|
|
63
|
+
if (numClients === 0) {
|
|
64
|
+
// skip patching and clear changes
|
|
65
|
+
this.encoder.discardChanges();
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!this.encoder.hasChanges) {
|
|
70
|
+
|
|
71
|
+
// check if views have changes (manual add() or remove() items)
|
|
72
|
+
if (this.hasFilters) {
|
|
73
|
+
//
|
|
74
|
+
// FIXME: refactor this to avoid duplicating code.
|
|
75
|
+
//
|
|
76
|
+
// it's probably better to have 2 different 'applyPatches' methods.
|
|
77
|
+
// (one for handling state with filters, and another for handling state without filters)
|
|
78
|
+
//
|
|
79
|
+
const clientsWithViewChange = clients.filter((client) => {
|
|
80
|
+
return client.state === ClientState.JOINED && client.view?.changes.size > 0
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (clientsWithViewChange.length > 0) {
|
|
84
|
+
const it: Iterator = { offset: 1 };
|
|
85
|
+
|
|
86
|
+
const sharedOffset = it.offset;
|
|
87
|
+
this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;
|
|
88
|
+
|
|
89
|
+
clientsWithViewChange.forEach((client) => {
|
|
90
|
+
client.raw(this.encoder.encodeView(client.view, sharedOffset, it));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// skip patching state if:
|
|
96
|
+
// - no clients are connected
|
|
97
|
+
// - no changes were made
|
|
98
|
+
// - no "filtered changes" were made when using filters
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.needFullEncode = true;
|
|
103
|
+
|
|
104
|
+
// dump changes for patch debugging
|
|
105
|
+
if (debugPatch.enabled) {
|
|
106
|
+
(debugPatch as any).dumpChanges = dumpChanges(this.encoder.state);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// get patch bytes
|
|
110
|
+
const it: Iterator = { offset: 1 };
|
|
111
|
+
this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;
|
|
112
|
+
|
|
113
|
+
// encode changes once, for all clients
|
|
114
|
+
const encodedChanges = this.encoder.encode(it);
|
|
115
|
+
|
|
116
|
+
if (!this.hasFilters) {
|
|
117
|
+
while (numClients--) {
|
|
118
|
+
const client = clients[numClients];
|
|
119
|
+
|
|
120
|
+
//
|
|
121
|
+
// FIXME: avoid this check for each client
|
|
122
|
+
//
|
|
123
|
+
if (client.state !== ClientState.JOINED) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
client.raw(encodedChanges);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
} else {
|
|
131
|
+
// cache shared offset
|
|
132
|
+
const sharedOffset = it.offset;
|
|
133
|
+
|
|
134
|
+
// encode state multiple times, for each client
|
|
135
|
+
while (numClients--) {
|
|
136
|
+
const client = clients[numClients];
|
|
137
|
+
|
|
138
|
+
//
|
|
139
|
+
// FIXME: avoid this check for each client
|
|
140
|
+
//
|
|
141
|
+
if (client.state !== ClientState.JOINED) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const view = client.view || SHARED_VIEW;
|
|
146
|
+
|
|
147
|
+
let encodedView = this.encodedViews.get(view);
|
|
148
|
+
|
|
149
|
+
// allow to pass the same encoded view for multiple clients
|
|
150
|
+
if (encodedView === undefined) {
|
|
151
|
+
encodedView = (view === SHARED_VIEW)
|
|
152
|
+
? encodedChanges
|
|
153
|
+
: this.encoder.encodeView(client.view, sharedOffset, it);
|
|
154
|
+
this.encodedViews.set(view, encodedView);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
client.raw(encodedView);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// clear views
|
|
161
|
+
this.encodedViews.clear();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// discard changes after sending
|
|
165
|
+
this.encoder.discardChanges();
|
|
166
|
+
|
|
167
|
+
// debug patches
|
|
168
|
+
if (debugPatch.enabled) {
|
|
169
|
+
debugPatch(
|
|
170
|
+
'%d bytes sent to %d clients, %j',
|
|
171
|
+
encodedChanges.length,
|
|
172
|
+
clients.length,
|
|
173
|
+
(debugPatch as any).dumpChanges,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public handshake() {
|
|
181
|
+
/**
|
|
182
|
+
* Cache handshake to avoid encoding it for each client joining
|
|
183
|
+
*/
|
|
184
|
+
if (!this.handshakeCache) {
|
|
185
|
+
//
|
|
186
|
+
// TODO: re-use handshake buffer for all rooms of same type (?)
|
|
187
|
+
//
|
|
188
|
+
this.handshakeCache = (this.encoder.state && Reflection.encode(this.encoder));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return this.handshakeCache;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This serializer is a copy of SchemaSerializer,
|
|
3
|
+
* but it writes debug information to a file.
|
|
4
|
+
*
|
|
5
|
+
* This script must be used
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import { Schema, dumpChanges, Iterator } from '@colyseus/schema';
|
|
10
|
+
import { SchemaSerializer } from './SchemaSerializer.ts';
|
|
11
|
+
import { Client, ClientState } from "../Transport.ts";
|
|
12
|
+
import { Protocol} from '../Protocol.ts';
|
|
13
|
+
import { debugPatch } from '../Debug.ts';
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
const SHARED_VIEW = {};
|
|
17
|
+
|
|
18
|
+
export class SchemaSerializerDebug<T> extends SchemaSerializer<T> {
|
|
19
|
+
protected debugStream: fs.WriteStream;
|
|
20
|
+
|
|
21
|
+
constructor(fileName: string = "schema-debug.txt") {
|
|
22
|
+
super();
|
|
23
|
+
|
|
24
|
+
try { fs.unlinkSync(fileName); } catch (e) {}
|
|
25
|
+
this.debugStream = fs.createWriteStream(fileName, { flags: "a" });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public getFullState(client?: Client): Buffer {
|
|
29
|
+
const buf = super.getFullState(client);
|
|
30
|
+
this.debugStream.write(`state:${client.sessionId}:${Array.from(buf).slice(1).join(",")}\n`);
|
|
31
|
+
return buf;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public applyPatches(clients: Client[]) {
|
|
35
|
+
let numClients = clients.length;
|
|
36
|
+
|
|
37
|
+
const debugChangesDeep = Schema.debugChangesDeep(this.encoder.state);
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
numClients == 0 ||
|
|
41
|
+
(
|
|
42
|
+
this.encoder.root.changes.size === 0 &&
|
|
43
|
+
(!this.hasFilters || this.encoder.root.filteredChanges.size === 0)
|
|
44
|
+
)
|
|
45
|
+
) {
|
|
46
|
+
// skip patching state if:
|
|
47
|
+
// - no clients are connected
|
|
48
|
+
// - no changes were made
|
|
49
|
+
// - no "filtered changes" were made when using filters
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.needFullEncode = true;
|
|
54
|
+
|
|
55
|
+
// dump changes for patch debugging
|
|
56
|
+
if (debugPatch.enabled) {
|
|
57
|
+
(debugPatch as any).dumpChanges = dumpChanges(this.encoder.state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// get patch bytes
|
|
61
|
+
const it: Iterator = { offset: 1 };
|
|
62
|
+
this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;
|
|
63
|
+
|
|
64
|
+
// encode changes once, for all clients
|
|
65
|
+
const encodedChanges = this.encoder.encode(it);
|
|
66
|
+
|
|
67
|
+
if (!this.hasFilters) {
|
|
68
|
+
while (numClients--) {
|
|
69
|
+
const client = clients[numClients];
|
|
70
|
+
|
|
71
|
+
//
|
|
72
|
+
// FIXME: avoid this check for each client
|
|
73
|
+
//
|
|
74
|
+
if (client.state !== ClientState.JOINED) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
debugChangesDeep.split("\n").forEach((line) => {
|
|
79
|
+
this.debugStream.write(`#${client.sessionId}:${line}\n`);
|
|
80
|
+
});
|
|
81
|
+
this.debugStream.write(`patch:${client.sessionId}:${Array.from(encodedChanges).slice(1).join(",")}\n`);
|
|
82
|
+
|
|
83
|
+
client.raw(encodedChanges);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
} else {
|
|
87
|
+
// cache shared offset
|
|
88
|
+
const sharedOffset = it.offset;
|
|
89
|
+
|
|
90
|
+
// encode state multiple times, for each client
|
|
91
|
+
while (numClients--) {
|
|
92
|
+
const client = clients[numClients];
|
|
93
|
+
|
|
94
|
+
//
|
|
95
|
+
// FIXME: avoid this check for each client
|
|
96
|
+
//
|
|
97
|
+
if (client.state !== ClientState.JOINED) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const view = client.view || SHARED_VIEW;
|
|
102
|
+
|
|
103
|
+
let encodedView = this.views.get(view);
|
|
104
|
+
|
|
105
|
+
// allow to pass the same encoded view for multiple clients
|
|
106
|
+
if (encodedView === undefined) {
|
|
107
|
+
encodedView = (view === SHARED_VIEW)
|
|
108
|
+
? encodedChanges
|
|
109
|
+
: this.encoder.encodeView(client.view, sharedOffset, it);
|
|
110
|
+
this.views.set(view, encodedView);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
debugChangesDeep.split("\n").forEach((line) => {
|
|
114
|
+
this.debugStream.write(`#${client.sessionId}:${line}\n`);
|
|
115
|
+
});
|
|
116
|
+
this.debugStream.write(`patch:${client.sessionId}:${Array.from(encodedView).slice(1).join(",")}\n`);
|
|
117
|
+
|
|
118
|
+
client.raw(encodedView);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// clear views
|
|
122
|
+
this.views.clear();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// discard changes after sending
|
|
126
|
+
this.encoder.discardChanges();
|
|
127
|
+
|
|
128
|
+
// debug patches
|
|
129
|
+
if (debugPatch.enabled) {
|
|
130
|
+
debugPatch(
|
|
131
|
+
'%d bytes sent to %d clients, %j',
|
|
132
|
+
encodedChanges.length,
|
|
133
|
+
clients.length,
|
|
134
|
+
(debugPatch as any).dumpChanges,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public handshake(): Buffer {
|
|
142
|
+
const buf = super.handshake();
|
|
143
|
+
this.debugStream.write(`handshake:${Array.from(buf).join(",")}\n`);
|
|
144
|
+
return buf;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
*/
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { type Schema, MapSchema, ArraySchema, SetSchema, CollectionSchema, $childType } from '@colyseus/schema';
|
|
4
|
+
import { logger } from '../Logger.ts';
|
|
5
|
+
import { debugAndPrintError, debugDevMode } from '../Debug.ts';
|
|
6
|
+
import { getLocalRoomById, handleCreateRoom, presence, remoteRoomCall } from '../MatchMaker.ts';
|
|
7
|
+
import type { Room } from '../Room.ts';
|
|
8
|
+
|
|
9
|
+
const DEVMODE_CACHE_FILE_PATH = path.resolve(".devmode.json");
|
|
10
|
+
|
|
11
|
+
export let isDevMode: boolean = false;
|
|
12
|
+
|
|
13
|
+
export function hasDevModeCache() {
|
|
14
|
+
return fs.existsSync(DEVMODE_CACHE_FILE_PATH);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getDevModeCache() {
|
|
18
|
+
return JSON.parse(fs.readFileSync(DEVMODE_CACHE_FILE_PATH, 'utf8')) || {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function writeDevModeCache(cache: any) {
|
|
22
|
+
fs.writeFileSync(DEVMODE_CACHE_FILE_PATH, JSON.stringify(cache, null, 2), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setDevMode(bool: boolean) {
|
|
26
|
+
isDevMode = bool;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function reloadFromCache() {
|
|
30
|
+
const roomHistoryList = Object.entries(await presence.hgetall(getRoomRestoreListKey()));
|
|
31
|
+
debugDevMode("rooms to restore: %i", roomHistoryList.length);
|
|
32
|
+
|
|
33
|
+
for (const [roomId, value] of roomHistoryList) {
|
|
34
|
+
const roomHistory = JSON.parse(value);
|
|
35
|
+
debugDevMode("restoring room %s (%s)", roomHistory.roomName, roomId);
|
|
36
|
+
|
|
37
|
+
const recreatedRoomListing = await handleCreateRoom(roomHistory.roomName, roomHistory.clientOptions, roomId);
|
|
38
|
+
const recreatedRoom = getLocalRoomById(recreatedRoomListing.roomId);
|
|
39
|
+
|
|
40
|
+
// Restore previous state
|
|
41
|
+
if (roomHistory.hasOwnProperty("state")) {
|
|
42
|
+
try {
|
|
43
|
+
const rawState = JSON.parse(roomHistory.state);
|
|
44
|
+
logger.debug(`📋 room '${roomId}' state =>`, rawState);
|
|
45
|
+
|
|
46
|
+
(recreatedRoom.state as Schema).restore(rawState);
|
|
47
|
+
} catch (e: any) {
|
|
48
|
+
debugAndPrintError(`❌ couldn't restore room '${roomId}' state:\n${e.stack}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reserve seats for clients from cached history
|
|
53
|
+
if (roomHistory.clients) {
|
|
54
|
+
for (const clientData of roomHistory.clients) {
|
|
55
|
+
// TODO: need to restore each client's StateView as well
|
|
56
|
+
// reserve seat for 20 seconds
|
|
57
|
+
const { sessionId, reconnectionToken } = clientData;
|
|
58
|
+
await remoteRoomCall(recreatedRoomListing.roomId, '_reserveSeat', [sessionId, {}, {}, 20, false, reconnectionToken]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// call `onRestoreRoom` with custom 'cache'd property.
|
|
63
|
+
recreatedRoom.onRestoreRoom?.(roomHistory["cache"]);
|
|
64
|
+
|
|
65
|
+
logger.debug(`🔄 room '${roomId}' has been restored with ${roomHistory.clients?.length || 0} reserved seats: ${roomHistory.clients?.map((c: any) => c.sessionId).join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (roomHistoryList.length > 0) {
|
|
69
|
+
logger.debug("✅", roomHistoryList.length, "room(s) have been restored.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function cacheRoomHistory(rooms: { [roomId: string]: Room }) {
|
|
74
|
+
for (const room of Object.values(rooms)) {
|
|
75
|
+
const roomHistoryResult = await presence.hget(getRoomRestoreListKey(), room.roomId);
|
|
76
|
+
if (roomHistoryResult) {
|
|
77
|
+
try {
|
|
78
|
+
const roomHistory = JSON.parse(roomHistoryResult);
|
|
79
|
+
|
|
80
|
+
// custom cache method
|
|
81
|
+
roomHistory["cache"] = room.onCacheRoom?.();
|
|
82
|
+
|
|
83
|
+
// encode state
|
|
84
|
+
debugDevMode("caching room %s (%s)", room.roomName, room.roomId);
|
|
85
|
+
|
|
86
|
+
if (room.state) {
|
|
87
|
+
roomHistory["state"] = JSON.stringify(room.state);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// cache active clients with their reconnection tokens
|
|
91
|
+
// TODO: need to cache each client's StateView as well
|
|
92
|
+
const activeClients = room.clients.map((client) => ({
|
|
93
|
+
sessionId: client.sessionId,
|
|
94
|
+
reconnectionToken: client.reconnectionToken,
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
// collect active client sessionIds to avoid duplicates
|
|
98
|
+
const activeSessionIds = new Set(activeClients.map((c) => c.sessionId));
|
|
99
|
+
|
|
100
|
+
// also cache reserved seats (they don't have reconnectionTokens yet)
|
|
101
|
+
// filter out reserved seats that are already active clients (from devMode reconnection)
|
|
102
|
+
const reservedSeats = Object.keys(room['_reservedSeats'])
|
|
103
|
+
.filter((sessionId) => !activeSessionIds.has(sessionId))
|
|
104
|
+
.map((sessionId) => ({
|
|
105
|
+
sessionId,
|
|
106
|
+
reconnectionToken: undefined,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
roomHistory["clients"] = activeClients.concat(reservedSeats);
|
|
110
|
+
|
|
111
|
+
await presence.hset(getRoomRestoreListKey(), room.roomId, JSON.stringify(roomHistory));
|
|
112
|
+
|
|
113
|
+
// Rewrite updated room history
|
|
114
|
+
logger.debug(`💾 caching room '${room.roomId}' (clients: ${room.clients.length}, has state: ${roomHistory["state"] !== undefined ? "yes" : "no"})`);
|
|
115
|
+
|
|
116
|
+
} catch (e: any) {
|
|
117
|
+
debugAndPrintError(`❌ couldn't cache room '${room.roomId}', due to:\n${e.stack}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function getPreviousProcessId(hostname: string = '') {
|
|
124
|
+
return await presence.hget(getProcessRestoreKey(), hostname);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getRoomRestoreListKey() {
|
|
128
|
+
return 'roomhistory';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getProcessRestoreKey() {
|
|
132
|
+
return 'processhistory';
|
|
133
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
export type { StandardSchemaV1 };
|
|
3
|
+
|
|
4
|
+
export function standardValidate<T extends StandardSchemaV1>(
|
|
5
|
+
schema: T,
|
|
6
|
+
input: StandardSchemaV1.InferInput<T>
|
|
7
|
+
): StandardSchemaV1.InferOutput<T> {
|
|
8
|
+
let result = schema['~standard'].validate(input);
|
|
9
|
+
|
|
10
|
+
if (result instanceof Promise) {
|
|
11
|
+
throw new Error('Schema validation must be synchronous');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// if the `issues` field exists, the validation failed
|
|
15
|
+
if (result.issues) {
|
|
16
|
+
throw new Error(JSON.stringify(result.issues, null, 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (result as StandardSchemaV1.SuccessResult<StandardSchemaV1.InferOutput<T>>).value;
|
|
20
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
|
|
3
|
+
import { EventEmitter } from "events";
|
|
4
|
+
import { type RoomException, type RoomMethodName } from '../errors/RoomExceptions.ts';
|
|
5
|
+
|
|
6
|
+
import { debugAndPrintError, debugMatchMaking } from '../Debug.ts';
|
|
7
|
+
|
|
8
|
+
export type Type<T> = new (...args: any[]) => T;
|
|
9
|
+
export type MethodName<T> = string & {
|
|
10
|
+
[K in keyof T]: T[K] extends (...args: any[]) => any ? K : never
|
|
11
|
+
}[keyof T];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Utility type that extracts the return type of a method or the type of a property
|
|
15
|
+
* from a given class/object type.
|
|
16
|
+
*
|
|
17
|
+
* - If the key is a method, returns the awaited return type of that method
|
|
18
|
+
* - If the key is a property, returns the type of that property
|
|
19
|
+
*/
|
|
20
|
+
export type ExtractMethodOrPropertyType<
|
|
21
|
+
TClass,
|
|
22
|
+
TKey extends keyof TClass
|
|
23
|
+
> = TClass[TKey] extends (...args: any[]) => infer R
|
|
24
|
+
? Awaited<R>
|
|
25
|
+
: TClass[TKey];
|
|
26
|
+
|
|
27
|
+
// remote room call timeouts
|
|
28
|
+
export const REMOTE_ROOM_SHORT_TIMEOUT = Number(process.env.COLYSEUS_PRESENCE_SHORT_TIMEOUT || 2000);
|
|
29
|
+
export const MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME = Number(process.env.COLYSEUS_MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME || 0.5);
|
|
30
|
+
|
|
31
|
+
export function generateId(length: number = 9) {
|
|
32
|
+
return nanoid(length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getBearerToken(authHeader: string) {
|
|
36
|
+
return (authHeader && authHeader.startsWith("Bearer ") && authHeader.substring(7, authHeader.length)) || undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// nodemon sends SIGUSR2 before reloading
|
|
40
|
+
// (https://github.com/remy/nodemon#controlling-shutdown-of-your-script)
|
|
41
|
+
//
|
|
42
|
+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGUSR2'];
|
|
43
|
+
|
|
44
|
+
export function registerGracefulShutdown(callback: (err?: Error) => void) {
|
|
45
|
+
/**
|
|
46
|
+
* Gracefully shutdown on uncaught errors
|
|
47
|
+
*/
|
|
48
|
+
process.on('uncaughtException', (err) => {
|
|
49
|
+
debugAndPrintError(err);
|
|
50
|
+
callback(err);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
signals.forEach((signal) =>
|
|
54
|
+
process.once(signal, () => callback()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function retry<T = any>(
|
|
58
|
+
cb: Function,
|
|
59
|
+
maxRetries: number = 3,
|
|
60
|
+
errorWhiteList: any[] = [],
|
|
61
|
+
retries: number = 0,
|
|
62
|
+
) {
|
|
63
|
+
return new Promise<T>((resolve, reject) => {
|
|
64
|
+
cb()
|
|
65
|
+
.then(resolve)
|
|
66
|
+
.catch((e: any) => {
|
|
67
|
+
if (
|
|
68
|
+
errorWhiteList.indexOf(e.constructor) !== -1 &&
|
|
69
|
+
retries++ < maxRetries
|
|
70
|
+
) {
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
debugMatchMaking("retrying due to error (error: %s, retries: %s, maxRetries: %s)", e.message, retries, maxRetries);
|
|
73
|
+
retry<T>(cb, maxRetries, errorWhiteList, retries).
|
|
74
|
+
then(resolve).
|
|
75
|
+
catch((e2) => reject(e2));
|
|
76
|
+
}, Math.floor(Math.random() * Math.pow(2, retries) * 400));
|
|
77
|
+
|
|
78
|
+
} else {
|
|
79
|
+
reject(e);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function spliceOne(arr: any[], index: number): boolean {
|
|
86
|
+
// manually splice availableRooms array
|
|
87
|
+
// http://jsperf.com/manual-splice
|
|
88
|
+
if (index === -1 || index >= arr.length) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const len = arr.length - 1;
|
|
93
|
+
for (let i = index; i < len; i++) {
|
|
94
|
+
arr[i] = arr[i + 1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
arr.length = len;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class Deferred<T = any> {
|
|
102
|
+
public promise: Promise<T>;
|
|
103
|
+
|
|
104
|
+
public resolve: Function;
|
|
105
|
+
public reject: Function;
|
|
106
|
+
|
|
107
|
+
constructor(promise?: Promise<T>) {
|
|
108
|
+
this.promise = promise ?? new Promise<T>((resolve, reject) => {
|
|
109
|
+
this.resolve = resolve;
|
|
110
|
+
this.reject = reject;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public then(onFulfilled?: (value: T) => any, onRejected?: (reason: any) => any) {
|
|
115
|
+
return this.promise.then(onFulfilled, onRejected);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public catch(func: (value: any) => any) {
|
|
119
|
+
return this.promise.catch(func);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static reject (reason?: any) {
|
|
123
|
+
return new Deferred(Promise.reject(reason));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static resolve<T = any>(value?: T) {
|
|
127
|
+
return new Deferred<T>(Promise.resolve(value));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function merge(a: any, ...objs: any[]): any {
|
|
133
|
+
for (let i = 0, len = objs.length; i < len; i++) {
|
|
134
|
+
const b = objs[i];
|
|
135
|
+
for (const key in b) {
|
|
136
|
+
if (b.hasOwnProperty(key)) {
|
|
137
|
+
a[key] = b[key];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return a;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function wrapTryCatch(
|
|
145
|
+
method: Function,
|
|
146
|
+
onError: (error: RoomException, methodName: RoomMethodName) => void,
|
|
147
|
+
exceptionClass: Type<RoomException>,
|
|
148
|
+
methodName: RoomMethodName,
|
|
149
|
+
rethrow: boolean = false,
|
|
150
|
+
...additionalErrorArgs: any[]
|
|
151
|
+
) {
|
|
152
|
+
return (...args: any[]) => {
|
|
153
|
+
try {
|
|
154
|
+
const result = method(...args);
|
|
155
|
+
if (typeof (result?.catch) === "function") {
|
|
156
|
+
return result.catch((e: Error) => {
|
|
157
|
+
onError(new exceptionClass(e, e.message, ...args, ...additionalErrorArgs), methodName);
|
|
158
|
+
if (rethrow) { throw e; }
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
} catch (e: any) {
|
|
163
|
+
onError(new exceptionClass(e, e.message, ...args, ...additionalErrorArgs), methodName);
|
|
164
|
+
if (rethrow) { throw e; }
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class HttpServerMock extends EventEmitter {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const createNanoEvents = () => ({
|
|
2
|
+
emit(event: string, ...args: any[]) {
|
|
3
|
+
for (
|
|
4
|
+
let callbacks = this.events[event] || [],
|
|
5
|
+
i = 0,
|
|
6
|
+
length = callbacks.length;
|
|
7
|
+
i < length;
|
|
8
|
+
i++
|
|
9
|
+
) {
|
|
10
|
+
callbacks[i](...args)
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
events: {},
|
|
14
|
+
on(event: string, cb: (...args: any[]) => void) {
|
|
15
|
+
;(this.events[event] ||= []).push(cb)
|
|
16
|
+
return () => {
|
|
17
|
+
this.events[event] = this.events[event]?.filter(i => cb !== i)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
})
|