@onebun/core 0.1.1 → 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.
- package/README.md +233 -0
- package/package.json +1 -1
- package/src/application.test.ts +119 -0
- package/src/application.ts +112 -5
- package/src/docs-examples.test.ts +753 -0
- package/src/index.ts +96 -0
- package/src/module.ts +10 -4
- package/src/redis-client.ts +502 -0
- package/src/shared-redis.ts +231 -0
- package/src/types.ts +50 -0
- package/src/ws-base-gateway.test.ts +479 -0
- package/src/ws-base-gateway.ts +514 -0
- package/src/ws-client.test.ts +511 -0
- package/src/ws-client.ts +628 -0
- package/src/ws-client.types.ts +129 -0
- package/src/ws-decorators.test.ts +331 -0
- package/src/ws-decorators.ts +417 -0
- package/src/ws-guards.test.ts +334 -0
- package/src/ws-guards.ts +298 -0
- package/src/ws-handler.ts +658 -0
- package/src/ws-integration.test.ts +517 -0
- package/src/ws-pattern-matcher.test.ts +152 -0
- package/src/ws-pattern-matcher.ts +240 -0
- package/src/ws-service-definition.ts +223 -0
- package/src/ws-socketio-protocol.test.ts +344 -0
- package/src/ws-socketio-protocol.ts +567 -0
- package/src/ws-storage-memory.test.ts +246 -0
- package/src/ws-storage-memory.ts +222 -0
- package/src/ws-storage-redis.ts +302 -0
- package/src/ws-storage.ts +210 -0
- package/src/ws.types.ts +342 -0
package/src/ws-client.ts
ADDED
|
@@ -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
|
+
}
|