@rpgjs/vite 5.0.0-alpha.41 → 5.0.0-alpha.44

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,501 +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
- * Includes optional packet loss simulation for testing network conditions.
30
- *
31
- * @example
32
- * ```typescript
33
- * const connection = new PartyConnection(websocket, 'player123');
34
- * connection.send('Hello player!');
35
- * connection.setState({ username: 'Alice' });
36
- * ```
37
- */
38
- class PartyConnection {
39
- public id: string;
40
- public uri: string;
41
- private _state: any = {};
42
- private messageQueue: Array<{ message: string; timestamp: number; sequence: number }> = [];
43
- private isProcessingQueue: boolean = false;
44
- private sequenceCounter: number = 0;
45
- private incomingQueue: Array<{
46
- message: string;
47
- timestamp: number;
48
- processor: (messages: string[]) => Promise<void>;
49
- }> = [];
50
- private isProcessingIncomingQueue: boolean = false;
51
- public static packetLossRate: number = parseFloat(process.env.RPGJS_PACKET_LOSS_RATE || '0.1');
52
- public static packetLossEnabled: boolean = process.env.RPGJS_ENABLE_PACKET_LOSS === 'true';
53
- public static packetLossFilter: string = process.env.RPGJS_PACKET_LOSS_FILTER || '';
54
- public static bandwidthEnabled: boolean = process.env.RPGJS_ENABLE_BANDWIDTH === 'true';
55
- public static bandwidthKbps: number = parseInt(process.env.RPGJS_BANDWIDTH_KBPS || '100'); // Kilobits per second
56
- public static bandwidthFilter: string = process.env.RPGJS_BANDWIDTH_FILTER || '';
57
- public static latencyEnabled: boolean = process.env.RPGJS_ENABLE_LATENCY === 'true';
58
- public static latencyMs: number = parseInt(process.env.RPGJS_LATENCY_MS || '50'); // Fixed latency in milliseconds
59
- public static latencyFilter: string = process.env.RPGJS_LATENCY_FILTER || '';
60
-
61
- constructor(private ws: WSConnection, id?: string, uri?: string) {
62
- this.id = id || this.generateId();
63
- this.uri = uri || "";
64
- }
65
-
66
- /**
67
- * Generates a unique identifier for the connection
68
- *
69
- * @returns {string} Unique identifier based on timestamp and random number
70
- */
71
- private generateId(): string {
72
- return `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
73
- }
74
-
75
- /**
76
- * Sends data to the client via WebSocket with bandwidth and latency simulation
77
- *
78
- * Messages are queued and sent in the order they were called, with bandwidth limitations
79
- * and network latency that simulate a slow, distant network connection. This ensures that
80
- * if send(A) is called before send(B), A will always be sent before B, but both will be
81
- * slowed down by bandwidth constraints and network latency.
82
- *
83
- * @param {any} data - Data to send (automatically serialized to JSON if not string)
84
- */
85
- async send(data: any): Promise<void> {
86
- if (this.ws.readyState !== 1) {
87
- // WebSocket not open
88
- return;
89
- }
90
-
91
- const message = typeof data === "string" ? data : JSON.stringify(data);
92
- const timestamp = Date.now();
93
- const sequence = ++this.sequenceCounter;
94
-
95
- // Add message to queue
96
- this.messageQueue.push({ message, timestamp, sequence });
97
-
98
- // Start processing queue if not already processing
99
- if (!this.isProcessingQueue) {
100
- this.processMessageQueue();
101
- }
102
- }
103
-
104
- /**
105
- * Processes the outgoing queue in order.
106
- *
107
- * Each message receives its own fixed latency (if enabled), while preserving
108
- * original spacing and order.
109
- */
110
- private async processMessageQueue(): Promise<void> {
111
- await this.flushSendQueue();
112
- }
113
-
114
- /**
115
- * Flushes the send queue sequentially, respecting bandwidth constraints.
116
- */
117
- private async flushSendQueue(): Promise<void> {
118
- if (this.isProcessingQueue) return;
119
- this.isProcessingQueue = true;
120
-
121
- while (this.messageQueue.length > 0) {
122
- const queueItem = this.messageQueue.shift()!;
123
-
124
- // Apply fixed one-way latency per message (not batched bursts).
125
- if (this.shouldApplyLatency(queueItem.message)) {
126
- await this.waitUntil(queueItem.timestamp + PartyConnection.latencyMs);
127
- }
128
-
129
- // Bandwidth simulation per message
130
- if (PartyConnection.bandwidthEnabled && PartyConnection.bandwidthKbps > 0) {
131
- if (!PartyConnection.bandwidthFilter || queueItem.message.includes(PartyConnection.bandwidthFilter)) {
132
- const messageSizeBits = queueItem.message.length * 8;
133
- const transmissionTimeMs = (messageSizeBits / (PartyConnection.bandwidthKbps * 1000)) * 1000;
134
- const minDelayMs = 10;
135
- const bandwidthDelayMs = Math.max(transmissionTimeMs, minDelayMs);
136
- console.log(`\x1b[34m[BANDWIDTH SIMULATION]\x1b[0m Connection ${this.id}: Message #${queueItem.sequence} transmission time: ${bandwidthDelayMs.toFixed(1)}ms`);
137
- await new Promise(resolve => setTimeout(resolve, bandwidthDelayMs));
138
- }
139
- }
140
-
141
- this.ws.send(queueItem.message);
142
- }
143
-
144
- this.isProcessingQueue = false;
145
- }
146
-
147
- private shouldApplyLatency(message: string): boolean {
148
- if (!PartyConnection.latencyEnabled || PartyConnection.latencyMs <= 0) {
149
- return false;
150
- }
151
- if (!PartyConnection.latencyFilter) {
152
- return true;
153
- }
154
- return message.includes(PartyConnection.latencyFilter);
155
- }
156
-
157
- private async waitUntil(targetTimestamp: number): Promise<void> {
158
- const delayMs = targetTimestamp - Date.now();
159
- if (delayMs <= 0) {
160
- return;
161
- }
162
- await new Promise((resolve) => setTimeout(resolve, delayMs));
163
- }
164
-
165
- /**
166
- * Closes the WebSocket connection
167
- */
168
- close(): void {
169
- if (this.ws.readyState === 1) {
170
- // WebSocket.OPEN
171
- this.ws.close();
172
- }
173
- }
174
-
175
- /**
176
- * Sets state data for this connection
177
- *
178
- * @param {any} value - State data to store (max 2KB as per PartyKit spec)
179
- */
180
- setState(value: any): void {
181
- this._state = value;
182
- }
183
-
184
- /**
185
- * Gets the current state of this connection
186
- *
187
- * @returns {any} Current connection state
188
- */
189
- get state(): any {
190
- return this._state;
191
- }
192
-
193
- /**
194
- * Buffers incoming messages to simulate TCP latency on reception.
195
- *
196
- * Messages are processed in strict order. Each message keeps its own fixed
197
- * latency delay relative to the moment it arrived.
198
- *
199
- * @param {string} message - Raw incoming message
200
- * @param {(messages: string[]) => Promise<void>} processor - Async batch processor
201
- *
202
- * @example
203
- * await connection.bufferIncoming(raw, async (batch) => {
204
- * for (const msg of batch) await handle(msg)
205
- * })
206
- */
207
- bufferIncoming(message: string, processor: (messages: string[]) => Promise<void>): void {
208
- this.incomingQueue.push({
209
- message,
210
- timestamp: Date.now(),
211
- processor,
212
- });
213
- if (!this.isProcessingIncomingQueue) {
214
- void this.processIncomingQueue();
215
- }
216
- }
217
-
218
- private async processIncomingQueue(): Promise<void> {
219
- if (this.isProcessingIncomingQueue) {
220
- return;
221
- }
222
- this.isProcessingIncomingQueue = true;
223
- while (this.incomingQueue.length > 0) {
224
- const item = this.incomingQueue.shift()!;
225
- if (this.shouldApplyLatency(item.message)) {
226
- await this.waitUntil(item.timestamp + PartyConnection.latencyMs);
227
- }
228
- try {
229
- await item.processor([item.message]);
230
- } catch (err) {
231
- console.error('Error processing incoming message:', err);
232
- }
233
- }
234
- this.isProcessingIncomingQueue = false;
235
- }
236
-
237
- /**
238
- * Configures packet loss simulation settings
239
- *
240
- * @param {boolean} enabled - Whether to enable packet loss simulation
241
- * @param {number} rate - Packet loss rate (0.0 to 1.0, e.g., 0.1 = 10% loss)
242
- * @param {string} filter - Optional filter string to only simulate loss for messages containing this string
243
- *
244
- * @example
245
- * ```typescript
246
- * PartyConnection.configurePacketLoss(true, 0.15); // 15% packet loss
247
- * PartyConnection.configurePacketLoss(true, 0.2, 'sync'); // 20% loss only for sync messages
248
- * ```
249
- */
250
- static configurePacketLoss(enabled: boolean, rate: number, filter?: string): void {
251
- PartyConnection.packetLossEnabled = enabled;
252
- PartyConnection.packetLossRate = Math.max(0, Math.min(1, rate)); // Clamp between 0 and 1
253
- PartyConnection.packetLossFilter = filter || '';
254
-
255
- if (enabled && rate > 0) {
256
- const filterInfo = filter ? ` (filtered: "${filter}")` : '';
257
- console.log(`\x1b[35m[PACKET LOSS SIMULATION]\x1b[0m Enabled with ${(rate * 100).toFixed(1)}% loss rate${filterInfo}`);
258
- } else if (enabled) {
259
- console.log(`\x1b[35m[PACKET LOSS SIMULATION]\x1b[0m Enabled but rate is 0% (no messages will be dropped)`);
260
- } else {
261
- console.log(`\x1b[35m[PACKET LOSS SIMULATION]\x1b[0m Disabled`);
262
- }
263
- }
264
-
265
- /**
266
- * Gets current packet loss simulation status
267
- *
268
- * @returns {Object} Current configuration
269
- */
270
- static getPacketLossStatus(): { enabled: boolean; rate: number; filter: string } {
271
- return {
272
- enabled: PartyConnection.packetLossEnabled,
273
- rate: PartyConnection.packetLossRate,
274
- filter: PartyConnection.packetLossFilter
275
- };
276
- }
277
-
278
- /**
279
- * Configures bandwidth simulation settings
280
- *
281
- * @param {boolean} enabled - Whether to enable bandwidth simulation
282
- * @param {number} kbps - Bandwidth in kilobits per second (e.g., 100 = 100 kbps)
283
- * @param {string} filter - Optional filter string to only simulate bandwidth for messages containing this string
284
- *
285
- * @example
286
- * ```typescript
287
- * PartyConnection.configureBandwidth(true, 50); // 50 kbps (very slow connection)
288
- * PartyConnection.configureBandwidth(true, 1000, 'sync'); // 1 Mbps only for sync messages
289
- * ```
290
- */
291
- static configureBandwidth(enabled: boolean, kbps: number, filter?: string): void {
292
- PartyConnection.bandwidthEnabled = enabled;
293
- PartyConnection.bandwidthKbps = Math.max(1, kbps); // Minimum 1 kbps
294
- PartyConnection.bandwidthFilter = filter || '';
295
-
296
- if (enabled && kbps > 0) {
297
- const filterInfo = filter ? ` (filtered: "${filter}")` : '';
298
- console.log(`\x1b[35m[BANDWIDTH SIMULATION]\x1b[0m Enabled with ${kbps} kbps bandwidth${filterInfo}`);
299
- } else if (enabled) {
300
- console.log(`\x1b[35m[BANDWIDTH SIMULATION]\x1b[0m Enabled but bandwidth is 0 kbps (no delay will be applied)`);
301
- } else {
302
- console.log(`\x1b[35m[BANDWIDTH SIMULATION]\x1b[0m Disabled`);
303
- }
304
- }
305
-
306
- /**
307
- * Gets current bandwidth simulation status
308
- *
309
- * @returns {Object} Current configuration
310
- */
311
- static getBandwidthStatus(): { enabled: boolean; kbps: number; filter: string } {
312
- return {
313
- enabled: PartyConnection.bandwidthEnabled,
314
- kbps: PartyConnection.bandwidthKbps,
315
- filter: PartyConnection.bandwidthFilter
316
- };
317
- }
318
-
319
- /**
320
- * Configures latency simulation settings
321
- *
322
- * Latency simulates the ping time to a distant server. Each message gets the same fixed delay,
323
- * regardless of when it was sent. This means if you send 3 messages rapidly, they will all
324
- * have the same latency delay applied to them.
325
- *
326
- * @param {boolean} enabled - Whether to enable latency simulation
327
- * @param {number} ms - Fixed latency in milliseconds (simulates ping to distant server)
328
- * @param {string} filter - Optional filter string to only simulate latency for messages containing this string
329
- *
330
- * @example
331
- * ```typescript
332
- * PartyConnection.configureLatency(true, 100); // 100ms latency (distant server)
333
- * PartyConnection.configureLatency(true, 200, 'sync'); // 200ms latency only for sync messages
334
- * ```
335
- */
336
- static configureLatency(enabled: boolean, ms: number, filter?: string): void {
337
- PartyConnection.latencyEnabled = enabled;
338
- PartyConnection.latencyMs = Math.max(0, ms);
339
- PartyConnection.latencyFilter = filter || '';
340
-
341
- if (enabled && ms > 0) {
342
- const filterInfo = filter ? ` (filtered: "${filter}")` : '';
343
- console.log(`\x1b[35m[LATENCY SIMULATION]\x1b[0m Enabled with ${ms}ms fixed latency${filterInfo}`);
344
- } else if (enabled) {
345
- console.log(`\x1b[35m[LATENCY SIMULATION]\x1b[0m Enabled but latency is 0ms (no delay will be applied)`);
346
- } else {
347
- console.log(`\x1b[35m[LATENCY SIMULATION]\x1b[0m Disabled`);
348
- }
349
- }
350
-
351
- /**
352
- * Gets current latency simulation status
353
- *
354
- * @returns {Object} Current configuration
355
- */
356
- static getLatencyStatus(): { enabled: boolean; ms: number; filter: string } {
357
- return {
358
- enabled: PartyConnection.latencyEnabled,
359
- ms: PartyConnection.latencyMs,
360
- filter: PartyConnection.latencyFilter
361
- };
362
- }
363
- }
364
-
365
- /**
366
- * Room class compatible with PartyKit's Party.Room interface
367
- *
368
- * This class manages multiple WebSocket connections and provides broadcasting
369
- * capabilities, storage, and connection management as expected by RPG-JS server.
370
- *
371
- * @example
372
- * ```typescript
373
- * const room = new Room('lobby-1');
374
- * room.broadcast('Game started!');
375
- * const playerCount = [...room.getConnections()].length;
376
- * ```
377
- */
378
- class Room {
379
- public id: string;
380
- public internalID: string;
381
- public env: Record<string, any> = {};
382
- public context: any = {};
383
- private connections: Map<string, PartyConnection> = new Map();
384
- private storageData: Map<string, any> = new Map();
385
-
386
- constructor(id: string) {
387
- this.id = id;
388
- this.internalID = `internal_${id}_${Date.now()}`;
389
- }
390
-
391
- /**
392
- * Broadcasts a message to all connected clients with bandwidth simulation
393
- *
394
- * Messages are sent to each connection in parallel, but each connection maintains
395
- * its own ordered queue of messages with bandwidth limitations. This ensures that
396
- * broadcast messages are queued in the correct order for each individual connection,
397
- * while being slowed down by simulated bandwidth constraints.
398
- *
399
- * @param {any} message - Message to broadcast
400
- * @param {string[]} except - Array of connection IDs to exclude from broadcast
401
- */
402
- async broadcast(message: any, except: string[] = []): Promise<void> {
403
- const data =
404
- typeof message === "string" ? message : JSON.stringify(message);
405
-
406
- const sendPromises: Promise<void>[] = [];
407
-
408
- for (const [connectionId, connection] of this.connections) {
409
- if (!except.includes(connectionId)) {
410
- // Each connection will handle its own queue ordering
411
- sendPromises.push(connection.send(data));
412
- }
413
- }
414
-
415
- // Wait for all messages to be queued (actual sending happens asynchronously in each connection's queue)
416
- await Promise.all(sendPromises);
417
- }
418
-
419
- /**
420
- * Gets a connection by its ID
421
- *
422
- * @param {string} id - Connection ID
423
- * @returns {PartyConnection | undefined} The connection or undefined if not found
424
- */
425
- getConnection(id: string): PartyConnection | undefined {
426
- return this.connections.get(id);
427
- }
428
-
429
- /**
430
- * Gets all currently connected clients
431
- *
432
- * @param {string} tag - Optional tag to filter connections (not implemented yet)
433
- * @returns {IterableIterator<PartyConnection>} Iterator of all connections
434
- */
435
- getConnections(tag?: string): IterableIterator<PartyConnection> {
436
- // TODO: Implement tag filtering if needed
437
- return this.connections.values();
438
- }
439
-
440
- /**
441
- * Adds a connection to this room
442
- *
443
- * @param {PartyConnection} connection - Connection to add
444
- */
445
- addConnection(connection: PartyConnection): void {
446
- this.connections.set(connection.id, connection);
447
- }
448
-
449
- /**
450
- * Removes a connection from this room
451
- *
452
- * @param {string} connectionId - ID of connection to remove
453
- */
454
- removeConnection(connectionId: string): void {
455
- this.connections.delete(connectionId);
456
- }
457
-
458
- /**
459
- * Simple key-value storage for the room
460
- */
461
- get storage() {
462
- return {
463
- put: async (key: string, value: any) => {
464
- this.storageData.set(key, value);
465
- },
466
- get: async <T = any>(key: string): Promise<T | undefined> => {
467
- return this.storageData.get(key) as T;
468
- },
469
- delete: async (key: string) => {
470
- this.storageData.delete(key);
471
- },
472
- list: async () => {
473
- // Return entries to match expected PartyKit/Server semantics
474
- // Consumers often iterate as: for (const [key, value] of await storage.list())
475
- return Array.from(this.storageData.entries());
476
- },
477
- };
478
- }
479
- }
480
-
481
- /**
482
- * Utility function to safely import WebSocketServer
483
- *
484
- * This function checks if we are in a Node.js environment
485
- * before trying to import the ws module, thus avoiding
486
- * browser compatibility errors.
487
- *
488
- * @returns {Promise<any>} The WebSocketServer class or null if not available
489
- */
490
5
  async function importWebSocketServer(): Promise<any> {
491
- // Check if we are in a Node.js environment
492
6
  if (typeof process === "undefined" || !process.versions?.node) {
493
7
  console.warn("Not in Node.js environment, WebSocket server not available");
494
8
  return null;
495
9
  }
496
10
 
497
11
  try {
498
- // Use createRequire to import ws in an ES module context
499
12
  const { createRequire } = await import("module");
500
13
  const require = createRequire(import.meta.url);
501
14
  const ws = require("ws");
@@ -506,168 +19,14 @@ async function importWebSocketServer(): Promise<any> {
506
19
  }
507
20
  }
508
21
 
509
- async function updateMap(roomId: string, rpgServer: RpgServerEngine) {
510
- if (!roomId.startsWith('map-')) {
511
- return;
512
- }
513
-
514
- try {
515
- const mapId = roomId.startsWith('map-') ? roomId.slice(4) : roomId;
516
- const defaultMapPayload = {
517
- id: mapId,
518
- width: 0,
519
- height: 0,
520
- events: [] as any[],
521
- };
522
-
523
- const req = {
524
- url: `http://localhost/parties/main/${roomId}/map/update`,
525
- method: 'POST',
526
- headers: new Headers({}),
527
- json: async () => defaultMapPayload,
528
- text: async () => JSON.stringify(defaultMapPayload)
529
- } as any;
530
-
531
- await (rpgServer as any).onRequest(req);
532
- console.log(`Initialized map for room ${roomId} via POST /map/update`);
533
- } catch (error) {
534
- console.warn(`Failed initializing map for room ${roomId}:`, error);
535
- }
536
- }
537
-
538
- /**
539
- * Creates a Vite plugin for integrating RPG-JS server functionality
540
- *
541
- * This plugin configures the development server to automatically start
542
- * an RPG-JS server instance when Vite's dev server starts. It handles
543
- * the instantiation and initialization of the server module, and sets up
544
- * HTTP request and WebSocket connection forwarding to the RPG-JS server.
545
- *
546
- * The plugin intercepts:
547
- * - HTTP requests to `/parties/*` paths and forwards them to the RPG-JS server
548
- * - WebSocket upgrade requests and establishes connections with the RPG-JS server
549
- *
550
- * @param {new () => RpgServerEngine} serverModule - A class constructor that extends RpgServerEngine
551
- * @returns {Object} Vite plugin configuration object
552
- *
553
- * @example
554
- * ```typescript
555
- * // In vite.config.ts
556
- * import { serverPlugin } from '@rpgjs/vite';
557
- * import startServer from './src/server';
558
- *
559
- * export default defineConfig({
560
- * plugins: [
561
- * serverPlugin(startServer)
562
- * ]
563
- * });
564
- * ```
565
- */
566
- export function serverPlugin(
567
- serverModule: new (room: Room) => RpgServerEngine
568
- ) {
569
- let wsServer: WSServer | null = null;
570
- let rooms: Map<string, Room> = new Map();
571
- let servers: Map<string, RpgServerEngine> = new Map();
572
-
573
- // Ensure a room and its server instance exist for a given roomId
574
- async function ensureRoomAndServer(roomId: string) {
575
- let room = rooms.get(roomId);
576
- if (!room) {
577
- room = new Room(roomId);
578
- rooms.set(roomId, room);
579
- console.log(`Created new room: ${roomId}`);
580
- }
581
- let rpgServer = servers.get(roomId);
582
- if (!rpgServer) {
583
- rpgServer = new serverModule(room);
584
- servers.set(roomId, rpgServer);
585
- console.log(`Created new server instance for room: ${roomId}`);
586
- if (typeof rpgServer.onStart === "function") {
587
- try {
588
- await rpgServer.onStart();
589
- console.log(`Server started for room: ${roomId}`);
590
- } catch (error) {
591
- console.error(`Error starting server for room ${roomId}:`, error);
592
- }
593
- }
594
-
595
- await updateMap(roomId, rpgServer);
596
- }
597
-
598
- // Make sure parties context is available on the room
599
- room.context.parties = buildPartiesContext();
600
- return { room, rpgServer };
601
- }
602
-
603
- // Build a parties context compatible with "room.context.parties"
604
- function buildPartiesContext() {
605
- return {
606
- main: {
607
- get: async (targetRoomId: string) => {
608
- const { rpgServer } = await ensureRoomAndServer(targetRoomId);
609
- return {
610
- fetch: async (path: string, init?: { method?: string; body?: any; headers?: Record<string, string> }) => {
611
- try {
612
- const url = `http://localhost/parties/main/${targetRoomId}${path}`;
613
- const method = (init?.method || 'GET').toUpperCase();
614
- const headers = new Headers(init?.headers || {});
615
- const bodyRaw = init?.body;
616
-
617
- const req = {
618
- url,
619
- method,
620
- headers,
621
- json: async () => {
622
- if (!bodyRaw) return undefined as any;
623
- return typeof bodyRaw === 'string' ? JSON.parse(bodyRaw) : bodyRaw;
624
- },
625
- text: async () => {
626
- if (typeof bodyRaw === 'string') return bodyRaw;
627
- if (typeof bodyRaw === 'undefined') return '';
628
- return JSON.stringify(bodyRaw);
629
- }
630
- } as any;
631
-
632
- const result = await (rpgServer as any).onRequest(req);
633
- const ok = !!result && (result.ok === true || typeof result !== 'undefined');
634
- const response = {
635
- ok,
636
- status: ok ? 200 : 404,
637
- async json() {
638
- if (result && typeof result.json === 'function') return result.json();
639
- return result ?? {};
640
- },
641
- async text() {
642
- if (typeof result === 'string') return result;
643
- try { return JSON.stringify(result ?? {}); } catch { return ''; }
644
- }
645
- } as any;
646
- return response;
647
- } catch (error) {
648
- return {
649
- ok: false,
650
- status: 500,
651
- async json() {
652
- return { error: (error as Error)?.message || 'Internal error' };
653
- },
654
- async text() {
655
- return (error as Error)?.message || 'Internal error';
656
- }
657
- } as any;
658
- }
659
- }
660
- };
661
- }
662
- }
663
- } as any;
664
- }
22
+ export function serverPlugin(serverModule: RpgTransportServerConstructor) {
23
+ let wsServer: RpgWebSocketServer | null = null;
24
+ const transport = createRpgServerTransport(serverModule);
665
25
 
666
26
  return {
667
27
  name: "server-plugin",
668
28
 
669
29
  async configureServer(server: ViteDevServer) {
670
- // Dynamic import of WebSocketServer to avoid compatibility issues
671
30
  try {
672
31
  const WebSocketServerClass = await importWebSocketServer();
673
32
  if (WebSocketServerClass) {
@@ -683,283 +42,22 @@ export function serverPlugin(
683
42
  wsServer = null;
684
43
  }
685
44
 
686
- console.log('RPG-JS server plugin initialized');
687
-
688
- // Display network simulation status
689
- const packetLossStatus = PartyConnection.getPacketLossStatus();
690
- const bandwidthStatus = PartyConnection.getBandwidthStatus();
691
- const latencyStatus = PartyConnection.getLatencyStatus();
692
-
693
- if (packetLossStatus.enabled) {
694
- const filterInfo = packetLossStatus.filter ? ` (filter: "${packetLossStatus.filter}")` : '';
695
- console.log(`\x1b[36m[NETWORK SIMULATION]\x1b[0m Packet loss simulation: ${(packetLossStatus.rate * 100).toFixed(1)}% loss rate${filterInfo}`);
696
- } else {
697
- console.log(`\x1b[36m[NETWORK SIMULATION]\x1b[0m Packet loss simulation: disabled`);
698
- }
699
-
700
- if (bandwidthStatus.enabled) {
701
- const filterInfo = bandwidthStatus.filter ? ` (filter: "${bandwidthStatus.filter}")` : '';
702
- console.log(`\x1b[36m[NETWORK SIMULATION]\x1b[0m Bandwidth simulation: ${bandwidthStatus.kbps} kbps${filterInfo}`);
703
- } else {
704
- console.log(`\x1b[36m[NETWORK SIMULATION]\x1b[0m Bandwidth simulation: disabled`);
705
- }
706
-
707
- if (latencyStatus.enabled) {
708
- const filterInfo = latencyStatus.filter ? ` (filter: "${latencyStatus.filter}")` : '';
709
- console.log(`\x1b[36m[NETWORK SIMULATION]\x1b[0m Latency simulation: ${latencyStatus.ms}ms ping${filterInfo}`);
710
- } else {
711
- console.log(`\x1b[36m[NETWORK SIMULATION]\x1b[0m Latency simulation: disabled`);
712
- }
45
+ console.log("RPG-JS server plugin initialized");
46
+ logNetworkSimulationStatus();
713
47
 
714
- // HTTP request interception for /parties/* routes
715
48
  server.middlewares.use("/parties", async (req, res, next) => {
716
- try {
717
- const host = req.headers.host || "localhost";
718
- const incomingUrl = req.url || "/";
719
- const parsedUrl = new URL(incomingUrl, `http://${host}`);
720
- const normalizedPath = parsedUrl.pathname.startsWith("/parties")
721
- ? parsedUrl.pathname
722
- : `/parties${parsedUrl.pathname.startsWith("/") ? parsedUrl.pathname : `/${parsedUrl.pathname}`}`;
723
- const pathParts = normalizedPath.split("/").filter(Boolean);
724
-
725
- if (pathParts[0] !== "parties" || pathParts[1] !== "main" || pathParts.length < 4) {
726
- next();
727
- return;
728
- }
729
-
730
- const roomId = pathParts[2];
731
- const requestPath = `/${pathParts.slice(3).join("/")}`;
732
- const { room, rpgServer } = await ensureRoomAndServer(roomId);
733
- room.context.parties = buildPartiesContext();
734
-
735
- const bodyText = await new Promise<string>((resolve, reject) => {
736
- const chunks: Buffer[] = [];
737
- req.on("data", (chunk: Buffer | string) => {
738
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
739
- });
740
- req.on("end", () => {
741
- resolve(Buffer.concat(chunks).toString("utf8"));
742
- });
743
- req.on("error", reject);
744
- });
745
-
746
- const requestHeaders = new Headers();
747
- Object.entries(req.headers).forEach(([key, value]) => {
748
- if (Array.isArray(value)) {
749
- if (value[0] !== undefined) requestHeaders.set(key, value[0]);
750
- return;
751
- }
752
- if (typeof value === "string") {
753
- requestHeaders.set(key, value);
754
- }
755
- });
756
-
757
- const requestLike = {
758
- url: `http://${host}/parties/main/${roomId}${requestPath}${parsedUrl.search}`,
759
- method: (req.method || "GET").toUpperCase(),
760
- headers: requestHeaders,
761
- json: async () => {
762
- if (!bodyText) return undefined as any;
763
- return JSON.parse(bodyText);
764
- },
765
- text: async () => bodyText,
766
- } as any;
767
-
768
- const result = await (rpgServer as any).onRequest(requestLike);
769
-
770
- if (result instanceof Response) {
771
- res.statusCode = result.status;
772
- result.headers.forEach((value, key) => {
773
- res.setHeader(key, value);
774
- });
775
- res.end(await result.text());
776
- return;
777
- }
778
-
779
- if (typeof result === "string") {
780
- res.statusCode = 200;
781
- res.setHeader("Content-Type", "text/plain");
782
- res.end(result);
783
- return;
784
- }
785
-
786
- res.statusCode = 200;
787
- res.setHeader("Content-Type", "application/json");
788
- res.end(JSON.stringify(result ?? {}));
789
- } catch (error) {
790
- console.error("Error handling RPG-JS request:", error);
791
- res.statusCode = 500;
792
- res.setHeader("Content-Type", "application/json");
793
- res.end(JSON.stringify({ error: "Internal server error" }));
794
- }
49
+ await transport.handleNodeRequest(req, res, next, {
50
+ mountedPath: "/parties",
51
+ });
795
52
  });
796
- // WebSocket upgrade handling (if available)
797
- if (wsServer) {
798
- server.httpServer?.on(
799
- "upgrade",
800
- (request: IncomingMessage, socket: Duplex, head: Buffer) => {
801
- const url = new URL(request.url!, `http://${request.headers.host}`);
802
-
803
- // Check if it's a WebSocket connection for RPG-JS
804
- if (url.pathname.startsWith("/parties/")) {
805
- console.log(`WebSocket upgrade request: ${url.pathname}`);
806
-
807
- wsServer!.handleUpgrade(
808
- request,
809
- socket,
810
- head,
811
- async (ws: WSConnection) => {
812
- try {
813
- // Extract room name from URL: /parties/main/lobby-1 -> lobby-1
814
- const pathParts = url.pathname.split("/");
815
- const roomName = pathParts[pathParts.length - 1]; // Get the last part (lobby-1)
816
-
817
- // Extract query parameters (like _pk)
818
- const queryParams = Object.fromEntries(
819
- url.searchParams.entries()
820
- );
821
- console.log(
822
- `Room: ${roomName}, Query params:`,
823
- queryParams
824
- );
825
-
826
- // Get or create the room and its server
827
- const ensured = await ensureRoomAndServer(roomName);
828
- const room = ensured.room;
829
- const rpgServer = ensured.rpgServer;
830
- // Inject a compatible parties context for cross-room calls
831
- room.context.parties = buildPartiesContext();
832
-
833
- // Create a connection instance
834
- const connection = new PartyConnection(
835
- ws,
836
- queryParams._pk,
837
- request.url
838
- );
839
-
840
- // Add connection to the room
841
- room.addConnection(connection);
842
-
843
- console.log(
844
- `WebSocket connection established: ${connection.id} in room: ${roomName}`
845
- );
846
-
847
- // Set up WebSocket event handlers
848
- ws.on("message", async (data: Buffer) => {
849
- try {
850
- const rawMessage = data.toString();
851
53
 
852
- // Packet loss simulation (pre-buffer)
853
- if (PartyConnection.packetLossEnabled && PartyConnection.packetLossRate > 0) {
854
- if (!PartyConnection.packetLossFilter || rawMessage.includes(PartyConnection.packetLossFilter)) {
855
- const random = Math.random();
856
- if (random < PartyConnection.packetLossRate) {
857
- console.log(`\x1b[31m[PACKET LOSS]\x1b[0m Connection ${connection.id}: Server dropped an incoming packet (${(PartyConnection.packetLossRate * 100).toFixed(1)}% loss rate)`);
858
- console.log(`\x1b[33m[PACKET DATA]\x1b[0m ${rawMessage.substring(0, 100)}${rawMessage.length > 100 ? '...' : ''}`);
859
- return;
860
- }
861
- }
862
- }
863
-
864
- // Buffer incoming messages to simulate TCP latency on reception
865
- connection.bufferIncoming(rawMessage, async (batch: string[]) => {
866
- // Process in order
867
- for (const msg of batch) {
868
- if (typeof rpgServer.onMessage === "function") {
869
- await rpgServer.onMessage(msg, connection as any);
870
- }
871
- }
872
- });
873
- } catch (error) {
874
- console.error(
875
- "Error processing WebSocket message:",
876
- error
877
- );
878
- }
879
- });
880
-
881
- ws.on("close", async () => {
882
- console.log(
883
- `WebSocket connection closed: ${connection.id} from room: ${roomName}`
884
- );
885
- // Remove connection from room
886
- room.removeConnection(connection.id);
887
- // Call onClose on the RPG-JS server
888
- if (typeof rpgServer.onClose === "function") {
889
- await rpgServer.onClose(connection as any);
890
- }
891
- });
892
-
893
- ws.on("error", async (error: Error) => {
894
- console.error("WebSocket error:", error);
895
- // Remove connection from room
896
- room.removeConnection(connection.id);
897
- // Call onClose on the RPG-JS server
898
- if (typeof rpgServer.onClose === "function") {
899
- await rpgServer.onClose(connection as any);
900
- }
901
- });
902
-
903
- // Call onConnect on the RPG-JS server if the method exists
904
- if (typeof rpgServer.onConnect === "function") {
905
- // Create a compatible connection context with Headers-like interface
906
- const headers = new Map();
907
- if (request.headers) {
908
- Object.entries(request.headers).forEach(
909
- ([key, value]) => {
910
- headers.set(
911
- key.toLowerCase(),
912
- Array.isArray(value) ? value[0] : value
913
- );
914
- }
915
- );
916
- }
917
-
918
- const connectionContext = {
919
- request: {
920
- headers: {
921
- has: (name: string) =>
922
- headers.has(name.toLowerCase()),
923
- get: (name: string) =>
924
- headers.get(name.toLowerCase()),
925
- entries: () => headers.entries(),
926
- keys: () => headers.keys(),
927
- values: () => headers.values(),
928
- },
929
- url: url.toString(),
930
- method: request.method,
931
- },
932
- url: url,
933
- };
934
- await rpgServer.onConnect(
935
- connection as any,
936
- connectionContext as any
937
- );
938
- }
939
-
940
- // Send connection confirmation
941
- connection.send({
942
- type: "connected",
943
- id: connection.id,
944
- message: "Connected to RPG-JS server",
945
- });
946
- } catch (error) {
947
- console.error(
948
- "Error establishing WebSocket connection:",
949
- error
950
- );
951
- ws.close();
952
- }
953
- }
954
- );
955
- }
956
- }
957
- );
54
+ if (wsServer) {
55
+ server.httpServer?.on("upgrade", (request, socket, head) => {
56
+ void transport.handleUpgrade(wsServer!, request, socket, head);
57
+ });
958
58
  }
959
59
 
960
- console.log(
961
- "RPG-JS server plugin configured with HTTP and WebSocket forwarding"
962
- );
60
+ console.log("RPG-JS server plugin configured with HTTP and WebSocket forwarding !");
963
61
  },
964
62
 
965
63
  buildStart() {
@@ -967,7 +65,6 @@ export function serverPlugin(
967
65
  },
968
66
 
969
67
  buildEnd() {
970
- // Cleanup when server stops
971
68
  if (wsServer) {
972
69
  wsServer.close();
973
70
  }