@api.global/typedsocket 5.1.2 → 6.1.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.clienttagreconciler.d.ts +51 -0
- package/dist_ts/classes.clienttagreconciler.js +290 -0
- package/dist_ts/classes.nativebyteerror.d.ts +13 -0
- package/dist_ts/classes.nativebyteerror.js +26 -0
- package/dist_ts/classes.nativebytemanager.d.ts +327 -0
- package/dist_ts/classes.nativebytemanager.js +2343 -0
- package/dist_ts/{typedsocket.classes.typedsocket.d.ts → classes.typedsocket.d.ts} +122 -13
- package/dist_ts/classes.typedsocket.js +1733 -0
- package/dist_ts/classes.typedsockettagpolicymanager.d.ts +112 -0
- package/dist_ts/classes.typedsockettagpolicymanager.js +549 -0
- package/dist_ts/constants.nativebytes.d.ts +59 -0
- package/dist_ts/constants.nativebytes.js +58 -0
- package/dist_ts/helpers.nativebytecodec.d.ts +8 -0
- package/dist_ts/helpers.nativebytecodec.js +328 -0
- package/dist_ts/helpers.websocket.d.ts +8 -0
- package/dist_ts/helpers.websocket.js +13 -0
- package/dist_ts/index.d.ts +9 -1
- package/dist_ts/index.js +6 -2
- package/dist_ts/interfaces.diagnostics.d.ts +73 -0
- package/dist_ts/interfaces.diagnostics.js +2 -0
- package/dist_ts/interfaces.nativebytes.d.ts +187 -0
- package/dist_ts/interfaces.nativebytes.js +2 -0
- package/{ts/typedsocket.plugins.ts → dist_ts/plugins.d.ts} +3 -9
- package/dist_ts/plugins.js +14 -0
- package/{license → license.md} +2 -2
- package/package.json +22 -18
- package/readme.hints.md +60 -19
- package/readme.md +536 -56
- package/dist_ts/typedsocket.classes.typedsocket.js +0 -941
- package/dist_ts/typedsocket.plugins.d.ts +0 -11
- package/dist_ts/typedsocket.plugins.js +0 -13
- package/npmextra.json +0 -35
- package/ts/00_commitinfo_data.ts +0 -8
- package/ts/index.ts +0 -1
- package/ts/typedsocket.classes.typedsocket.ts +0 -1156
|
@@ -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
|
-
}
|