@rpgjs/vite 5.0.0-alpha.9 → 5.0.0-beta.2
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/LICENSE +19 -0
- package/dist/index-Bc9qRSkO.js +5496 -0
- package/dist/index-Bc9qRSkO.js.map +1 -0
- package/dist/index.js +4542 -863
- package/dist/index.js.map +1 -1
- package/dist/mmorpg-build-plugin.d.ts +8 -0
- package/dist/rpgjs-plugin.d.ts +8 -3
- package/dist/server-plugin.d.ts +2 -149
- package/package.json +14 -13
- package/src/mmorpg-build-plugin.ts +123 -0
- package/src/module-config.ts +13 -0
- package/src/rpgjs-plugin.ts +36 -4
- package/src/server-plugin.ts +15 -436
- package/tests/latency-simulation.spec.ts +209 -0
- package/tsconfig.json +2 -2
package/src/server-plugin.ts
CHANGED
|
@@ -1,221 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRpgServerTransport, logNetworkSimulationStatus } from "@rpgjs/server/node";
|
|
2
|
+
import type { RpgTransportServerConstructor, RpgWebSocketServer } from "@rpgjs/server/node";
|
|
2
3
|
import type { ViteDevServer } from "vite";
|
|
3
|
-
import { IncomingMessage } from "http";
|
|
4
|
-
import { Duplex } from "stream";
|
|
5
4
|
|
|
6
|
-
// Types for WebSocket without importing ws directly
|
|
7
|
-
interface WSConnection {
|
|
8
|
-
readyState: number;
|
|
9
|
-
send(data: string): void;
|
|
10
|
-
close(): void;
|
|
11
|
-
on(event: string, callback: (...args: any[]) => void): void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface WSServer {
|
|
15
|
-
handleUpgrade(
|
|
16
|
-
request: IncomingMessage,
|
|
17
|
-
socket: Duplex,
|
|
18
|
-
head: Buffer,
|
|
19
|
-
callback: (ws: WSConnection) => void
|
|
20
|
-
): void;
|
|
21
|
-
close(): void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* PartyConnection class compatible with PartyKit's Party.Connection interface
|
|
26
|
-
*
|
|
27
|
-
* This class implements the Connection interface expected by RPG-JS server,
|
|
28
|
-
* providing WebSocket communication capabilities and connection state management.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```typescript
|
|
32
|
-
* const connection = new PartyConnection(websocket, 'player123');
|
|
33
|
-
* connection.send('Hello player!');
|
|
34
|
-
* connection.setState({ username: 'Alice' });
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
class PartyConnection {
|
|
38
|
-
public id: string;
|
|
39
|
-
public uri: string;
|
|
40
|
-
private _state: any = {};
|
|
41
|
-
|
|
42
|
-
constructor(private ws: WSConnection, id?: string, uri?: string) {
|
|
43
|
-
this.id = id || this.generateId();
|
|
44
|
-
this.uri = uri || "";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Generates a unique identifier for the connection
|
|
49
|
-
*
|
|
50
|
-
* @returns {string} Unique identifier based on timestamp and random number
|
|
51
|
-
*/
|
|
52
|
-
private generateId(): string {
|
|
53
|
-
return `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Sends data to the client via WebSocket
|
|
58
|
-
*
|
|
59
|
-
* @param {any} data - Data to send (automatically serialized to JSON if not string)
|
|
60
|
-
*/
|
|
61
|
-
send(data: any): void {
|
|
62
|
-
if (this.ws.readyState === 1) {
|
|
63
|
-
// WebSocket.OPEN
|
|
64
|
-
const message = typeof data === "string" ? data : JSON.stringify(data);
|
|
65
|
-
this.ws.send(message);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Closes the WebSocket connection
|
|
71
|
-
*/
|
|
72
|
-
close(): void {
|
|
73
|
-
if (this.ws.readyState === 1) {
|
|
74
|
-
// WebSocket.OPEN
|
|
75
|
-
this.ws.close();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Sets state data for this connection
|
|
81
|
-
*
|
|
82
|
-
* @param {any} value - State data to store (max 2KB as per PartyKit spec)
|
|
83
|
-
*/
|
|
84
|
-
setState(value: any): void {
|
|
85
|
-
this._state = value;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Gets the current state of this connection
|
|
90
|
-
*
|
|
91
|
-
* @returns {any} Current connection state
|
|
92
|
-
*/
|
|
93
|
-
get state(): any {
|
|
94
|
-
return this._state;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Room class compatible with PartyKit's Party.Room interface
|
|
100
|
-
*
|
|
101
|
-
* This class manages multiple WebSocket connections and provides broadcasting
|
|
102
|
-
* capabilities, storage, and connection management as expected by RPG-JS server.
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* ```typescript
|
|
106
|
-
* const room = new Room('lobby-1');
|
|
107
|
-
* room.broadcast('Game started!');
|
|
108
|
-
* const playerCount = [...room.getConnections()].length;
|
|
109
|
-
* ```
|
|
110
|
-
*/
|
|
111
|
-
class Room {
|
|
112
|
-
public id: string;
|
|
113
|
-
public internalID: string;
|
|
114
|
-
public env: Record<string, any> = {};
|
|
115
|
-
public context: any = {};
|
|
116
|
-
private connections: Map<string, PartyConnection> = new Map();
|
|
117
|
-
private storageData: Map<string, any> = new Map();
|
|
118
|
-
|
|
119
|
-
constructor(id: string) {
|
|
120
|
-
this.id = id;
|
|
121
|
-
this.internalID = `internal_${id}_${Date.now()}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Broadcasts a message to all connected clients
|
|
126
|
-
*
|
|
127
|
-
* @param {any} message - Message to broadcast
|
|
128
|
-
* @param {string[]} except - Array of connection IDs to exclude from broadcast
|
|
129
|
-
*/
|
|
130
|
-
broadcast(message: any, except: string[] = []): void {
|
|
131
|
-
const data =
|
|
132
|
-
typeof message === "string" ? message : JSON.stringify(message);
|
|
133
|
-
|
|
134
|
-
for (const [connectionId, connection] of this.connections) {
|
|
135
|
-
if (!except.includes(connectionId)) {
|
|
136
|
-
connection.send(data);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Gets a connection by its ID
|
|
143
|
-
*
|
|
144
|
-
* @param {string} id - Connection ID
|
|
145
|
-
* @returns {PartyConnection | undefined} The connection or undefined if not found
|
|
146
|
-
*/
|
|
147
|
-
getConnection(id: string): PartyConnection | undefined {
|
|
148
|
-
return this.connections.get(id);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Gets all currently connected clients
|
|
153
|
-
*
|
|
154
|
-
* @param {string} tag - Optional tag to filter connections (not implemented yet)
|
|
155
|
-
* @returns {IterableIterator<PartyConnection>} Iterator of all connections
|
|
156
|
-
*/
|
|
157
|
-
getConnections(tag?: string): IterableIterator<PartyConnection> {
|
|
158
|
-
// TODO: Implement tag filtering if needed
|
|
159
|
-
return this.connections.values();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Adds a connection to this room
|
|
164
|
-
*
|
|
165
|
-
* @param {PartyConnection} connection - Connection to add
|
|
166
|
-
*/
|
|
167
|
-
addConnection(connection: PartyConnection): void {
|
|
168
|
-
this.connections.set(connection.id, connection);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Removes a connection from this room
|
|
173
|
-
*
|
|
174
|
-
* @param {string} connectionId - ID of connection to remove
|
|
175
|
-
*/
|
|
176
|
-
removeConnection(connectionId: string): void {
|
|
177
|
-
this.connections.delete(connectionId);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Simple key-value storage for the room
|
|
182
|
-
*/
|
|
183
|
-
get storage() {
|
|
184
|
-
return {
|
|
185
|
-
put: async (key: string, value: any) => {
|
|
186
|
-
this.storageData.set(key, value);
|
|
187
|
-
},
|
|
188
|
-
get: async <T = any>(key: string): Promise<T | undefined> => {
|
|
189
|
-
return this.storageData.get(key) as T;
|
|
190
|
-
},
|
|
191
|
-
delete: async (key: string) => {
|
|
192
|
-
this.storageData.delete(key);
|
|
193
|
-
},
|
|
194
|
-
list: async () => {
|
|
195
|
-
return Array.from(this.storageData.keys());
|
|
196
|
-
},
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Utility function to safely import WebSocketServer
|
|
203
|
-
*
|
|
204
|
-
* This function checks if we are in a Node.js environment
|
|
205
|
-
* before trying to import the ws module, thus avoiding
|
|
206
|
-
* browser compatibility errors.
|
|
207
|
-
*
|
|
208
|
-
* @returns {Promise<any>} The WebSocketServer class or null if not available
|
|
209
|
-
*/
|
|
210
5
|
async function importWebSocketServer(): Promise<any> {
|
|
211
|
-
// Check if we are in a Node.js environment
|
|
212
6
|
if (typeof process === "undefined" || !process.versions?.node) {
|
|
213
7
|
console.warn("Not in Node.js environment, WebSocket server not available");
|
|
214
8
|
return null;
|
|
215
9
|
}
|
|
216
10
|
|
|
217
11
|
try {
|
|
218
|
-
// Use createRequire to import ws in an ES module context
|
|
219
12
|
const { createRequire } = await import("module");
|
|
220
13
|
const require = createRequire(import.meta.url);
|
|
221
14
|
const ws = require("ws");
|
|
@@ -226,46 +19,14 @@ async function importWebSocketServer(): Promise<any> {
|
|
|
226
19
|
}
|
|
227
20
|
}
|
|
228
21
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
* This plugin configures the development server to automatically start
|
|
233
|
-
* an RPG-JS server instance when Vite's dev server starts. It handles
|
|
234
|
-
* the instantiation and initialization of the server module, and sets up
|
|
235
|
-
* HTTP request and WebSocket connection forwarding to the RPG-JS server.
|
|
236
|
-
*
|
|
237
|
-
* The plugin intercepts:
|
|
238
|
-
* - HTTP requests to `/parties/*` paths and forwards them to the RPG-JS server
|
|
239
|
-
* - WebSocket upgrade requests and establishes connections with the RPG-JS server
|
|
240
|
-
*
|
|
241
|
-
* @param {new () => RpgServerEngine} serverModule - A class constructor that extends RpgServerEngine
|
|
242
|
-
* @returns {Object} Vite plugin configuration object
|
|
243
|
-
*
|
|
244
|
-
* @example
|
|
245
|
-
* ```typescript
|
|
246
|
-
* // In vite.config.ts
|
|
247
|
-
* import { serverPlugin } from '@rpgjs/vite';
|
|
248
|
-
* import startServer from './src/server';
|
|
249
|
-
*
|
|
250
|
-
* export default defineConfig({
|
|
251
|
-
* plugins: [
|
|
252
|
-
* serverPlugin(startServer)
|
|
253
|
-
* ]
|
|
254
|
-
* });
|
|
255
|
-
* ```
|
|
256
|
-
*/
|
|
257
|
-
export function serverPlugin(
|
|
258
|
-
serverModule: new (room: Room) => RpgServerEngine
|
|
259
|
-
) {
|
|
260
|
-
let wsServer: WSServer | null = null;
|
|
261
|
-
let rooms: Map<string, Room> = new Map();
|
|
262
|
-
let servers: Map<string, RpgServerEngine> = new Map();
|
|
22
|
+
export function serverPlugin(serverModule: RpgTransportServerConstructor) {
|
|
23
|
+
let wsServer: RpgWebSocketServer | null = null;
|
|
24
|
+
const transport = createRpgServerTransport(serverModule);
|
|
263
25
|
|
|
264
26
|
return {
|
|
265
27
|
name: "server-plugin",
|
|
266
28
|
|
|
267
29
|
async configureServer(server: ViteDevServer) {
|
|
268
|
-
// Dynamic import of WebSocketServer to avoid compatibility issues
|
|
269
30
|
try {
|
|
270
31
|
const WebSocketServerClass = await importWebSocketServer();
|
|
271
32
|
if (WebSocketServerClass) {
|
|
@@ -281,203 +42,22 @@ export function serverPlugin(
|
|
|
281
42
|
wsServer = null;
|
|
282
43
|
}
|
|
283
44
|
|
|
284
|
-
console.log(
|
|
45
|
+
console.log("RPG-JS server plugin initialized");
|
|
46
|
+
logNetworkSimulationStatus();
|
|
285
47
|
|
|
286
|
-
// HTTP request interception for /parties/* routes
|
|
287
48
|
server.middlewares.use("/parties", async (req, res, next) => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
console.log(`RPG-JS HTTP request: ${req.method} ${req.url}`);
|
|
292
|
-
|
|
293
|
-
// Create a basic response for test routes
|
|
294
|
-
if (req.url?.includes("/test")) {
|
|
295
|
-
res.statusCode = 200;
|
|
296
|
-
res.setHeader("Content-Type", "application/json");
|
|
297
|
-
res.end(
|
|
298
|
-
JSON.stringify({
|
|
299
|
-
message: "RPG-JS server is running",
|
|
300
|
-
timestamp: new Date().toISOString(),
|
|
301
|
-
})
|
|
302
|
-
);
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
next();
|
|
307
|
-
} catch (error) {
|
|
308
|
-
console.error("Error handling RPG-JS request:", error);
|
|
309
|
-
res.statusCode = 500;
|
|
310
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
311
|
-
}
|
|
49
|
+
await transport.handleNodeRequest(req, res, next, {
|
|
50
|
+
mountedPath: "/parties",
|
|
51
|
+
});
|
|
312
52
|
});
|
|
313
|
-
// WebSocket upgrade handling (if available)
|
|
314
|
-
if (wsServer) {
|
|
315
|
-
server.httpServer?.on(
|
|
316
|
-
"upgrade",
|
|
317
|
-
(request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
318
|
-
const url = new URL(request.url!, `http://${request.headers.host}`);
|
|
319
|
-
|
|
320
|
-
// Check if it's a WebSocket connection for RPG-JS
|
|
321
|
-
if (url.pathname.startsWith("/parties/")) {
|
|
322
|
-
console.log(`WebSocket upgrade request: ${url.pathname}`);
|
|
323
53
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
async (ws: WSConnection) => {
|
|
329
|
-
try {
|
|
330
|
-
// Extract room name from URL: /parties/main/lobby-1 -> lobby-1
|
|
331
|
-
const pathParts = url.pathname.split("/");
|
|
332
|
-
const roomName = pathParts[pathParts.length - 1]; // Get the last part (lobby-1)
|
|
333
|
-
|
|
334
|
-
// Extract query parameters (like _pk)
|
|
335
|
-
const queryParams = Object.fromEntries(
|
|
336
|
-
url.searchParams.entries()
|
|
337
|
-
);
|
|
338
|
-
console.log(
|
|
339
|
-
`Room: ${roomName}, Query params:`,
|
|
340
|
-
queryParams
|
|
341
|
-
);
|
|
342
|
-
|
|
343
|
-
// Get or create the room
|
|
344
|
-
let room = rooms.get(roomName);
|
|
345
|
-
if (!room) {
|
|
346
|
-
room = new Room(roomName);
|
|
347
|
-
rooms.set(roomName, room);
|
|
348
|
-
console.log(`Created new room: ${roomName}`);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Get or create the server for this room
|
|
352
|
-
let rpgServer = servers.get(roomName);
|
|
353
|
-
if (!rpgServer) {
|
|
354
|
-
rpgServer = new serverModule(room);
|
|
355
|
-
servers.set(roomName, rpgServer);
|
|
356
|
-
console.log(`Created new server instance for room: ${roomName}`);
|
|
357
|
-
|
|
358
|
-
// Call onStart on the new server instance
|
|
359
|
-
if (typeof rpgServer.onStart === "function") {
|
|
360
|
-
try {
|
|
361
|
-
await rpgServer.onStart();
|
|
362
|
-
console.log(`Server started for room: ${roomName}`);
|
|
363
|
-
} catch (error) {
|
|
364
|
-
console.error(`Error starting server for room ${roomName}:`, error);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Create a connection instance
|
|
370
|
-
const connection = new PartyConnection(
|
|
371
|
-
ws,
|
|
372
|
-
undefined,
|
|
373
|
-
request.url
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
// Add connection to the room
|
|
377
|
-
room.addConnection(connection);
|
|
378
|
-
|
|
379
|
-
console.log(
|
|
380
|
-
`WebSocket connection established: ${connection.id} in room: ${roomName}`
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
// Set up WebSocket event handlers
|
|
384
|
-
ws.on("message", async (data: Buffer) => {
|
|
385
|
-
try {
|
|
386
|
-
const message = data.toString();
|
|
387
|
-
// Call onMessage on the RPG-JS server
|
|
388
|
-
if (typeof rpgServer.onMessage === "function") {
|
|
389
|
-
await rpgServer.onMessage(message, connection as any);
|
|
390
|
-
}
|
|
391
|
-
} catch (error) {
|
|
392
|
-
console.error(
|
|
393
|
-
"Error processing WebSocket message:",
|
|
394
|
-
error
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
ws.on("close", async () => {
|
|
400
|
-
console.log(
|
|
401
|
-
`WebSocket connection closed: ${connection.id} from room: ${roomName}`
|
|
402
|
-
);
|
|
403
|
-
// Remove connection from room
|
|
404
|
-
room.removeConnection(connection.id);
|
|
405
|
-
// Call onClose on the RPG-JS server
|
|
406
|
-
if (typeof rpgServer.onClose === "function") {
|
|
407
|
-
await rpgServer.onClose(connection as any);
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
ws.on("error", async (error: Error) => {
|
|
412
|
-
console.error("WebSocket error:", error);
|
|
413
|
-
// Remove connection from room
|
|
414
|
-
room.removeConnection(connection.id);
|
|
415
|
-
// Call onClose on the RPG-JS server
|
|
416
|
-
if (typeof rpgServer.onClose === "function") {
|
|
417
|
-
await rpgServer.onClose(connection as any);
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
// Call onConnect on the RPG-JS server if the method exists
|
|
422
|
-
if (typeof rpgServer.onConnect === "function") {
|
|
423
|
-
// Create a compatible connection context with Headers-like interface
|
|
424
|
-
const headers = new Map();
|
|
425
|
-
if (request.headers) {
|
|
426
|
-
Object.entries(request.headers).forEach(
|
|
427
|
-
([key, value]) => {
|
|
428
|
-
headers.set(
|
|
429
|
-
key.toLowerCase(),
|
|
430
|
-
Array.isArray(value) ? value[0] : value
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const connectionContext = {
|
|
437
|
-
request: {
|
|
438
|
-
headers: {
|
|
439
|
-
has: (name: string) =>
|
|
440
|
-
headers.has(name.toLowerCase()),
|
|
441
|
-
get: (name: string) =>
|
|
442
|
-
headers.get(name.toLowerCase()),
|
|
443
|
-
entries: () => headers.entries(),
|
|
444
|
-
keys: () => headers.keys(),
|
|
445
|
-
values: () => headers.values(),
|
|
446
|
-
},
|
|
447
|
-
url: request.url,
|
|
448
|
-
method: request.method,
|
|
449
|
-
},
|
|
450
|
-
url: url,
|
|
451
|
-
};
|
|
452
|
-
await rpgServer.onConnect(
|
|
453
|
-
connection as any,
|
|
454
|
-
connectionContext as any
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Send connection confirmation
|
|
459
|
-
connection.send({
|
|
460
|
-
type: "connected",
|
|
461
|
-
id: connection.id,
|
|
462
|
-
message: "Connected to RPG-JS server",
|
|
463
|
-
});
|
|
464
|
-
} catch (error) {
|
|
465
|
-
console.error(
|
|
466
|
-
"Error establishing WebSocket connection:",
|
|
467
|
-
error
|
|
468
|
-
);
|
|
469
|
-
ws.close();
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
);
|
|
54
|
+
if (wsServer) {
|
|
55
|
+
server.httpServer?.on("upgrade", (request, socket, head) => {
|
|
56
|
+
void transport.handleUpgrade(wsServer!, request, socket, head);
|
|
57
|
+
});
|
|
476
58
|
}
|
|
477
59
|
|
|
478
|
-
console.log(
|
|
479
|
-
"RPG-JS server plugin configured with HTTP and WebSocket forwarding"
|
|
480
|
-
);
|
|
60
|
+
console.log("RPG-JS server plugin configured with HTTP and WebSocket forwarding !");
|
|
481
61
|
},
|
|
482
62
|
|
|
483
63
|
buildStart() {
|
|
@@ -485,7 +65,6 @@ export function serverPlugin(
|
|
|
485
65
|
},
|
|
486
66
|
|
|
487
67
|
buildEnd() {
|
|
488
|
-
// Cleanup when server stops
|
|
489
68
|
if (wsServer) {
|
|
490
69
|
wsServer.close();
|
|
491
70
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock the PartyConnection class for testing
|
|
4
|
+
class MockPartyConnection {
|
|
5
|
+
public static latencyEnabled: boolean = false;
|
|
6
|
+
public static latencyMinMs: number = 50;
|
|
7
|
+
public static latencyMaxMs: number = 200;
|
|
8
|
+
public static latencyFilter: string = '';
|
|
9
|
+
|
|
10
|
+
public id: string;
|
|
11
|
+
private ws: any;
|
|
12
|
+
|
|
13
|
+
constructor(ws: any, id?: string) {
|
|
14
|
+
this.ws = ws;
|
|
15
|
+
this.id = id || 'test-connection';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async send(data: any): Promise<void> {
|
|
19
|
+
if (this.ws.readyState === 1) {
|
|
20
|
+
const message = typeof data === "string" ? data : JSON.stringify(data);
|
|
21
|
+
|
|
22
|
+
// Check if latency simulation is enabled
|
|
23
|
+
if (MockPartyConnection.latencyEnabled && MockPartyConnection.latencyMaxMs > 0) {
|
|
24
|
+
// Apply filter if specified
|
|
25
|
+
if (MockPartyConnection.latencyFilter && !message.includes(MockPartyConnection.latencyFilter)) {
|
|
26
|
+
this.ws.send(message);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Calculate random latency between min and max
|
|
31
|
+
const latencyMs = Math.random() * (MockPartyConnection.latencyMaxMs - MockPartyConnection.latencyMinMs) + MockPartyConnection.latencyMinMs;
|
|
32
|
+
|
|
33
|
+
// Delay the message
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, latencyMs));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.ws.send(message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static configureLatency(enabled: boolean, minMs: number, maxMs: number, filter?: string): void {
|
|
42
|
+
MockPartyConnection.latencyEnabled = enabled;
|
|
43
|
+
MockPartyConnection.latencyMinMs = Math.max(0, minMs);
|
|
44
|
+
MockPartyConnection.latencyMaxMs = Math.max(MockPartyConnection.latencyMinMs, maxMs);
|
|
45
|
+
MockPartyConnection.latencyFilter = filter || '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static getLatencyStatus(): { enabled: boolean; minMs: number; maxMs: number; filter: string } {
|
|
49
|
+
return {
|
|
50
|
+
enabled: MockPartyConnection.latencyEnabled,
|
|
51
|
+
minMs: MockPartyConnection.latencyMinMs,
|
|
52
|
+
maxMs: MockPartyConnection.latencyMaxMs,
|
|
53
|
+
filter: MockPartyConnection.latencyFilter
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('Latency Simulation', () => {
|
|
59
|
+
let mockWs: any;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
// Reset latency settings before each test
|
|
63
|
+
MockPartyConnection.configureLatency(false, 0, 0);
|
|
64
|
+
|
|
65
|
+
// Create mock WebSocket
|
|
66
|
+
mockWs = {
|
|
67
|
+
readyState: 1, // WebSocket.OPEN
|
|
68
|
+
send: vi.fn(),
|
|
69
|
+
close: vi.fn()
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Configuration', () => {
|
|
78
|
+
it('should configure latency settings correctly', () => {
|
|
79
|
+
MockPartyConnection.configureLatency(true, 100, 300, 'sync');
|
|
80
|
+
|
|
81
|
+
const status = MockPartyConnection.getLatencyStatus();
|
|
82
|
+
expect(status.enabled).toBe(true);
|
|
83
|
+
expect(status.minMs).toBe(100);
|
|
84
|
+
expect(status.maxMs).toBe(300);
|
|
85
|
+
expect(status.filter).toBe('sync');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should clamp minMs to 0', () => {
|
|
89
|
+
MockPartyConnection.configureLatency(true, -50, 200);
|
|
90
|
+
|
|
91
|
+
const status = MockPartyConnection.getLatencyStatus();
|
|
92
|
+
expect(status.minMs).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should ensure maxMs is at least minMs', () => {
|
|
96
|
+
MockPartyConnection.configureLatency(true, 200, 100);
|
|
97
|
+
|
|
98
|
+
const status = MockPartyConnection.getLatencyStatus();
|
|
99
|
+
expect(status.maxMs).toBe(200); // Should be set to minMs value
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should disable latency when enabled is false', () => {
|
|
103
|
+
MockPartyConnection.configureLatency(false, 100, 300);
|
|
104
|
+
|
|
105
|
+
const status = MockPartyConnection.getLatencyStatus();
|
|
106
|
+
expect(status.enabled).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('Message Sending', () => {
|
|
111
|
+
it('should send message immediately when latency is disabled', async () => {
|
|
112
|
+
const connection = new MockPartyConnection(mockWs);
|
|
113
|
+
const startTime = Date.now();
|
|
114
|
+
|
|
115
|
+
await connection.send('test message');
|
|
116
|
+
|
|
117
|
+
const endTime = Date.now();
|
|
118
|
+
expect(mockWs.send).toHaveBeenCalledWith('test message');
|
|
119
|
+
expect(endTime - startTime).toBeLessThan(10); // Should be almost immediate
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should delay message when latency is enabled', async () => {
|
|
123
|
+
MockPartyConnection.configureLatency(true, 50, 100);
|
|
124
|
+
const connection = new MockPartyConnection(mockWs);
|
|
125
|
+
const startTime = Date.now();
|
|
126
|
+
|
|
127
|
+
await connection.send('test message');
|
|
128
|
+
|
|
129
|
+
const endTime = Date.now();
|
|
130
|
+
expect(mockWs.send).toHaveBeenCalledWith('test message');
|
|
131
|
+
expect(endTime - startTime).toBeGreaterThanOrEqual(50);
|
|
132
|
+
expect(endTime - startTime).toBeLessThanOrEqual(150); // Allow some buffer
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should apply filter correctly', async () => {
|
|
136
|
+
MockPartyConnection.configureLatency(true, 50, 100, 'sync');
|
|
137
|
+
const connection = new MockPartyConnection(mockWs);
|
|
138
|
+
|
|
139
|
+
// Message with filter should be delayed
|
|
140
|
+
const startTime1 = Date.now();
|
|
141
|
+
await connection.send('sync message');
|
|
142
|
+
const endTime1 = Date.now();
|
|
143
|
+
expect(endTime1 - startTime1).toBeGreaterThanOrEqual(50);
|
|
144
|
+
|
|
145
|
+
// Message without filter should be sent immediately
|
|
146
|
+
const startTime2 = Date.now();
|
|
147
|
+
await connection.send('normal message');
|
|
148
|
+
const endTime2 = Date.now();
|
|
149
|
+
expect(endTime2 - startTime2).toBeLessThan(10);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should not send when WebSocket is not open', async () => {
|
|
153
|
+
mockWs.readyState = 3; // WebSocket.CLOSED
|
|
154
|
+
const connection = new MockPartyConnection(mockWs);
|
|
155
|
+
|
|
156
|
+
await connection.send('test message');
|
|
157
|
+
|
|
158
|
+
expect(mockWs.send).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should handle JSON data correctly', async () => {
|
|
162
|
+
const connection = new MockPartyConnection(mockWs);
|
|
163
|
+
const testData = { type: 'test', value: 123 };
|
|
164
|
+
|
|
165
|
+
await connection.send(testData);
|
|
166
|
+
|
|
167
|
+
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(testData));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('Edge Cases', () => {
|
|
172
|
+
it('should handle zero latency range', async () => {
|
|
173
|
+
MockPartyConnection.configureLatency(true, 0, 0);
|
|
174
|
+
const connection = new MockPartyConnection(mockWs);
|
|
175
|
+
const startTime = Date.now();
|
|
176
|
+
|
|
177
|
+
await connection.send('test message');
|
|
178
|
+
|
|
179
|
+
const endTime = Date.now();
|
|
180
|
+
expect(mockWs.send).toHaveBeenCalledWith('test message');
|
|
181
|
+
expect(endTime - startTime).toBeLessThan(10); // Should be immediate
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle very high latency', async () => {
|
|
185
|
+
MockPartyConnection.configureLatency(true, 1000, 2000);
|
|
186
|
+
const connection = new MockPartyConnection(mockWs);
|
|
187
|
+
const startTime = Date.now();
|
|
188
|
+
|
|
189
|
+
await connection.send('test message');
|
|
190
|
+
|
|
191
|
+
const endTime = Date.now();
|
|
192
|
+
expect(mockWs.send).toHaveBeenCalledWith('test message');
|
|
193
|
+
expect(endTime - startTime).toBeGreaterThanOrEqual(1000);
|
|
194
|
+
expect(endTime - startTime).toBeLessThanOrEqual(2100); // Allow buffer
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle empty filter string', async () => {
|
|
198
|
+
MockPartyConnection.configureLatency(true, 50, 100, '');
|
|
199
|
+
const connection = new MockPartyConnection(mockWs);
|
|
200
|
+
const startTime = Date.now();
|
|
201
|
+
|
|
202
|
+
await connection.send('test message');
|
|
203
|
+
|
|
204
|
+
const endTime = Date.now();
|
|
205
|
+
expect(mockWs.send).toHaveBeenCalledWith('test message');
|
|
206
|
+
expect(endTime - startTime).toBeGreaterThanOrEqual(50); // Should still be delayed
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|