@onebun/core 0.1.0 → 0.1.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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * WebSocket Client
3
+ *
4
+ * Typed WebSocket client for connecting to OneBun WebSocket gateways.
5
+ * Supports both native WebSocket and Socket.IO protocols.
6
+ */
7
+
8
+ /* eslint-disable no-magic-numbers */
9
+
10
+ import type {
11
+ WsClientOptions,
12
+ WsClient,
13
+ WsGatewayClient,
14
+ WsEventListener,
15
+ WsClientEvent,
16
+ WsClientEventListeners,
17
+ PendingRequest,
18
+ TypedWsClient,
19
+ } from './ws-client.types';
20
+ import type { WsServiceDefinition } from './ws-service-definition';
21
+
22
+ import { WsConnectionState } from './ws-client.types';
23
+ import { matchPattern, isPattern } from './ws-pattern-matcher';
24
+ import {
25
+ parseMessage,
26
+ createPongPacket,
27
+ EngineIOPacketType,
28
+ SocketIOPacketType,
29
+ isNativeMessage,
30
+ parseNativeMessage,
31
+ createNativeMessage,
32
+ } from './ws-socketio-protocol';
33
+
34
+ /**
35
+ * Default client options
36
+ */
37
+ const DEFAULT_OPTIONS: Partial<WsClientOptions> = {
38
+ reconnect: true,
39
+ reconnectInterval: 1000,
40
+ maxReconnectAttempts: 10,
41
+ timeout: 5000,
42
+ transports: ['websocket'],
43
+ };
44
+
45
+ /**
46
+ * WebSocket client implementation
47
+ */
48
+ class WsClientImpl<TDef extends WsServiceDefinition> implements WsClient<TDef> {
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ private ws: any = null;
51
+ private state: WsConnectionState = WsConnectionState.DISCONNECTED;
52
+ private options: WsClientOptions;
53
+ private reconnectAttempts = 0;
54
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
55
+ private pendingRequests = new Map<number, PendingRequest>();
56
+ private ackId = 0;
57
+ private eventListeners = new Map<string, Set<WsEventListener>>();
58
+ private clientEventListeners = new Map<WsClientEvent, Set<Function>>();
59
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
60
+ private sid: string | null = null;
61
+
62
+ // Gateway clients cache
63
+ private gatewayClients = new Map<string, WsGatewayClient>();
64
+
65
+ constructor(
66
+ private definition: TDef,
67
+ options: WsClientOptions,
68
+ ) {
69
+ this.options = { ...DEFAULT_OPTIONS, ...options };
70
+ }
71
+
72
+ /**
73
+ * Connect to WebSocket server
74
+ */
75
+ async connect(): Promise<void> {
76
+ if (this.state === WsConnectionState.CONNECTED) {
77
+ return;
78
+ }
79
+
80
+ return await new Promise((resolve, reject) => {
81
+ this.state = WsConnectionState.CONNECTING;
82
+
83
+ try {
84
+ // Build URL with auth
85
+ let url = this.options.url;
86
+ const params = new URLSearchParams();
87
+
88
+ // Add Socket.IO parameters
89
+ params.set('EIO', '4');
90
+ params.set('transport', 'websocket');
91
+
92
+ // Add auth token
93
+ if (this.options.auth?.token) {
94
+ params.set('token', this.options.auth.token);
95
+ }
96
+
97
+ // Add namespace
98
+ if (this.options.namespace) {
99
+ params.set('namespace', this.options.namespace);
100
+ }
101
+
102
+ // Append params to URL
103
+ const separator = url.includes('?') ? '&' : '?';
104
+ url = `${url}${separator}${params.toString()}`;
105
+
106
+ // Create WebSocket connection
107
+ // Use globalThis.WebSocket for browser compatibility
108
+ const WS = typeof globalThis !== 'undefined' && globalThis.WebSocket
109
+ ? globalThis.WebSocket
110
+ : WebSocket;
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ this.ws = new WS(url) as any;
113
+
114
+ if (this.ws) {
115
+ this.ws.onopen = () => {
116
+ this.state = WsConnectionState.CONNECTED;
117
+ this.reconnectAttempts = 0;
118
+ this.emitClientEvent('connect');
119
+ resolve();
120
+ };
121
+
122
+ this.ws.onmessage = (event: MessageEvent) => {
123
+ this.handleMessage(event.data);
124
+ };
125
+
126
+ this.ws.onclose = (event: CloseEvent) => {
127
+ this.handleClose(event.code, event.reason);
128
+ };
129
+
130
+ this.ws.onerror = () => {
131
+ const err = new Error('WebSocket error');
132
+ this.emitClientEvent('error', err);
133
+
134
+ if (this.state === WsConnectionState.CONNECTING) {
135
+ reject(err);
136
+ }
137
+ };
138
+ }
139
+ } catch (error) {
140
+ this.state = WsConnectionState.DISCONNECTED;
141
+ reject(error);
142
+ }
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Disconnect from WebSocket server
148
+ */
149
+ disconnect(): void {
150
+ this.stopPing();
151
+ this.clearReconnectTimer();
152
+
153
+ if (this.ws) {
154
+ this.ws.close(1000, 'Client disconnect');
155
+ this.ws = null;
156
+ }
157
+
158
+ this.state = WsConnectionState.DISCONNECTED;
159
+ this.pendingRequests.clear();
160
+ this.sid = null;
161
+ }
162
+
163
+ /**
164
+ * Check if connected
165
+ */
166
+ isConnected(): boolean {
167
+ return this.state === WsConnectionState.CONNECTED;
168
+ }
169
+
170
+ /**
171
+ * Get current connection state
172
+ */
173
+ getState(): WsConnectionState {
174
+ return this.state;
175
+ }
176
+
177
+ /**
178
+ * Subscribe to client events
179
+ */
180
+ on<E extends WsClientEvent>(event: E, listener: WsClientEventListeners[E]): void {
181
+ let listeners = this.clientEventListeners.get(event);
182
+ if (!listeners) {
183
+ listeners = new Set();
184
+ this.clientEventListeners.set(event, listeners);
185
+ }
186
+ listeners.add(listener);
187
+ }
188
+
189
+ /**
190
+ * Unsubscribe from client events
191
+ */
192
+ off<E extends WsClientEvent>(event: E, listener?: WsClientEventListeners[E]): void {
193
+ if (listener) {
194
+ const listeners = this.clientEventListeners.get(event);
195
+ if (listeners) {
196
+ listeners.delete(listener);
197
+ }
198
+ } else {
199
+ this.clientEventListeners.delete(event);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Handle incoming message
205
+ */
206
+ private handleMessage(data: string): void {
207
+ // Try native format first
208
+ if (isNativeMessage(data)) {
209
+ const native = parseNativeMessage(data);
210
+ if (native) {
211
+ this.handleEvent(native.event, native.data, native.ack);
212
+
213
+ return;
214
+ }
215
+ }
216
+
217
+ // Parse Socket.IO format
218
+ const { engineIO, socketIO } = parseMessage(data);
219
+
220
+ // Handle Engine.IO packets
221
+ switch (engineIO.type) {
222
+ case EngineIOPacketType.OPEN:
223
+ // Handle handshake
224
+ if (engineIO.data) {
225
+ try {
226
+ const handshake = JSON.parse(engineIO.data as string);
227
+ this.sid = handshake.sid;
228
+ this.startPing(handshake.pingInterval || 25000);
229
+ } catch {
230
+ // Ignore parse errors
231
+ }
232
+ }
233
+
234
+ return;
235
+
236
+ case EngineIOPacketType.PING:
237
+ this.ws?.send(createPongPacket(engineIO.data as string | undefined));
238
+
239
+ return;
240
+
241
+ case EngineIOPacketType.PONG:
242
+ // Server responded to our ping
243
+ return;
244
+
245
+ case EngineIOPacketType.CLOSE:
246
+ this.handleClose(1000, 'Server closed');
247
+
248
+ return;
249
+
250
+ case EngineIOPacketType.MESSAGE:
251
+ // Socket.IO packet
252
+ if (socketIO) {
253
+ this.handleSocketIOPacket(socketIO);
254
+ }
255
+
256
+ return;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Handle Socket.IO packet
262
+ */
263
+ private handleSocketIOPacket(packet: {
264
+ type: number;
265
+ nsp: string;
266
+ data?: unknown[];
267
+ id?: number;
268
+ }): void {
269
+ switch (packet.type) {
270
+ case SocketIOPacketType.CONNECT:
271
+ // Connected to namespace
272
+ break;
273
+
274
+ case SocketIOPacketType.DISCONNECT:
275
+ // Disconnected from namespace
276
+ break;
277
+
278
+ case SocketIOPacketType.EVENT:
279
+ if (packet.data && Array.isArray(packet.data) && packet.data.length > 0) {
280
+ const [event, ...args] = packet.data;
281
+ if (typeof event === 'string') {
282
+ this.handleEvent(event, args[0], packet.id);
283
+ }
284
+ }
285
+ break;
286
+
287
+ case SocketIOPacketType.ACK:
288
+ // Handle acknowledgement
289
+ if (packet.id !== undefined && packet.data) {
290
+ const pending = this.pendingRequests.get(packet.id);
291
+ if (pending) {
292
+ clearTimeout(pending.timeout);
293
+ pending.resolve(packet.data[0]);
294
+ this.pendingRequests.delete(packet.id);
295
+ }
296
+ }
297
+ break;
298
+
299
+ case SocketIOPacketType.CONNECT_ERROR:
300
+ this.emitClientEvent('error', new Error('Connection error'));
301
+ break;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Handle event
307
+ */
308
+ private handleEvent(event: string, data: unknown, ackId?: number): void {
309
+ // Check for acknowledgement (response to our request)
310
+ if (ackId !== undefined) {
311
+ const pending = this.pendingRequests.get(ackId);
312
+ if (pending) {
313
+ clearTimeout(pending.timeout);
314
+ pending.resolve(data);
315
+ this.pendingRequests.delete(ackId);
316
+
317
+ return;
318
+ }
319
+ }
320
+
321
+ // Notify listeners
322
+ this.notifyListeners(event, data);
323
+ }
324
+
325
+ /**
326
+ * Notify event listeners
327
+ */
328
+ private notifyListeners(event: string, data: unknown): void {
329
+ // Exact match
330
+ const exactListeners = this.eventListeners.get(event);
331
+ if (exactListeners) {
332
+ for (const listener of exactListeners) {
333
+ try {
334
+ listener(data);
335
+ } catch {
336
+ // Ignore listener errors
337
+ }
338
+ }
339
+ }
340
+
341
+ // Pattern match
342
+ for (const [pattern, listeners] of this.eventListeners) {
343
+ if (isPattern(pattern)) {
344
+ const match = matchPattern(pattern, event);
345
+ if (match.matched) {
346
+ for (const listener of listeners) {
347
+ try {
348
+ listener(data, match.params);
349
+ } catch {
350
+ // Ignore listener errors
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Handle connection close
360
+ */
361
+ private handleClose(code: number, reason: string): void {
362
+ this.stopPing();
363
+ this.ws = null;
364
+
365
+ const wasConnected = this.state === WsConnectionState.CONNECTED;
366
+ this.state = WsConnectionState.DISCONNECTED;
367
+
368
+ // Reject all pending requests
369
+ for (const [_, pending] of this.pendingRequests) {
370
+ clearTimeout(pending.timeout);
371
+ pending.reject(new Error('Connection closed'));
372
+ }
373
+ this.pendingRequests.clear();
374
+
375
+ this.emitClientEvent('disconnect', reason);
376
+
377
+ // Try to reconnect if enabled
378
+ if (wasConnected && this.options.reconnect) {
379
+ this.scheduleReconnect();
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Schedule reconnection
385
+ */
386
+ private scheduleReconnect(): void {
387
+ if (
388
+ this.reconnectAttempts >= (this.options.maxReconnectAttempts || 10)
389
+ ) {
390
+ this.emitClientEvent('reconnect_failed');
391
+
392
+ return;
393
+ }
394
+
395
+ this.state = WsConnectionState.RECONNECTING;
396
+ this.reconnectAttempts++;
397
+
398
+ this.emitClientEvent('reconnect_attempt', this.reconnectAttempts);
399
+
400
+ const delay =
401
+ (this.options.reconnectInterval || 1000) *
402
+ Math.min(this.reconnectAttempts, 5);
403
+
404
+ this.reconnectTimer = setTimeout(async () => {
405
+ try {
406
+ await this.connect();
407
+ this.emitClientEvent('reconnect', this.reconnectAttempts);
408
+ } catch {
409
+ this.scheduleReconnect();
410
+ }
411
+ }, delay);
412
+ }
413
+
414
+ /**
415
+ * Clear reconnect timer
416
+ */
417
+ private clearReconnectTimer(): void {
418
+ if (this.reconnectTimer) {
419
+ clearTimeout(this.reconnectTimer);
420
+ this.reconnectTimer = null;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Start ping interval
426
+ */
427
+ private startPing(interval: number): void {
428
+ this.stopPing();
429
+ this.pingTimer = setInterval(() => {
430
+ if (this.ws && this.state === WsConnectionState.CONNECTED) {
431
+ this.ws.send(String(EngineIOPacketType.PING));
432
+ }
433
+ }, interval);
434
+ }
435
+
436
+ /**
437
+ * Stop ping interval
438
+ */
439
+ private stopPing(): void {
440
+ if (this.pingTimer) {
441
+ clearInterval(this.pingTimer);
442
+ this.pingTimer = null;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Emit client event
448
+ */
449
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
450
+ private emitClientEvent(event: WsClientEvent, ...args: any[]): void {
451
+ const listeners = this.clientEventListeners.get(event);
452
+ if (listeners) {
453
+ for (const listener of listeners) {
454
+ try {
455
+ listener(...args);
456
+ } catch {
457
+ // Ignore listener errors
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Send message to server
465
+ */
466
+ private send(event: string, data: unknown, ackId?: number): void {
467
+ if (!this.ws || this.state !== WsConnectionState.CONNECTED) {
468
+ throw new Error('Not connected');
469
+ }
470
+
471
+ // Use native format (simpler)
472
+ const message = createNativeMessage(event, data, ackId);
473
+ this.ws.send(message);
474
+ }
475
+
476
+ /**
477
+ * Send message and wait for acknowledgement
478
+ */
479
+ private async emit<T = unknown>(event: string, data: unknown): Promise<T> {
480
+ return await new Promise((resolve, reject) => {
481
+ const id = ++this.ackId;
482
+
483
+ const timeout = setTimeout(() => {
484
+ this.pendingRequests.delete(id);
485
+ reject(new Error('Request timeout'));
486
+ }, this.options.timeout || 5000);
487
+
488
+ this.pendingRequests.set(id, {
489
+ resolve: resolve as (value: unknown) => void,
490
+ reject,
491
+ timeout,
492
+ });
493
+
494
+ try {
495
+ this.send(event, data, id);
496
+ } catch (error) {
497
+ clearTimeout(timeout);
498
+ this.pendingRequests.delete(id);
499
+ reject(error);
500
+ }
501
+ });
502
+ }
503
+
504
+ /**
505
+ * Subscribe to events
506
+ */
507
+ private addEventListener<T = unknown>(event: string, listener: WsEventListener<T>): void {
508
+ let listeners = this.eventListeners.get(event);
509
+ if (!listeners) {
510
+ listeners = new Set();
511
+ this.eventListeners.set(event, listeners);
512
+ }
513
+ listeners.add(listener as WsEventListener);
514
+ }
515
+
516
+ /**
517
+ * Unsubscribe from events
518
+ */
519
+ private removeEventListener(event: string, listener?: WsEventListener): void {
520
+ if (listener) {
521
+ const listeners = this.eventListeners.get(event);
522
+ if (listeners) {
523
+ listeners.delete(listener);
524
+ }
525
+ } else {
526
+ this.eventListeners.delete(event);
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Create gateway client proxy
532
+ */
533
+ private createGatewayClient(gatewayName: string): WsGatewayClient {
534
+ const cached = this.gatewayClients.get(gatewayName);
535
+ if (cached) {
536
+ return cached;
537
+ }
538
+
539
+ const client: WsGatewayClient = {
540
+ emit: <T = unknown>(event: string, data?: unknown) => this.emit<T>(event, data),
541
+ on: <T = unknown>(event: string, listener: WsEventListener<T>) =>
542
+ this.addEventListener(event, listener),
543
+ off: (event: string, listener?: WsEventListener) =>
544
+ this.removeEventListener(event, listener),
545
+ send: (event: string, data?: unknown) => this.send(event, data),
546
+ };
547
+
548
+ this.gatewayClients.set(gatewayName, client);
549
+
550
+ return client;
551
+ }
552
+
553
+ /**
554
+ * Get gateway client by name (for proxy)
555
+ */
556
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
557
+ [key: string]: any;
558
+ }
559
+
560
+ /**
561
+ * Create a typed WebSocket client for a service
562
+ *
563
+ * @param definition - Service definition created by createWsServiceDefinition()
564
+ * @param options - Client options
565
+ * @returns Typed WebSocket client
566
+ *
567
+ * @example
568
+ * ```typescript
569
+ * import { createWsClient, createWsServiceDefinition } from '@onebun/core';
570
+ * import { ChatModule } from './chat.module';
571
+ *
572
+ * const definition = createWsServiceDefinition(ChatModule);
573
+ * const client = createWsClient(definition, {
574
+ * url: 'ws://localhost:3000',
575
+ * auth: { token: 'xxx' },
576
+ * reconnect: true,
577
+ * });
578
+ *
579
+ * // Connect
580
+ * await client.connect();
581
+ *
582
+ * // Use typed gateway methods
583
+ * await client.ChatGateway.emit('chat:message', { text: 'Hello' });
584
+ *
585
+ * // Subscribe to events
586
+ * client.ChatGateway.on('chat:message', (message) => {
587
+ * console.log('Received:', message);
588
+ * });
589
+ *
590
+ * // Disconnect
591
+ * client.disconnect();
592
+ * ```
593
+ */
594
+ export function createWsClient<TDef extends WsServiceDefinition>(
595
+ definition: TDef,
596
+ options: WsClientOptions,
597
+ ): TypedWsClient<TDef> {
598
+ const client = new WsClientImpl(definition, options);
599
+
600
+ // Create proxy for gateway access
601
+ return new Proxy(client as unknown as TypedWsClient<TDef>, {
602
+ get(target, prop) {
603
+ // Check if it's a known method
604
+ if (typeof prop === 'string') {
605
+ const knownMethods = [
606
+ 'connect',
607
+ 'disconnect',
608
+ 'isConnected',
609
+ 'getState',
610
+ 'on',
611
+ 'off',
612
+ ];
613
+
614
+ if (knownMethods.includes(prop)) {
615
+ return (target as unknown as Record<string, unknown>)[prop];
616
+ }
617
+
618
+ // Check if it's a gateway
619
+ if (definition._gateways.has(prop)) {
620
+ return (target as unknown as WsClientImpl<TDef>)['createGatewayClient'](prop);
621
+ }
622
+ }
623
+
624
+ // Default property access
625
+ return (target as unknown as Record<string | symbol, unknown>)[prop];
626
+ },
627
+ });
628
+ }