@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.
@@ -1,221 +1,14 @@
1
- import { RpgServerEngine } from "@rpgjs/server";
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
- * Creates a Vite plugin for integrating RPG-JS server functionality
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('RPG-JS server plugin initialized');
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
- try {
289
- // For now, pass to the next middleware
290
- // The RPG-JS server handles its own routes via @signe/room
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
- wsServer!.handleUpgrade(
325
- request,
326
- socket,
327
- head,
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
+ });