@api.global/typedsocket 5.1.2 → 6.0.0

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,1156 +0,0 @@
1
- import * as plugins from './typedsocket.plugins.js';
2
-
3
- export type TTypedSocketSide = 'server' | 'client';
4
- export type TConnectionStatus = 'new' | 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
5
-
6
- const TAG_PREFIX = '__typedsocket_tag__';
7
-
8
- /**
9
- * Internal TypedRequest interfaces for tag management
10
- */
11
- interface IReq_SetClientTag extends plugins.typedrequestInterfaces.ITypedRequest {
12
- method: '__typedsocket_setTag';
13
- request: { name: string; payload: any };
14
- response: { success: boolean };
15
- }
16
-
17
- interface IReq_RemoveClientTag extends plugins.typedrequestInterfaces.ITypedRequest {
18
- method: '__typedsocket_removeTag';
19
- request: { name: string };
20
- response: { success: boolean };
21
- }
22
-
23
- /**
24
- * Options for creating a TypedSocket client
25
- */
26
- export interface ITypedSocketClientOptions {
27
- autoReconnect?: boolean;
28
- maxRetries?: number;
29
- initialBackoffMs?: number;
30
- maxBackoffMs?: number;
31
- abortSignal?: AbortSignal;
32
- }
33
-
34
- /**
35
- * Lifecycle controls for one TypedRequest sent over a TypedSocket.
36
- */
37
- export interface ITypedSocketRequestOptions {
38
- timeoutMs?: number;
39
- abortSignal?: AbortSignal;
40
- }
41
-
42
- export type TTypedSocketServerRouterInput =
43
- | plugins.typedrequest.TypedRouter
44
- | readonly plugins.typedrequest.TypedRouter[];
45
-
46
- interface IPendingServerRequest {
47
- peerId: string;
48
- cancel: (errorArg: Error) => void;
49
- cleanupComplete: Promise<void>;
50
- }
51
-
52
- interface IComposedAbortSignal {
53
- signal?: AbortSignal;
54
- cleanup: () => void;
55
- }
56
-
57
- /**
58
- * Wrapper for SmartServe's IWebSocketPeer to provide tag compatibility
59
- */
60
- export interface ISmartServeConnectionWrapper {
61
- peer: plugins.IWebSocketPeer;
62
- getTagById(tagId: string): Promise<{ id: string; payload: any } | undefined>;
63
- }
64
-
65
- /**
66
- * Creates a wrapper around IWebSocketPeer for tag compatibility
67
- */
68
- function wrapSmartServePeer(peer: plugins.IWebSocketPeer): ISmartServeConnectionWrapper {
69
- return {
70
- peer,
71
- async getTagById(tagId: string): Promise<{ id: string; payload: any } | undefined> {
72
- if (!peer.tags.has(tagId)) {
73
- return undefined;
74
- }
75
- const payload = peer.data.get(`${TAG_PREFIX}${tagId}`);
76
- return { id: tagId, payload };
77
- },
78
- };
79
- }
80
-
81
- function composeAbortSignals(
82
- configuredSignalArg?: AbortSignal,
83
- requestSignalArg?: AbortSignal,
84
- ): IComposedAbortSignal {
85
- if (!configuredSignalArg || configuredSignalArg === requestSignalArg) {
86
- return {
87
- signal: requestSignalArg ?? configuredSignalArg,
88
- cleanup: () => {},
89
- };
90
- }
91
- if (!requestSignalArg) {
92
- return {
93
- signal: configuredSignalArg,
94
- cleanup: () => {},
95
- };
96
- }
97
-
98
- const abortController = new AbortController();
99
- const configuredAbortListener = () => {
100
- abortController.abort(configuredSignalArg.reason);
101
- };
102
- const requestAbortListener = () => {
103
- abortController.abort(requestSignalArg.reason);
104
- };
105
- const cleanup = () => {
106
- configuredSignalArg.removeEventListener('abort', configuredAbortListener);
107
- requestSignalArg.removeEventListener('abort', requestAbortListener);
108
- };
109
-
110
- if (configuredSignalArg.aborted) {
111
- configuredAbortListener();
112
- } else if (requestSignalArg.aborted) {
113
- requestAbortListener();
114
- } else {
115
- configuredSignalArg.addEventListener('abort', configuredAbortListener, { once: true });
116
- requestSignalArg.addEventListener('abort', requestAbortListener, { once: true });
117
- }
118
-
119
- return {
120
- signal: abortController.signal,
121
- cleanup,
122
- };
123
- }
124
-
125
- export class TypedSocket {
126
- // ============================================================================
127
- // STATIC METHODS
128
- // ============================================================================
129
-
130
- /**
131
- * Creates a TypedSocket client using native WebSocket.
132
- * Works in both browser and Node.js environments.
133
- *
134
- * @param typedrouterArg - TypedRouter for handling server-initiated requests
135
- * @param serverUrlArg - Server URL (e.g., 'http://localhost:3000' or 'wss://example.com')
136
- * @param options - Connection options
137
- *
138
- * @example
139
- * ```typescript
140
- * const typedRouter = new TypedRouter();
141
- * const client = await TypedSocket.createClient(
142
- * typedRouter,
143
- * 'http://localhost:3000',
144
- * { autoReconnect: true }
145
- * );
146
- * ```
147
- */
148
- public static async createClient(
149
- typedrouterArg: plugins.typedrequest.TypedRouter,
150
- serverUrlArg: string,
151
- options: ITypedSocketClientOptions = {}
152
- ): Promise<TypedSocket> {
153
- const defaultOptions: Omit<Required<ITypedSocketClientOptions>, 'abortSignal'> = {
154
- autoReconnect: true,
155
- maxRetries: 100,
156
- initialBackoffMs: 1000,
157
- maxBackoffMs: 60000,
158
- };
159
- const opts = { ...defaultOptions, ...options };
160
-
161
- const typedSocket = new TypedSocket('client', typedrouterArg);
162
- typedSocket.clientOptions = opts;
163
- typedSocket.serverUrl = serverUrlArg;
164
- typedSocket.currentBackoff = opts.initialBackoffMs;
165
-
166
- if (opts.abortSignal?.aborted) {
167
- throw typedSocket.createAbortError();
168
- }
169
- if (opts.abortSignal) {
170
- typedSocket.abortSignalListener = () => {
171
- void typedSocket.stop();
172
- };
173
- opts.abortSignal.addEventListener('abort', typedSocket.abortSignalListener, { once: true });
174
- }
175
-
176
- try {
177
- await typedSocket.connect();
178
- } catch (error) {
179
- await typedSocket.stop();
180
- throw error;
181
- }
182
-
183
- return typedSocket;
184
- }
185
-
186
- /**
187
- * Returns the current window location origin URL.
188
- * Useful in browser environments for connecting to the same origin.
189
- */
190
- public static useWindowLocationOriginUrl = (): string => {
191
- return plugins.smarturl.Smarturl.createFromUrl(globalThis.location.origin).toString();
192
- };
193
-
194
- /**
195
- * Creates a server-side TypedSocket and composes its private protocol
196
- * handlers into one or more isolated application routers.
197
- *
198
- * Call attachSmartServe() after constructing the SmartServe transport.
199
- */
200
- public static createServer(
201
- typedRouterOrRoutersArg: TTypedSocketServerRouterInput,
202
- ): TypedSocket {
203
- const typedRouters = Array.isArray(typedRouterOrRoutersArg)
204
- ? [...typedRouterOrRoutersArg]
205
- : [typedRouterOrRoutersArg];
206
- if (typedRouters.length === 0) {
207
- throw new Error('TypedSocket.createServer requires at least one TypedRouter.');
208
- }
209
- if (new Set(typedRouters).size !== typedRouters.length) {
210
- throw new Error('TypedSocket.createServer received the same TypedRouter more than once.');
211
- }
212
-
213
- const protocolRouter = new plugins.typedrequest.TypedRouter();
214
- TypedSocket.registerTagHandlers(protocolRouter);
215
- const protocolMethods = new Set(protocolRouter.getMethodNames());
216
- for (const typedRouter of typedRouters) {
217
- const collision = typedRouter
218
- .getMethodNames()
219
- .find((methodArg) => protocolMethods.has(methodArg));
220
- if (collision) {
221
- throw new Error(
222
- `TypedSocket protocol handler collides with application method "${collision}".`,
223
- );
224
- }
225
- }
226
- const detachProtocolRouters: Array<() => void> = [];
227
- try {
228
- for (const typedRouter of typedRouters) {
229
- detachProtocolRouters.push(typedRouter.addFallbackRouter(protocolRouter));
230
- }
231
- } catch (error) {
232
- for (const detachProtocolRouter of detachProtocolRouters.reverse()) {
233
- detachProtocolRouter();
234
- }
235
- throw error;
236
- }
237
-
238
- const typedSocket = new TypedSocket('server', typedRouters[0]);
239
- typedSocket.serverTypedRouters = typedRouters;
240
- typedSocket.detachProtocolRouters = detachProtocolRouters;
241
- return typedSocket;
242
- }
243
-
244
- /**
245
- * Creates and attaches a TypedSocket server to an existing SmartServe
246
- * instance. Prefer createServer() plus attachSmartServe() when transport
247
- * construction needs the composed routers first.
248
- *
249
- * @param smartServeArg - SmartServe instance configured with the application router
250
- * @param typedRouterArg - Application TypedRouter used by SmartServe
251
- *
252
- * @example
253
- * ```typescript
254
- * const typedRouter = new TypedRouter();
255
- * const smartServe = new SmartServe({
256
- * port: 3000,
257
- * websocket: {
258
- * typedRouter,
259
- * onConnectionOpen: (peer) => peer.tags.add('client')
260
- * }
261
- * });
262
- * await smartServe.start();
263
- * const typedSocket = TypedSocket.fromSmartServe(smartServe, typedRouter);
264
- * ```
265
- */
266
- public static fromSmartServe(
267
- smartServeArg: plugins.SmartServe,
268
- typedRouterArg: TTypedSocketServerRouterInput,
269
- ): TypedSocket {
270
- return TypedSocket
271
- .createServer(typedRouterArg)
272
- .attachSmartServe(smartServeArg);
273
- }
274
-
275
- /**
276
- * Registers built-in TypedHandlers for tag management
277
- */
278
- private static registerTagHandlers(typedRouter: plugins.typedrequest.TypedRouter): void {
279
- // Set tag handler
280
- typedRouter.addTypedHandler<IReq_SetClientTag>(
281
- new plugins.typedrequest.TypedHandler('__typedsocket_setTag', async (data, meta) => {
282
- const peer = meta?.localData?.peer as plugins.IWebSocketPeer;
283
- if (!peer) {
284
- console.warn('setTag: No peer found in request context');
285
- return { success: false };
286
- }
287
-
288
- peer.tags.add(data.name);
289
- peer.data.set(`${TAG_PREFIX}${data.name}`, data.payload);
290
-
291
- return { success: true };
292
- })
293
- );
294
-
295
- // Remove tag handler
296
- typedRouter.addTypedHandler<IReq_RemoveClientTag>(
297
- new plugins.typedrequest.TypedHandler('__typedsocket_removeTag', async (data, meta) => {
298
- const peer = meta?.localData?.peer as plugins.IWebSocketPeer;
299
- if (!peer) {
300
- console.warn('removeTag: No peer found in request context');
301
- return { success: false };
302
- }
303
-
304
- peer.tags.delete(data.name);
305
- peer.data.delete(`${TAG_PREFIX}${data.name}`);
306
-
307
- return { success: true };
308
- })
309
- );
310
- }
311
-
312
- // ============================================================================
313
- // INSTANCE PROPERTIES
314
- // ============================================================================
315
-
316
- public readonly side: TTypedSocketSide;
317
- public readonly typedrouter: plugins.typedrequest.TypedRouter;
318
-
319
- // Connection status observable
320
- public statusSubject = new plugins.smartrx.rxjs.Subject<TConnectionStatus>();
321
- private connectionStatus: TConnectionStatus = 'new';
322
-
323
- // Client-specific properties
324
- private websocket: WebSocket | null = null;
325
- private clientOptions: (Omit<Required<ITypedSocketClientOptions>, 'abortSignal'> & Pick<ITypedSocketClientOptions, 'abortSignal'>) | null = null;
326
- private serverUrl: string = '';
327
- private retryCount = 0;
328
- private currentBackoff = 1000;
329
- private stopped = false;
330
- private abortSignalListener?: () => void;
331
- private stopListeners = new Set<() => void>();
332
- private pendingRequests = new Map<string, {
333
- resolve: (response: any) => void;
334
- reject: (error: Error) => void;
335
- }>();
336
- // Tags set via setTag(), kept client-side so reconnects can restore them on
337
- // the fresh server-side connection (server tags die with the old socket).
338
- private clientTags = new Map<string, unknown>();
339
- private clientTagMutations = new Map<string, symbol>();
340
-
341
- // Server-specific properties (SmartServe mode)
342
- private smartServeRef: plugins.SmartServe | null = null;
343
- private serverTypedRouters: plugins.typedrequest.TypedRouter[] = [];
344
- private detachProtocolRouters: Array<() => void> = [];
345
- private serverStopping = false;
346
- private unsubscribeSmartServeConnectionClose: (() => void) | null = null;
347
- private pendingServerRequests = new Map<string, IPendingServerRequest>();
348
- private pendingServerRequestIdsByPeerId = new Map<string, Set<string>>();
349
-
350
- // ============================================================================
351
- // CONSTRUCTOR
352
- // ============================================================================
353
-
354
- private constructor(
355
- sideArg: TTypedSocketSide,
356
- typedrouterArg: plugins.typedrequest.TypedRouter
357
- ) {
358
- this.side = sideArg;
359
- this.typedrouter = typedrouterArg;
360
- }
361
-
362
- /**
363
- * Attaches the SmartServe transport after server router composition.
364
- */
365
- public attachSmartServe(smartServeArg: plugins.SmartServe): this {
366
- if (this.side !== 'server') {
367
- throw new Error('attachSmartServe is only available on servers.');
368
- }
369
- if (this.serverStopping) {
370
- throw new Error('Cannot attach SmartServe after the TypedSocket server has stopped.');
371
- }
372
- if (this.smartServeRef) {
373
- if (this.smartServeRef === smartServeArg) {
374
- return this;
375
- }
376
- throw new Error('TypedSocket server is already attached to a SmartServe instance.');
377
- }
378
-
379
- const unsubscribe = smartServeArg.subscribeWebSocketConnectionClose((peerArg) => {
380
- this.cancelPendingServerRequestsForPeer(
381
- peerArg.id,
382
- new Error(`TypedSocket target connection closed: ${peerArg.id}`),
383
- );
384
- });
385
- this.smartServeRef = smartServeArg;
386
- this.unsubscribeSmartServeConnectionClose = unsubscribe;
387
- return this;
388
- }
389
-
390
- // ============================================================================
391
- // CLIENT METHODS
392
- // ============================================================================
393
-
394
- /**
395
- * Connects the client to the server using native WebSocket
396
- */
397
- private async connect(): Promise<void> {
398
- if (this.stopped || this.clientOptions?.abortSignal?.aborted) {
399
- throw this.createAbortError();
400
- }
401
-
402
- const done = plugins.smartpromise.defer<void>();
403
- let connectionSettled = false;
404
- let connectionTimeout: ReturnType<typeof setTimeout> | undefined;
405
- let abortConnection: (() => void) | undefined;
406
-
407
- const settleConnection = (errorArg?: Error) => {
408
- if (connectionSettled) {
409
- return;
410
- }
411
- connectionSettled = true;
412
- if (connectionTimeout) {
413
- clearTimeout(connectionTimeout);
414
- }
415
- if (abortConnection) {
416
- this.stopListeners.delete(abortConnection);
417
- this.clientOptions?.abortSignal?.removeEventListener('abort', abortConnection);
418
- }
419
- if (errorArg) {
420
- done.reject(errorArg);
421
- } else {
422
- done.resolve();
423
- }
424
- };
425
-
426
- this.updateStatus('connecting');
427
-
428
- // Convert HTTP URL to WebSocket URL
429
- const wsUrl = this.toWebSocketUrl(this.serverUrl);
430
- this.logDebug(`TypedSocket connecting to ${wsUrl}...`);
431
-
432
- this.websocket = new WebSocket(wsUrl);
433
-
434
- abortConnection = () => {
435
- this.websocket?.close();
436
- settleConnection(this.createAbortError());
437
- };
438
- this.stopListeners.add(abortConnection);
439
- this.clientOptions?.abortSignal?.addEventListener('abort', abortConnection, { once: true });
440
-
441
- connectionTimeout = setTimeout(() => {
442
- if (this.connectionStatus !== 'connected') {
443
- this.logTransient(`TypedSocket connection timeout for ${wsUrl}`);
444
- this.websocket?.close();
445
- settleConnection(new Error('Connection timeout'));
446
- }
447
- }, 10000);
448
-
449
- this.websocket.onopen = async () => {
450
- const openedWebsocket = this.websocket;
451
- try {
452
- await this.reapplyClientTags();
453
- if (this.websocket !== openedWebsocket || openedWebsocket?.readyState !== WebSocket.OPEN) {
454
- throw new Error('TypedSocket connection changed while restoring client tags');
455
- }
456
- this.logDebug('TypedSocket connected and client registration restored!');
457
- this.retryCount = 0;
458
- this.currentBackoff = this.clientOptions?.initialBackoffMs ?? 1000;
459
- this.updateStatus('connected');
460
- settleConnection();
461
- } catch (error) {
462
- const restorationError = error instanceof Error
463
- ? error
464
- : new Error(String(error));
465
- this.logTransient(`TypedSocket client registration restoration failed: ${restorationError.message}`);
466
- settleConnection(restorationError);
467
- openedWebsocket?.close();
468
- }
469
- };
470
-
471
- this.websocket.onmessage = async (event) => {
472
- await this.handleMessage(event.data);
473
- };
474
-
475
- this.websocket.onclose = () => {
476
- if (this.connectionStatus === 'connected') {
477
- this.handleDisconnect();
478
- } else {
479
- this.rejectPendingClientRequests(new Error('TypedSocket disconnected'));
480
- settleConnection(this.stopped || this.clientOptions?.abortSignal?.aborted
481
- ? this.createAbortError()
482
- : new Error('TypedSocket connection closed before it opened'));
483
- }
484
- };
485
-
486
- this.websocket.onerror = () => {
487
- // transient socket errors (network blips, proxy TLS hiccups) are expected
488
- // and handled by reconnection; only exhausted retries surface as errors
489
- this.logTransient(`TypedSocket websocket error on ${wsUrl}`);
490
- };
491
-
492
- try {
493
- await done.promise;
494
- } catch (err) {
495
- if (connectionTimeout) {
496
- clearTimeout(connectionTimeout);
497
- }
498
- if (this.shouldReconnect()) {
499
- await this.scheduleReconnect();
500
- if (this.connectionStatus !== 'connected') {
501
- throw this.stopped || this.clientOptions?.abortSignal?.aborted ? this.createAbortError() : err;
502
- }
503
- } else {
504
- throw err;
505
- }
506
- }
507
- }
508
-
509
- /**
510
- * Converts an HTTP(S) URL to a WebSocket URL
511
- */
512
- private toWebSocketUrl(url: string): string {
513
- const parsed = new URL(url);
514
- const wsProtocol = (() => {
515
- switch (parsed.protocol) {
516
- case 'http:':
517
- case 'ws:':
518
- return 'ws:';
519
- case 'https:':
520
- case 'wss:':
521
- return 'wss:';
522
- default:
523
- throw new Error('TypedSocket server URL must use http, https, ws, or wss.');
524
- }
525
- })();
526
- return `${wsProtocol}//${parsed.host}${parsed.pathname}`;
527
- }
528
-
529
- /**
530
- * Handles incoming WebSocket messages
531
- */
532
- private async handleMessage(data: string | ArrayBuffer): Promise<void> {
533
- try {
534
- const messageText = typeof data === 'string' ? data : new TextDecoder().decode(data);
535
- const message = plugins.smartjson.parse(messageText) as plugins.typedrequestInterfaces.ITypedRequest;
536
-
537
- // Check if this is a response to a pending request
538
- if (message.correlation?.id && this.pendingRequests.has(message.correlation.id)) {
539
- const pending = this.pendingRequests.get(message.correlation.id)!;
540
- this.pendingRequests.delete(message.correlation.id);
541
- pending.resolve(message);
542
- return;
543
- }
544
-
545
- // Server-initiated request - route through TypedRouter
546
- const response = await this.typedrouter.routeAndAddResponse(message);
547
- if (response && this.websocket?.readyState === WebSocket.OPEN) {
548
- this.websocket.send(plugins.smartjson.stringify(response));
549
- }
550
- } catch (err) {
551
- console.error('TypedSocket failed to process message:', err);
552
- }
553
- }
554
-
555
- /**
556
- * Handles WebSocket disconnection
557
- */
558
- private handleDisconnect(): void {
559
- if (this.connectionStatus === 'disconnected') {
560
- return; // Already handled
561
- }
562
-
563
- this.updateStatus('disconnected');
564
-
565
- // Reject all pending requests — the connection is gone and they'll never receive a response
566
- this.rejectPendingClientRequests(new Error('TypedSocket disconnected'));
567
-
568
- if (this.shouldReconnect()) {
569
- this.scheduleReconnect();
570
- }
571
- }
572
-
573
- /**
574
- * Schedules a reconnection attempt with exponential backoff
575
- */
576
- private async scheduleReconnect(): Promise<void> {
577
- if (!this.shouldReconnect()) return;
578
- const clientOptions = this.clientOptions!;
579
-
580
- this.updateStatus('reconnecting');
581
- this.retryCount++;
582
-
583
- // Exponential backoff with jitter
584
- const jitter = this.currentBackoff * 0.2 * (Math.random() * 2 - 1);
585
- const delay = Math.min(this.currentBackoff + jitter, clientOptions.maxBackoffMs);
586
-
587
- this.logDebug(`TypedSocket reconnecting in ${Math.round(delay)}ms (attempt ${this.retryCount}/${clientOptions.maxRetries})`);
588
-
589
- await this.waitForReconnectDelay(delay);
590
-
591
- if (!this.shouldReconnect()) return;
592
-
593
- // Increase backoff for next time
594
- this.currentBackoff = Math.min(this.currentBackoff * 2, clientOptions.maxBackoffMs);
595
-
596
- try {
597
- await this.connect();
598
- } catch (err) {
599
- if (this.shouldReconnect()) {
600
- this.logDebug(`TypedSocket reconnection attempt failed: ${err instanceof Error ? err.message : String(err)}`);
601
- } else if (!this.stopped && !this.clientOptions?.abortSignal?.aborted) {
602
- console.error(
603
- `TypedSocket giving up on ${this.serverUrl} after ${this.retryCount} attempts:`,
604
- err instanceof Error ? err.message : err,
605
- );
606
- }
607
- }
608
- }
609
-
610
- /**
611
- * Lifecycle chatter: hidden by default in browser devtools (verbose level)
612
- * and irrelevant for servers, but available when debugging.
613
- */
614
- private logDebug(messageArg: string) {
615
- console.debug(messageArg);
616
- }
617
-
618
- /**
619
- * Failures that reconnection is expected to recover from. Logged as debug
620
- * while retries remain; the final failure is reported by scheduleReconnect.
621
- */
622
- private logTransient(messageArg: string) {
623
- if (this.shouldReconnect()) {
624
- this.logDebug(messageArg);
625
- } else if (!this.stopped && !this.clientOptions?.abortSignal?.aborted) {
626
- console.warn(messageArg);
627
- }
628
- }
629
-
630
- private shouldReconnect(): boolean {
631
- return Boolean(
632
- this.clientOptions?.autoReconnect &&
633
- !this.stopped &&
634
- !this.clientOptions.abortSignal?.aborted &&
635
- this.retryCount < this.clientOptions.maxRetries,
636
- );
637
- }
638
-
639
- private async waitForReconnectDelay(delayMsArg: number): Promise<void> {
640
- await new Promise<void>((resolve) => {
641
- let timeout: ReturnType<typeof setTimeout> | undefined;
642
- let finished = false;
643
- const finish = () => {
644
- if (finished) return;
645
- finished = true;
646
- if (timeout) {
647
- clearTimeout(timeout);
648
- }
649
- this.stopListeners.delete(finish);
650
- this.clientOptions?.abortSignal?.removeEventListener('abort', finish);
651
- resolve();
652
- };
653
- if (this.clientOptions?.abortSignal?.aborted) {
654
- finish();
655
- return;
656
- }
657
- if (this.stopped) {
658
- finish();
659
- return;
660
- }
661
- timeout = setTimeout(finish, delayMsArg);
662
- this.stopListeners.add(finish);
663
- this.clientOptions?.abortSignal?.addEventListener('abort', finish, { once: true });
664
- });
665
- }
666
-
667
- private createAbortError(): Error {
668
- return new Error('TypedSocket client startup aborted');
669
- }
670
-
671
- private rejectPendingClientRequests(errorArg: Error): void {
672
- for (const pending of this.pendingRequests.values()) {
673
- pending.reject(errorArg);
674
- }
675
- this.pendingRequests.clear();
676
- }
677
-
678
- /**
679
- * Updates connection status and notifies subscribers
680
- */
681
- private updateStatus(status: TConnectionStatus): void {
682
- if (this.connectionStatus !== status) {
683
- this.connectionStatus = status;
684
- this.statusSubject.next(status);
685
- }
686
- }
687
-
688
- /**
689
- * Sends a request to the server and waits for response (client-side)
690
- */
691
- private async sendRequest<T extends plugins.typedrequestInterfaces.ITypedRequest>(
692
- request: T,
693
- optionsArg: ITypedSocketRequestOptions = {},
694
- ): Promise<T> {
695
- if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
696
- throw new Error('WebSocket not connected');
697
- }
698
-
699
- const timeoutMs = this.normalizeRequestTimeout(optionsArg.timeoutMs);
700
- if (optionsArg.abortSignal?.aborted) {
701
- throw new Error('TypedSocket request aborted');
702
- }
703
-
704
- request.correlation ||= {
705
- id: plugins.smartstring.create.createCryptoRandomString(),
706
- phase: 'request',
707
- };
708
- const correlationId = request.correlation.id;
709
-
710
- return new Promise((resolve, reject) => {
711
- let settled = false;
712
- const cleanup = () => {
713
- clearTimeout(timeout);
714
- optionsArg.abortSignal?.removeEventListener('abort', onAbort);
715
- this.pendingRequests.delete(correlationId);
716
- };
717
- const settle = (errorArg?: Error, responseArg?: T) => {
718
- if (settled) {
719
- return;
720
- }
721
- settled = true;
722
- cleanup();
723
- if (errorArg) {
724
- reject(errorArg);
725
- } else {
726
- resolve(responseArg!);
727
- }
728
- };
729
- const onAbort = () => settle(new Error('TypedSocket request aborted'));
730
- const timeout = setTimeout(() => {
731
- settle(new Error('TypedSocket request timed out'));
732
- }, timeoutMs);
733
-
734
- this.pendingRequests.set(correlationId, {
735
- resolve: (response) => {
736
- settle(undefined, response as T);
737
- },
738
- reject: (error) => {
739
- settle(error);
740
- },
741
- });
742
- optionsArg.abortSignal?.addEventListener('abort', onAbort, { once: true });
743
- if (optionsArg.abortSignal?.aborted) {
744
- onAbort();
745
- return;
746
- }
747
-
748
- try {
749
- this.websocket!.send(plugins.smartjson.stringify(request));
750
- } catch (error) {
751
- settle(error instanceof Error ? error : new Error(String(error)));
752
- }
753
- });
754
- }
755
-
756
- private normalizeRequestTimeout(timeoutMsArg: number | undefined): number {
757
- const timeoutMs = timeoutMsArg ?? 30000;
758
- if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
759
- throw new Error('TypedSocket request timeout must be a finite non-negative number');
760
- }
761
- return timeoutMs;
762
- }
763
-
764
- private cancelPendingServerRequestsForPeer(peerIdArg: string, errorArg: Error): void {
765
- const correlationIds = this.pendingServerRequestIdsByPeerId.get(peerIdArg);
766
- if (!correlationIds) {
767
- return;
768
- }
769
- for (const correlationId of Array.from(correlationIds)) {
770
- this.pendingServerRequests.get(correlationId)?.cancel(errorArg);
771
- }
772
- }
773
-
774
- private async sendServerRequest<T extends plugins.typedrequestInterfaces.ITypedRequest>(
775
- typedRequestDataArg: T,
776
- targetArg: ISmartServeConnectionWrapper,
777
- optionsArg: ITypedSocketRequestOptions,
778
- ): Promise<T> {
779
- const timeoutMs = this.normalizeRequestTimeout(optionsArg.timeoutMs);
780
- if (this.serverStopping || !this.smartServeRef) {
781
- throw new Error('TypedSocket server stopped');
782
- }
783
- if (optionsArg.abortSignal?.aborted) {
784
- throw new Error('TypedSocket request aborted');
785
- }
786
-
787
- typedRequestDataArg.correlation ||= {
788
- id: plugins.smartstring.create.createCryptoRandomString(),
789
- phase: 'request',
790
- };
791
- const correlationId = typedRequestDataArg.correlation.id;
792
- const peerId = targetArg.peer.id;
793
- if (this.pendingServerRequests.has(correlationId)) {
794
- throw new Error(`Duplicate in-flight TypedSocket correlation ID: ${correlationId}`);
795
- }
796
- const cancellation = plugins.smartpromise.defer<never>();
797
- // A stop/abort may happen while InterestMap.addInterest is yielding. Attach
798
- // a rejection handler immediately; Promise.race below still receives the
799
- // original rejection once the interest exists.
800
- void cancellation.promise.catch(() => {});
801
- const cleanup = plugins.smartpromise.defer<void>();
802
- let cancellationRequested = false;
803
- const cancel = (errorArg: Error) => {
804
- if (cancellationRequested) {
805
- return;
806
- }
807
- cancellationRequested = true;
808
- cancellation.reject(errorArg);
809
- };
810
- const pendingRequest: IPendingServerRequest = {
811
- peerId,
812
- cancel,
813
- cleanupComplete: cleanup.promise,
814
- };
815
- this.pendingServerRequests.set(correlationId, pendingRequest);
816
- const requestsForPeer = this.pendingServerRequestIdsByPeerId.get(peerId) ?? new Set<string>();
817
- requestsForPeer.add(correlationId);
818
- this.pendingServerRequestIdsByPeerId.set(peerId, requestsForPeer);
819
-
820
- const onAbort = () => cancel(new Error('TypedSocket request aborted'));
821
- const timeout = setTimeout(
822
- () => cancel(new Error(`TypedSocket request timed out after ${timeoutMs}ms`)),
823
- timeoutMs,
824
- );
825
- optionsArg.abortSignal?.addEventListener('abort', onAbort, { once: true });
826
- if (optionsArg.abortSignal?.aborted) {
827
- onAbort();
828
- }
829
-
830
- const destroyInterests: Array<() => void> = [];
831
- try {
832
- const interestPromises: Array<Promise<T>> = [];
833
- for (const typedRouter of this.serverTypedRouters) {
834
- const interestRegistration = typedRouter.fireEventInterestMap
835
- .addInterest(correlationId, typedRequestDataArg)
836
- .then((interestArg) => {
837
- if (cancellationRequested || this.serverStopping) {
838
- interestArg.destroy();
839
- }
840
- return interestArg;
841
- });
842
- const interest = await Promise.race([
843
- interestRegistration,
844
- cancellation.promise,
845
- ]);
846
- if (cancellationRequested || this.serverStopping) {
847
- interest.destroy();
848
- await cancellation.promise;
849
- }
850
- destroyInterests.push(() => interest.destroy());
851
- interestPromises.push(interest.interestFullfilled as Promise<T>);
852
- }
853
- // Establish the rejecting race before send so synchronous close/abort and
854
- // send failures all use the same cleanup path.
855
- const response = Promise.race([
856
- ...interestPromises,
857
- cancellation.promise,
858
- ]);
859
- const targetIsStillConnected = this.smartServeRef
860
- ?.getWebSocketConnections()
861
- .some((peerArg) => peerArg.id === peerId) ?? false;
862
- if (!targetIsStillConnected) {
863
- cancel(new Error(`TypedSocket target connection closed: ${peerId}`));
864
- }
865
- if (this.serverStopping) {
866
- cancel(new Error('TypedSocket server stopped'));
867
- }
868
- if (!cancellationRequested && targetIsStillConnected && !this.serverStopping) {
869
- targetArg.peer.send(plugins.smartjson.stringify(typedRequestDataArg));
870
- }
871
- return await response;
872
- } finally {
873
- clearTimeout(timeout);
874
- optionsArg.abortSignal?.removeEventListener('abort', onAbort);
875
- this.pendingServerRequests.delete(correlationId);
876
- const remainingRequestsForPeer = this.pendingServerRequestIdsByPeerId.get(peerId);
877
- remainingRequestsForPeer?.delete(correlationId);
878
- if (remainingRequestsForPeer?.size === 0) {
879
- this.pendingServerRequestIdsByPeerId.delete(peerId);
880
- }
881
- for (const destroyInterest of destroyInterests) {
882
- destroyInterest();
883
- }
884
- cleanup.resolve();
885
- }
886
- }
887
-
888
- // ============================================================================
889
- // PUBLIC API - SHARED
890
- // ============================================================================
891
-
892
- /**
893
- * Creates a TypedRequest for the specified method.
894
- * On clients, sends to the server.
895
- * On servers, sends to the specified target connection.
896
- */
897
- public createTypedRequest<T extends plugins.typedrequestInterfaces.ITypedRequest>(
898
- methodName: T['method'],
899
- targetConnection?: ISmartServeConnectionWrapper,
900
- optionsArg: ITypedSocketRequestOptions = {},
901
- ): plugins.typedrequest.TypedRequest<T> {
902
- const postMethod = async (
903
- requestDataArg: plugins.typedrequestInterfaces.ITypedRequest,
904
- postOptionsArg: plugins.typedrequest.ITypedTargetPostOptions = {},
905
- ): Promise<plugins.typedrequestInterfaces.ITypedRequest> => {
906
- const typedRequestData = requestDataArg as T;
907
- const composedAbortSignal = composeAbortSignals(
908
- optionsArg.abortSignal,
909
- postOptionsArg.signal,
910
- );
911
- const requestOptions: ITypedSocketRequestOptions = {
912
- ...optionsArg,
913
- abortSignal: composedAbortSignal.signal,
914
- };
915
-
916
- try {
917
- if (this.side === 'client') {
918
- return await this.sendRequest(typedRequestData, requestOptions);
919
- }
920
-
921
- // Server-side: send to target connection
922
- if (!this.smartServeRef) {
923
- throw new Error('Server not initialized');
924
- }
925
-
926
- let target = targetConnection;
927
- if (!target) {
928
- const allConnections = this.smartServeRef.getWebSocketConnections();
929
- if (allConnections.length === 1) {
930
- const peer = allConnections[0];
931
- target = wrapSmartServePeer(peer);
932
- } else if (allConnections.length === 0) {
933
- throw new Error('No WebSocket connections available');
934
- } else {
935
- throw new Error('Multiple connections available - specify targetConnection');
936
- }
937
- }
938
-
939
- return await this.sendServerRequest(typedRequestData, target, requestOptions);
940
- } finally {
941
- composedAbortSignal.cleanup();
942
- }
943
- };
944
-
945
- return new plugins.typedrequest.TypedRequest<T>(
946
- new plugins.typedrequest.TypedTarget({
947
- postMethod,
948
- supportsAbortSignal: true,
949
- }),
950
- methodName
951
- );
952
- }
953
-
954
- /**
955
- * Gets the current connection status
956
- */
957
- public getStatus(): TConnectionStatus {
958
- return this.connectionStatus;
959
- }
960
-
961
- /**
962
- * Stops the TypedSocket client or cleans up server state
963
- */
964
- public async stop(): Promise<void> {
965
- if (this.side === 'client') {
966
- this.stopped = true;
967
- for (const stopListener of Array.from(this.stopListeners)) {
968
- stopListener();
969
- }
970
- this.stopListeners.clear();
971
- if (this.clientOptions) {
972
- this.clientOptions.autoReconnect = false;
973
- if (this.clientOptions.abortSignal && this.abortSignalListener) {
974
- this.clientOptions.abortSignal.removeEventListener('abort', this.abortSignalListener);
975
- }
976
- }
977
- this.abortSignalListener = undefined;
978
- if (this.websocket) {
979
- this.websocket.close();
980
- this.websocket = null;
981
- }
982
- for (const pendingRequest of this.pendingRequests.values()) {
983
- pendingRequest.reject(new Error('TypedSocket stopped'));
984
- }
985
- this.pendingRequests.clear();
986
- } else {
987
- this.serverStopping = true;
988
- const pendingRequests = Array.from(this.pendingServerRequests.values());
989
- for (const pendingRequest of pendingRequests) {
990
- pendingRequest.cancel(new Error('TypedSocket server stopped'));
991
- }
992
- await Promise.all(pendingRequests.map((pendingRequest) => pendingRequest.cleanupComplete));
993
- this.unsubscribeSmartServeConnectionClose?.();
994
- this.unsubscribeSmartServeConnectionClose = null;
995
- this.smartServeRef = null;
996
- for (const detachProtocolRouter of this.detachProtocolRouters.reverse()) {
997
- detachProtocolRouter();
998
- }
999
- this.detachProtocolRouters = [];
1000
- }
1001
- }
1002
-
1003
- // ============================================================================
1004
- // CLIENT-ONLY METHODS
1005
- // ============================================================================
1006
-
1007
- /**
1008
- * Sets a tag on this client connection.
1009
- * Tags are stored on the server and can be used for filtering.
1010
- * @client-only
1011
- */
1012
- public async setTag<T extends plugins.typedrequestInterfaces.ITag>(
1013
- name: T['name'],
1014
- payload: T['payload']
1015
- ): Promise<void> {
1016
- if (this.side !== 'client') {
1017
- throw new Error('setTag is only available on clients');
1018
- }
1019
-
1020
- const mutation = Symbol(name);
1021
- this.clientTagMutations.set(name, mutation);
1022
- try {
1023
- const request = this.createTypedRequest<IReq_SetClientTag>('__typedsocket_setTag');
1024
- const response = await request.fire({ name, payload });
1025
-
1026
- if (!response.success) {
1027
- throw new Error('Failed to set tag on server');
1028
- }
1029
- if (this.clientTagMutations.get(name) === mutation) {
1030
- this.clientTags.set(name, payload);
1031
- }
1032
- } finally {
1033
- if (this.clientTagMutations.get(name) === mutation) {
1034
- this.clientTagMutations.delete(name);
1035
- }
1036
- }
1037
- }
1038
-
1039
- /**
1040
- * Re-applies all client tags after a reconnect, since server-side tags are
1041
- * bound to the previous connection.
1042
- */
1043
- private async reapplyClientTags(): Promise<void> {
1044
- for (const [name, payload] of this.clientTags) {
1045
- const request = this.createTypedRequest<IReq_SetClientTag>('__typedsocket_setTag');
1046
- const response = await request.fire({ name, payload });
1047
- if (!response.success) {
1048
- throw new Error(`TypedSocket failed to restore tag ${name} after reconnect`);
1049
- }
1050
- }
1051
- }
1052
-
1053
- /**
1054
- * Removes a tag from this client connection.
1055
- * @client-only
1056
- */
1057
- public async removeTag(name: string): Promise<void> {
1058
- if (this.side !== 'client') {
1059
- throw new Error('removeTag is only available on clients');
1060
- }
1061
-
1062
- const mutation = Symbol(name);
1063
- this.clientTagMutations.set(name, mutation);
1064
- this.clientTags.delete(name);
1065
- try {
1066
- const request = this.createTypedRequest<IReq_RemoveClientTag>('__typedsocket_removeTag');
1067
- const response = await request.fire({ name });
1068
-
1069
- if (!response.success) {
1070
- throw new Error('Failed to remove tag on server');
1071
- }
1072
- } finally {
1073
- if (this.clientTagMutations.get(name) === mutation) {
1074
- this.clientTagMutations.delete(name);
1075
- }
1076
- }
1077
- }
1078
-
1079
- // ============================================================================
1080
- // SERVER-ONLY METHODS
1081
- // ============================================================================
1082
-
1083
- /**
1084
- * Finds all connections matching the filter function.
1085
- * @server-only
1086
- */
1087
- public async findAllTargetConnections(
1088
- asyncFindFuncArg: (connectionArg: ISmartServeConnectionWrapper) => Promise<boolean>
1089
- ): Promise<ISmartServeConnectionWrapper[]> {
1090
- if (this.side !== 'server' || !this.smartServeRef) {
1091
- throw new Error('findAllTargetConnections is only available on servers');
1092
- }
1093
-
1094
- const matchingConnections: ISmartServeConnectionWrapper[] = [];
1095
- for (const peer of this.smartServeRef.getWebSocketConnections()) {
1096
- const wrapper = wrapSmartServePeer(peer);
1097
- if (await asyncFindFuncArg(wrapper)) {
1098
- matchingConnections.push(wrapper);
1099
- }
1100
- }
1101
- return matchingConnections;
1102
- }
1103
-
1104
- /**
1105
- * Finds the first connection matching the filter function.
1106
- * @server-only
1107
- */
1108
- public async findTargetConnection(
1109
- asyncFindFuncArg: (connectionArg: ISmartServeConnectionWrapper) => Promise<boolean>
1110
- ): Promise<ISmartServeConnectionWrapper | undefined> {
1111
- const allMatching = await this.findAllTargetConnections(asyncFindFuncArg);
1112
- return allMatching[0];
1113
- }
1114
-
1115
- /**
1116
- * Finds all connections with the specified tag.
1117
- * @server-only
1118
- */
1119
- public async findAllTargetConnectionsByTag<TTag extends plugins.typedrequestInterfaces.ITag = any>(
1120
- keyArg: TTag['name'],
1121
- payloadArg?: TTag['payload']
1122
- ): Promise<ISmartServeConnectionWrapper[]> {
1123
- if (this.side !== 'server' || !this.smartServeRef) {
1124
- throw new Error('findAllTargetConnectionsByTag is only available on servers');
1125
- }
1126
-
1127
- const peers = this.smartServeRef.getWebSocketConnectionsByTag(keyArg);
1128
- const results: ISmartServeConnectionWrapper[] = [];
1129
-
1130
- for (const peer of peers) {
1131
- const wrapper = wrapSmartServePeer(peer);
1132
-
1133
- // If payload specified, also filter by payload
1134
- if (payloadArg !== undefined) {
1135
- const tag = await wrapper.getTagById(keyArg);
1136
- if (plugins.smartjson.stringify(tag?.payload) !== plugins.smartjson.stringify(payloadArg)) {
1137
- continue;
1138
- }
1139
- }
1140
- results.push(wrapper);
1141
- }
1142
- return results;
1143
- }
1144
-
1145
- /**
1146
- * Finds the first connection with the specified tag.
1147
- * @server-only
1148
- */
1149
- public async findTargetConnectionByTag<TTag extends plugins.typedrequestInterfaces.ITag = any>(
1150
- keyArg: TTag['name'],
1151
- payloadArg?: TTag['payload']
1152
- ): Promise<ISmartServeConnectionWrapper | undefined> {
1153
- const allResults = await this.findAllTargetConnectionsByTag(keyArg, payloadArg);
1154
- return allResults[0];
1155
- }
1156
- }