@cratis/arc 22.185.0 → 22.186.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/commands/Command.ts +13 -20
- package/dist/cjs/commands/Command.d.ts +1 -0
- package/dist/cjs/commands/Command.d.ts.map +1 -1
- package/dist/cjs/commands/Command.js +15 -16
- package/dist/cjs/commands/Command.js.map +1 -1
- package/dist/cjs/queries/ObservableQueryFor.d.ts +2 -0
- package/dist/cjs/queries/ObservableQueryFor.d.ts.map +1 -1
- package/dist/cjs/queries/ObservableQueryFor.js +34 -27
- package/dist/cjs/queries/ObservableQueryFor.js.map +1 -1
- package/dist/cjs/queries/QueryInstanceCache.d.ts +1 -0
- package/dist/cjs/queries/QueryInstanceCache.d.ts.map +1 -1
- package/dist/cjs/queries/QueryInstanceCache.js +11 -16
- package/dist/cjs/queries/QueryInstanceCache.js.map +1 -1
- package/dist/cjs/queries/ServerSentEventHubConnection.d.ts +7 -0
- package/dist/cjs/queries/ServerSentEventHubConnection.d.ts.map +1 -1
- package/dist/cjs/queries/ServerSentEventHubConnection.js +97 -57
- package/dist/cjs/queries/ServerSentEventHubConnection.js.map +1 -1
- package/dist/cjs/queries/WebSocketHubConnection.d.ts +6 -0
- package/dist/cjs/queries/WebSocketHubConnection.d.ts.map +1 -1
- package/dist/cjs/queries/WebSocketHubConnection.js +62 -43
- package/dist/cjs/queries/WebSocketHubConnection.js.map +1 -1
- package/dist/esm/commands/Command.d.ts +1 -0
- package/dist/esm/commands/Command.d.ts.map +1 -1
- package/dist/esm/commands/Command.js +15 -16
- package/dist/esm/commands/Command.js.map +1 -1
- package/dist/esm/queries/ObservableQueryFor.d.ts +2 -0
- package/dist/esm/queries/ObservableQueryFor.d.ts.map +1 -1
- package/dist/esm/queries/ObservableQueryFor.js +35 -28
- package/dist/esm/queries/ObservableQueryFor.js.map +1 -1
- package/dist/esm/queries/QueryInstanceCache.d.ts +1 -0
- package/dist/esm/queries/QueryInstanceCache.d.ts.map +1 -1
- package/dist/esm/queries/QueryInstanceCache.js +11 -16
- package/dist/esm/queries/QueryInstanceCache.js.map +1 -1
- package/dist/esm/queries/ServerSentEventHubConnection.d.ts +7 -0
- package/dist/esm/queries/ServerSentEventHubConnection.d.ts.map +1 -1
- package/dist/esm/queries/ServerSentEventHubConnection.js +97 -57
- package/dist/esm/queries/ServerSentEventHubConnection.js.map +1 -1
- package/dist/esm/queries/WebSocketHubConnection.d.ts +6 -0
- package/dist/esm/queries/WebSocketHubConnection.d.ts.map +1 -1
- package/dist/esm/queries/WebSocketHubConnection.js +62 -43
- package/dist/esm/queries/WebSocketHubConnection.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/queries/ObservableQueryFor.ts +43 -32
- package/queries/QueryInstanceCache.ts +12 -18
- package/queries/ServerSentEventHubConnection.ts +120 -113
- package/queries/WebSocketHubConnection.ts +72 -53
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@cratis/arc",
|
|
4
|
-
"version": "22.
|
|
4
|
+
"version": "22.186.0",
|
|
5
5
|
"description": "TypeScript command, query, validation, identity, and messaging runtime for Cratis Arc — the CQRS application framework for ASP.NET Core — used by generated proxies.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"cratis",
|
|
@@ -124,29 +124,7 @@ export abstract class ObservableQueryFor<
|
|
|
124
124
|
this._connection.disconnect();
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
this.validation?.validate((args as object) || {}) || [];
|
|
129
|
-
if (clientValidationErrors.length > 0) {
|
|
130
|
-
// Serve the failure through the connection rather than throwing, so a subscriber sees an invalid result
|
|
131
|
-
// on its normal callback path instead of the empty result an unestablished connection would emit.
|
|
132
|
-
this._connection = new NullObservableQueryConnection(
|
|
133
|
-
this.defaultValue,
|
|
134
|
-
QueryResult.validationFailed(clientValidationErrors, this),
|
|
135
|
-
);
|
|
136
|
-
} else if (this.validateArguments(args)) {
|
|
137
|
-
this._connection = createObservableQueryConnection({
|
|
138
|
-
route: this.route,
|
|
139
|
-
queryName: this.queryName ?? this.constructor.name,
|
|
140
|
-
origin: this._origin,
|
|
141
|
-
apiBasePath: this._apiBasePath,
|
|
142
|
-
microservice: this._microservice,
|
|
143
|
-
args: args as object,
|
|
144
|
-
httpHeadersCallback: this._httpHeadersCallback,
|
|
145
|
-
pollingOptions: this._pollingOptions,
|
|
146
|
-
});
|
|
147
|
-
} else {
|
|
148
|
-
this._connection = new NullObservableQueryConnection(this.defaultValue);
|
|
149
|
-
}
|
|
127
|
+
this._connection = this.createConnection(args);
|
|
150
128
|
|
|
151
129
|
// Descriptor-backed instance properties provide defaults; fresh args passed to subscribe()
|
|
152
130
|
// must take precedence over any stale instance property values. Spread parameterValues
|
|
@@ -185,6 +163,47 @@ export abstract class ObservableQueryFor<
|
|
|
185
163
|
return subscriber;
|
|
186
164
|
}
|
|
187
165
|
|
|
166
|
+
private createConnection(args?: TParameters): IObservableQueryConnection<TDataType> {
|
|
167
|
+
const clientValidationErrors =
|
|
168
|
+
this.validation?.validate((args as object) || {}) || [];
|
|
169
|
+
if (clientValidationErrors.length > 0) {
|
|
170
|
+
// Serve the failure through the connection rather than throwing, so a subscriber sees an invalid result
|
|
171
|
+
// on its normal callback path instead of the empty result an unestablished connection would emit.
|
|
172
|
+
return new NullObservableQueryConnection(
|
|
173
|
+
this.defaultValue,
|
|
174
|
+
QueryResult.validationFailed(clientValidationErrors, this),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!this.validateArguments(args)) {
|
|
179
|
+
return new NullObservableQueryConnection(this.defaultValue);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return createObservableQueryConnection({
|
|
183
|
+
route: this.route,
|
|
184
|
+
queryName: this.queryName ?? this.constructor.name,
|
|
185
|
+
origin: this._origin,
|
|
186
|
+
apiBasePath: this._apiBasePath,
|
|
187
|
+
microservice: this._microservice,
|
|
188
|
+
args: args as object,
|
|
189
|
+
httpHeadersCallback: this._httpHeadersCallback,
|
|
190
|
+
pollingOptions: this._pollingOptions,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private buildRequestHeaders(): Record<string, any> {
|
|
195
|
+
const headers: Record<string, any> = {
|
|
196
|
+
...(this._httpHeadersCallback?.() || {}),
|
|
197
|
+
Accept: 'application/json',
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (this._microservice?.length > 0) {
|
|
202
|
+
headers[Globals.microserviceHttpHeader] = this._microservice;
|
|
203
|
+
}
|
|
204
|
+
return headers;
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
/** @inheritdoc */
|
|
189
208
|
async perform(args?: TParameters): Promise<QueryResult<TDataType>> {
|
|
190
209
|
const noSuccess = {
|
|
@@ -207,15 +226,7 @@ export abstract class ObservableQueryFor<
|
|
|
207
226
|
// Collect parameter values from parameterDescriptors that are set
|
|
208
227
|
const parameterValues = ParametersHelper.collectParameterValues(this);
|
|
209
228
|
|
|
210
|
-
const headers =
|
|
211
|
-
...(this._httpHeadersCallback?.() || {}),
|
|
212
|
-
Accept: 'application/json',
|
|
213
|
-
'Content-Type': 'application/json',
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
if (this._microservice?.length > 0) {
|
|
217
|
-
headers[Globals.microserviceHttpHeader] = this._microservice;
|
|
218
|
-
}
|
|
229
|
+
const headers = this.buildRequestHeaders();
|
|
219
230
|
|
|
220
231
|
const response = await executeQueryHttpRequest(this._httpMethod, {
|
|
221
232
|
route: this.route,
|
|
@@ -378,15 +378,19 @@ export class QueryInstanceCache {
|
|
|
378
378
|
*/
|
|
379
379
|
teardownAllSubscriptions(): void {
|
|
380
380
|
for (const [, entry] of this._entries) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
381
|
+
this.teardownEntry(entry);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
385
384
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
entry.
|
|
385
|
+
private teardownEntry(entry: QueryCacheEntry<unknown>): void {
|
|
386
|
+
if (entry.pendingCleanup !== undefined) {
|
|
387
|
+
clearTimeout(entry.pendingCleanup);
|
|
388
|
+
entry.pendingCleanup = undefined;
|
|
389
389
|
}
|
|
390
|
+
|
|
391
|
+
entry.subscribed = false;
|
|
392
|
+
entry.teardown?.();
|
|
393
|
+
entry.teardown = undefined;
|
|
390
394
|
}
|
|
391
395
|
|
|
392
396
|
/**
|
|
@@ -395,17 +399,7 @@ export class QueryInstanceCache {
|
|
|
395
399
|
* unmounts permanently so that all query connections are closed synchronously.
|
|
396
400
|
*/
|
|
397
401
|
dispose(): void {
|
|
398
|
-
|
|
399
|
-
if (entry.pendingCleanup !== undefined) {
|
|
400
|
-
clearTimeout(entry.pendingCleanup);
|
|
401
|
-
entry.pendingCleanup = undefined;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
entry.subscribed = false;
|
|
405
|
-
entry.teardown?.();
|
|
406
|
-
entry.teardown = undefined;
|
|
407
|
-
}
|
|
408
|
-
|
|
402
|
+
this.teardownAllSubscriptions();
|
|
409
403
|
this._entries.clear();
|
|
410
404
|
}
|
|
411
405
|
|
|
@@ -31,6 +31,19 @@ interface ActiveSubscription {
|
|
|
31
31
|
*/
|
|
32
32
|
const IDLE_THRESHOLD_FACTOR = 2;
|
|
33
33
|
|
|
34
|
+
/** Maximum number of subscribe retries before falling back to a full SSE reconnect. */
|
|
35
|
+
const SUBSCRIBE_MAX_RETRIES = 3;
|
|
36
|
+
|
|
37
|
+
/** Base delay between subscribe retries; multiplied by the attempt number. */
|
|
38
|
+
const SUBSCRIBE_RETRY_DELAY_MS = 200;
|
|
39
|
+
|
|
40
|
+
interface SubscribeTarget {
|
|
41
|
+
queryId: string;
|
|
42
|
+
subscription: ActiveSubscription;
|
|
43
|
+
connectionId: string;
|
|
44
|
+
attempt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/**
|
|
35
48
|
* A multiplexed SSE hub connection that uses EventSource for server→client streaming
|
|
36
49
|
* and fetch POST requests for client→server subscribe/unsubscribe commands.
|
|
@@ -225,11 +238,7 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
|
|
|
225
238
|
}
|
|
226
239
|
|
|
227
240
|
this._policy.cancel();
|
|
228
|
-
|
|
229
|
-
if (this._microservice?.length > 0) {
|
|
230
|
-
const param = `${Globals.microserviceWSQueryArgument}=${encodeURIComponent(this._microservice)}`;
|
|
231
|
-
url += (url.includes('?') ? '&' : '?') + param;
|
|
232
|
-
}
|
|
241
|
+
const url = this.buildEventSourceUrl();
|
|
233
242
|
|
|
234
243
|
this._connectionId = undefined;
|
|
235
244
|
this._supportsSubscriptionRevisions = false;
|
|
@@ -239,43 +248,59 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
|
|
|
239
248
|
this._eventSource = eventSource;
|
|
240
249
|
|
|
241
250
|
eventSource.onopen = () => {
|
|
242
|
-
if (this.
|
|
251
|
+
if (!this.isActiveSource(eventSource)) return;
|
|
243
252
|
console.log('SSE hub connection established', url);
|
|
244
253
|
this._policy.reset();
|
|
245
254
|
this._keepAlive.start();
|
|
246
|
-
|
|
247
|
-
// If the server does not send a Connected message within the timeout, the
|
|
248
|
-
// connection is broken. Close and retry via the reconnect policy.
|
|
249
|
-
this.clearConnectTimeout();
|
|
250
|
-
this._connectTimeoutTimer = setTimeout(() => {
|
|
251
|
-
if (
|
|
252
|
-
!this._disconnected &&
|
|
253
|
-
this._eventSource === eventSource &&
|
|
254
|
-
!this._connectionId
|
|
255
|
-
) {
|
|
256
|
-
console.warn(
|
|
257
|
-
'SSE hub: no Connected message within timeout; retrying',
|
|
258
|
-
this._connectTimeoutMs,
|
|
259
|
-
url,
|
|
260
|
-
);
|
|
261
|
-
this.reconnect();
|
|
262
|
-
}
|
|
263
|
-
}, this._connectTimeoutMs);
|
|
255
|
+
this.startConnectTimeout(eventSource, url);
|
|
264
256
|
};
|
|
265
257
|
|
|
266
258
|
eventSource.onmessage = (event: MessageEvent) => {
|
|
267
|
-
if (this.
|
|
259
|
+
if (!this.isActiveSource(eventSource)) return;
|
|
268
260
|
this._keepAlive.recordActivity();
|
|
269
261
|
this.handleMessage(event.data as string);
|
|
270
262
|
};
|
|
271
263
|
|
|
272
264
|
eventSource.onerror = () => {
|
|
273
|
-
if (this.
|
|
265
|
+
if (!this.isActiveSource(eventSource)) return;
|
|
274
266
|
console.warn('SSE hub connection error', url);
|
|
275
267
|
this.reconnect();
|
|
276
268
|
};
|
|
277
269
|
}
|
|
278
270
|
|
|
271
|
+
private buildEventSourceUrl(): string {
|
|
272
|
+
let url = this._sseUrl;
|
|
273
|
+
if (this._microservice?.length > 0) {
|
|
274
|
+
const param = `${Globals.microserviceWSQueryArgument}=${encodeURIComponent(this._microservice)}`;
|
|
275
|
+
url += (url.includes('?') ? '&' : '?') + param;
|
|
276
|
+
}
|
|
277
|
+
return url;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private isActiveSource(eventSource: EventSource): boolean {
|
|
281
|
+
return !this._disconnected && this._eventSource === eventSource;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* If the server does not send a Connected message within the timeout, the
|
|
286
|
+
* connection is broken. Close and retry via the reconnect policy.
|
|
287
|
+
* @param {EventSource} eventSource The source the timeout guards.
|
|
288
|
+
* @param {string} url The URL used for diagnostics.
|
|
289
|
+
*/
|
|
290
|
+
private startConnectTimeout(eventSource: EventSource, url: string): void {
|
|
291
|
+
this.clearConnectTimeout();
|
|
292
|
+
this._connectTimeoutTimer = setTimeout(() => {
|
|
293
|
+
if (this.isActiveSource(eventSource) && !this._connectionId) {
|
|
294
|
+
console.warn(
|
|
295
|
+
'SSE hub: no Connected message within timeout; retrying',
|
|
296
|
+
this._connectTimeoutMs,
|
|
297
|
+
url,
|
|
298
|
+
);
|
|
299
|
+
this.reconnect();
|
|
300
|
+
}
|
|
301
|
+
}, this._connectTimeoutMs);
|
|
302
|
+
}
|
|
303
|
+
|
|
279
304
|
private reconnect(): void {
|
|
280
305
|
this._keepAlive.stop();
|
|
281
306
|
this.clearConnectTimeout();
|
|
@@ -321,20 +346,10 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
|
|
|
321
346
|
// Server-sent keep-alive ping — activity already recorded in onmessage.
|
|
322
347
|
break;
|
|
323
348
|
case HubMessageType.Unauthorized:
|
|
324
|
-
|
|
325
|
-
console.warn('SSE hub: query unauthorized', message.queryId);
|
|
326
|
-
this.handleUnauthorized(message);
|
|
327
|
-
}
|
|
349
|
+
this.handleUnauthorizedMessage(message);
|
|
328
350
|
break;
|
|
329
351
|
case HubMessageType.Error:
|
|
330
|
-
|
|
331
|
-
console.error(
|
|
332
|
-
'SSE hub: query error',
|
|
333
|
-
message.queryId,
|
|
334
|
-
message.payload,
|
|
335
|
-
);
|
|
336
|
-
this.handleError(message);
|
|
337
|
-
}
|
|
352
|
+
this.handleErrorMessage(message);
|
|
338
353
|
break;
|
|
339
354
|
}
|
|
340
355
|
} catch (error) {
|
|
@@ -342,6 +357,18 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
|
|
|
342
357
|
}
|
|
343
358
|
}
|
|
344
359
|
|
|
360
|
+
private handleUnauthorizedMessage(message: HubMessage): void {
|
|
361
|
+
if (!this.isMessageForCurrentSubscription(message)) return;
|
|
362
|
+
console.warn('SSE hub: query unauthorized', message.queryId);
|
|
363
|
+
this.handleUnauthorized(message);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private handleErrorMessage(message: HubMessage): void {
|
|
367
|
+
if (!this.isMessageForCurrentSubscription(message)) return;
|
|
368
|
+
console.error('SSE hub: query error', message.queryId, message.payload);
|
|
369
|
+
this.handleError(message);
|
|
370
|
+
}
|
|
371
|
+
|
|
345
372
|
private handleConnected(message: HubMessage): void {
|
|
346
373
|
const connectionId = message.payload as string;
|
|
347
374
|
const connectionIdChanged = this._connectionId !== connectionId;
|
|
@@ -458,15 +485,7 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
|
|
|
458
485
|
}
|
|
459
486
|
|
|
460
487
|
const customHeaders = Globals.httpHeadersCallback?.() ?? {};
|
|
461
|
-
|
|
462
|
-
// Maximum number of subscribe retries before falling back to a full SSE reconnect.
|
|
463
|
-
// In a round-robin load-balanced deployment the subscribe POST may land on a different
|
|
464
|
-
// backend instance than the one holding the SSE connection. Retrying gives the load
|
|
465
|
-
// balancer the chance to route a subsequent attempt to the correct instance without
|
|
466
|
-
// tearing down the SSE connection unnecessarily. With N backend instances at most
|
|
467
|
-
// N-1 retries are needed, so 3 retries covers deployments with up to 4 replicas.
|
|
468
|
-
const maxRetries = 3;
|
|
469
|
-
const retryDelayMs = 200;
|
|
488
|
+
const target: SubscribeTarget = { queryId, subscription, connectionId, attempt };
|
|
470
489
|
|
|
471
490
|
fetch(this._subscribeUrl, {
|
|
472
491
|
method: 'POST',
|
|
@@ -475,78 +494,66 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
|
|
|
475
494
|
})
|
|
476
495
|
.then((response) => {
|
|
477
496
|
if (!response.ok) {
|
|
478
|
-
|
|
479
|
-
!this.isCurrentSubscription(queryId, subscription, connectionId)
|
|
480
|
-
) {
|
|
481
|
-
console.warn(
|
|
482
|
-
'SSE hub: subscribe POST returned a failure for an obsolete connection or subscription; ignoring',
|
|
483
|
-
queryId,
|
|
484
|
-
response.status,
|
|
485
|
-
);
|
|
486
|
-
} else if (attempt < maxRetries) {
|
|
487
|
-
setTimeout(
|
|
488
|
-
() => {
|
|
489
|
-
if (
|
|
490
|
-
this.isCurrentSubscription(
|
|
491
|
-
queryId,
|
|
492
|
-
subscription,
|
|
493
|
-
connectionId,
|
|
494
|
-
)
|
|
495
|
-
) {
|
|
496
|
-
this.sendSubscribe(
|
|
497
|
-
queryId,
|
|
498
|
-
subscription,
|
|
499
|
-
attempt + 1,
|
|
500
|
-
);
|
|
501
|
-
}
|
|
502
|
-
},
|
|
503
|
-
retryDelayMs * (attempt + 1),
|
|
504
|
-
);
|
|
505
|
-
} else {
|
|
506
|
-
console.warn(
|
|
507
|
-
'SSE hub: subscribe POST failed after retries; reconnecting',
|
|
508
|
-
queryId,
|
|
509
|
-
response.status,
|
|
510
|
-
attempt + 1,
|
|
511
|
-
);
|
|
512
|
-
this.reconnect();
|
|
513
|
-
}
|
|
497
|
+
this.handleSubscribeFailure(target, console.warn, response.status);
|
|
514
498
|
}
|
|
515
499
|
})
|
|
516
500
|
.catch((error) => {
|
|
517
|
-
|
|
518
|
-
console.warn(
|
|
519
|
-
'SSE hub: subscribe POST failed for an obsolete connection or subscription; ignoring',
|
|
520
|
-
queryId,
|
|
521
|
-
error,
|
|
522
|
-
);
|
|
523
|
-
} else if (attempt < maxRetries) {
|
|
524
|
-
setTimeout(
|
|
525
|
-
() => {
|
|
526
|
-
if (
|
|
527
|
-
this.isCurrentSubscription(
|
|
528
|
-
queryId,
|
|
529
|
-
subscription,
|
|
530
|
-
connectionId,
|
|
531
|
-
)
|
|
532
|
-
) {
|
|
533
|
-
this.sendSubscribe(queryId, subscription, attempt + 1);
|
|
534
|
-
}
|
|
535
|
-
},
|
|
536
|
-
retryDelayMs * (attempt + 1),
|
|
537
|
-
);
|
|
538
|
-
} else {
|
|
539
|
-
console.error(
|
|
540
|
-
'SSE hub: subscribe POST failed after retries; reconnecting',
|
|
541
|
-
queryId,
|
|
542
|
-
attempt + 1,
|
|
543
|
-
error,
|
|
544
|
-
);
|
|
545
|
-
this.reconnect();
|
|
546
|
-
}
|
|
501
|
+
this.handleSubscribeFailure(target, console.error, undefined, error);
|
|
547
502
|
});
|
|
548
503
|
}
|
|
549
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Decide what to do after a failed subscribe POST: ignore it when obsolete, retry with back-off,
|
|
507
|
+
* or fall back to a full reconnect once the retries are exhausted.
|
|
508
|
+
*
|
|
509
|
+
* The retry limit exists because in a round-robin load-balanced deployment the subscribe POST may
|
|
510
|
+
* land on a different backend instance than the one holding the SSE connection. Retrying gives
|
|
511
|
+
* the load balancer the chance to route a subsequent attempt to the correct instance without
|
|
512
|
+
* tearing down the SSE connection unnecessarily. With N backend instances at most N-1 retries
|
|
513
|
+
* are needed, so 3 retries covers deployments with up to 4 replicas.
|
|
514
|
+
* @param {SubscribeTarget} target The subscribe attempt that failed.
|
|
515
|
+
* @param {Function} exhaustedLog The logger used when the retries are exhausted.
|
|
516
|
+
* @param {number | undefined} status The HTTP status, when the server responded.
|
|
517
|
+
* @param {unknown} error The thrown error, when the request itself failed.
|
|
518
|
+
*/
|
|
519
|
+
private handleSubscribeFailure(
|
|
520
|
+
target: SubscribeTarget,
|
|
521
|
+
exhaustedLog: (...data: unknown[]) => void,
|
|
522
|
+
status?: number,
|
|
523
|
+
error?: unknown,
|
|
524
|
+
): void {
|
|
525
|
+
const { queryId, subscription, connectionId, attempt } = target;
|
|
526
|
+
const detail = error === undefined ? [status] : [error];
|
|
527
|
+
|
|
528
|
+
if (!this.isCurrentSubscription(queryId, subscription, connectionId)) {
|
|
529
|
+
const reason = error === undefined ? 'returned a failure' : 'failed';
|
|
530
|
+
console.warn(
|
|
531
|
+
`SSE hub: subscribe POST ${reason} for an obsolete connection or subscription; ignoring`,
|
|
532
|
+
queryId,
|
|
533
|
+
...detail,
|
|
534
|
+
);
|
|
535
|
+
} else if (attempt < SUBSCRIBE_MAX_RETRIES) {
|
|
536
|
+
this.scheduleSubscribeRetry(target);
|
|
537
|
+
} else {
|
|
538
|
+
const tail = error === undefined ? [status, attempt + 1] : [attempt + 1, error];
|
|
539
|
+
exhaustedLog(
|
|
540
|
+
'SSE hub: subscribe POST failed after retries; reconnecting',
|
|
541
|
+
queryId,
|
|
542
|
+
...tail,
|
|
543
|
+
);
|
|
544
|
+
this.reconnect();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private scheduleSubscribeRetry(target: SubscribeTarget): void {
|
|
549
|
+
const { queryId, subscription, connectionId, attempt } = target;
|
|
550
|
+
setTimeout(() => {
|
|
551
|
+
if (this.isCurrentSubscription(queryId, subscription, connectionId)) {
|
|
552
|
+
this.sendSubscribe(queryId, subscription, attempt + 1);
|
|
553
|
+
}
|
|
554
|
+
}, SUBSCRIBE_RETRY_DELAY_MS * (attempt + 1));
|
|
555
|
+
}
|
|
556
|
+
|
|
550
557
|
private isMessageForCurrentSubscription(message: HubMessage): boolean {
|
|
551
558
|
if (!message.queryId) return false;
|
|
552
559
|
|
|
@@ -167,7 +167,7 @@ export class WebSocketHubConnection {
|
|
|
167
167
|
this._subscriptions.set(queryId, subscription);
|
|
168
168
|
this.ensureConnected();
|
|
169
169
|
|
|
170
|
-
if (this.
|
|
170
|
+
if (this.isOpenAndReleased()) {
|
|
171
171
|
this.sendSubscribeMessage(queryId, subscription);
|
|
172
172
|
}
|
|
173
173
|
// Until the server has said what it supports, subscriptions are held back and sent once, by releaseSubscriptions.
|
|
@@ -181,7 +181,7 @@ export class WebSocketHubConnection {
|
|
|
181
181
|
const subscription = this._subscriptions.get(queryId);
|
|
182
182
|
this._subscriptions.delete(queryId);
|
|
183
183
|
|
|
184
|
-
if (this.
|
|
184
|
+
if (this.isOpenAndReleased() && subscription) {
|
|
185
185
|
const message: HubMessage = {
|
|
186
186
|
type: HubMessageType.Unsubscribe,
|
|
187
187
|
queryId,
|
|
@@ -212,11 +212,7 @@ export class WebSocketHubConnection {
|
|
|
212
212
|
this._disconnected = false;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
if (
|
|
216
|
-
this._socket &&
|
|
217
|
-
(this._socket.readyState === WebSocket.OPEN ||
|
|
218
|
-
this._socket.readyState === WebSocket.CONNECTING)
|
|
219
|
-
) {
|
|
215
|
+
if (this.isSocketLive(this._socket)) {
|
|
220
216
|
this.cancelPendingReconnect();
|
|
221
217
|
return;
|
|
222
218
|
}
|
|
@@ -243,11 +239,7 @@ export class WebSocketHubConnection {
|
|
|
243
239
|
|
|
244
240
|
private openSocket(): void {
|
|
245
241
|
const currentSocket = this._socket;
|
|
246
|
-
if (
|
|
247
|
-
currentSocket &&
|
|
248
|
-
(currentSocket.readyState === WebSocket.OPEN ||
|
|
249
|
-
currentSocket.readyState === WebSocket.CONNECTING)
|
|
250
|
-
) {
|
|
242
|
+
if (this.isSocketLive(currentSocket)) {
|
|
251
243
|
return;
|
|
252
244
|
}
|
|
253
245
|
|
|
@@ -259,67 +251,94 @@ export class WebSocketHubConnection {
|
|
|
259
251
|
this._supportsSubscriptionRevisions = false;
|
|
260
252
|
this.resetSubscriptionRelease();
|
|
261
253
|
|
|
262
|
-
|
|
263
|
-
if (this._microservice?.length > 0) {
|
|
264
|
-
const param = `${Globals.microserviceWSQueryArgument}=${encodeURIComponent(this._microservice)}`;
|
|
265
|
-
url += (url.includes('?') ? '&' : '?') + param;
|
|
266
|
-
}
|
|
267
|
-
|
|
254
|
+
const url = this.buildSocketUrl();
|
|
268
255
|
const socket = new WebSocket(url);
|
|
269
256
|
this._socket = socket;
|
|
270
257
|
|
|
271
258
|
socket.onopen = () => {
|
|
272
|
-
if (this.
|
|
273
|
-
|
|
274
|
-
this.cancelPendingReconnect();
|
|
275
|
-
this._policy.reset();
|
|
276
|
-
this._keepAlive.start();
|
|
277
|
-
|
|
278
|
-
// A capable server announces itself with a Connected message straight after accepting the socket. Sending
|
|
279
|
-
// the subscriptions before that would mean sending them twice (once without a revision, once with), and the
|
|
280
|
-
// server would run every query twice and cancel the first run. Wait for it, and only fall back to the
|
|
281
|
-
// revision-less form for a server that never says anything.
|
|
282
|
-
this._releaseTimer = setTimeout(() => {
|
|
283
|
-
this._releaseTimer = undefined;
|
|
284
|
-
if (this._socket === socket && !this._disconnected) {
|
|
285
|
-
this.releaseSubscriptions();
|
|
286
|
-
}
|
|
287
|
-
}, WebSocketHubConnection.ConnectedGraceMs);
|
|
259
|
+
if (!this.isActiveSocket(socket)) return;
|
|
260
|
+
this.handleSocketOpened(socket, url);
|
|
288
261
|
};
|
|
289
262
|
|
|
290
263
|
socket.onclose = () => {
|
|
291
|
-
if (this.
|
|
292
|
-
|
|
293
|
-
this._socket = undefined;
|
|
294
|
-
this._supportsSubscriptionRevisions = false;
|
|
295
|
-
this.resetSubscriptionRelease();
|
|
296
|
-
this._keepAlive.stop();
|
|
297
|
-
if (this._subscriptions.size === 0) return;
|
|
298
|
-
this._reconnectPending = true;
|
|
299
|
-
const scheduled = this._policy.schedule(() => {
|
|
300
|
-
this._reconnectPending = false;
|
|
301
|
-
if (!this._disconnected && this._subscriptions.size > 0) {
|
|
302
|
-
this.ensureConnected();
|
|
303
|
-
}
|
|
304
|
-
}, this._url);
|
|
305
|
-
if (!scheduled) {
|
|
306
|
-
this._reconnectPending = false;
|
|
307
|
-
}
|
|
264
|
+
if (!this.isActiveSocket(socket)) return;
|
|
265
|
+
this.handleSocketClosed(url);
|
|
308
266
|
};
|
|
309
267
|
|
|
310
268
|
socket.onerror = (error) => {
|
|
311
|
-
if (this.
|
|
269
|
+
if (!this.isActiveSocket(socket)) return;
|
|
312
270
|
console.error('Hub connection error', url, error);
|
|
313
271
|
this._keepAlive.stop();
|
|
314
272
|
// onclose will fire after onerror, triggering reconnect
|
|
315
273
|
};
|
|
316
274
|
|
|
317
275
|
socket.onmessage = (ev) => {
|
|
318
|
-
if (this.
|
|
276
|
+
if (!this.isActiveSocket(socket)) return;
|
|
319
277
|
this.handleMessage(ev.data as string);
|
|
320
278
|
};
|
|
321
279
|
}
|
|
322
280
|
|
|
281
|
+
private buildSocketUrl(): string {
|
|
282
|
+
let url = this._url;
|
|
283
|
+
if (this._microservice?.length > 0) {
|
|
284
|
+
const param = `${Globals.microserviceWSQueryArgument}=${encodeURIComponent(this._microservice)}`;
|
|
285
|
+
url += (url.includes('?') ? '&' : '?') + param;
|
|
286
|
+
}
|
|
287
|
+
return url;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private isSocketLive(socket: WebSocket | undefined): boolean {
|
|
291
|
+
return (
|
|
292
|
+
socket !== undefined &&
|
|
293
|
+
(socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private isActiveSocket(socket: WebSocket): boolean {
|
|
298
|
+
return this._socket === socket && !this._disconnected;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private isOpenAndReleased(): boolean {
|
|
302
|
+
return this._socket?.readyState === WebSocket.OPEN && this._subscriptionsReleased;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private handleSocketOpened(socket: WebSocket, url: string): void {
|
|
306
|
+
console.log('Hub connection established', url);
|
|
307
|
+
this.cancelPendingReconnect();
|
|
308
|
+
this._policy.reset();
|
|
309
|
+
this._keepAlive.start();
|
|
310
|
+
|
|
311
|
+
// A capable server announces itself with a Connected message straight after accepting the socket. Sending
|
|
312
|
+
// the subscriptions before that would mean sending them twice (once without a revision, once with), and the
|
|
313
|
+
// server would run every query twice and cancel the first run. Wait for it, and only fall back to the
|
|
314
|
+
// revision-less form for a server that never says anything.
|
|
315
|
+
this._releaseTimer = setTimeout(() => {
|
|
316
|
+
this._releaseTimer = undefined;
|
|
317
|
+
if (this.isActiveSocket(socket)) {
|
|
318
|
+
this.releaseSubscriptions();
|
|
319
|
+
}
|
|
320
|
+
}, WebSocketHubConnection.ConnectedGraceMs);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private handleSocketClosed(url: string): void {
|
|
324
|
+
console.log('Hub connection closed', url);
|
|
325
|
+
this._socket = undefined;
|
|
326
|
+
this._supportsSubscriptionRevisions = false;
|
|
327
|
+
this.resetSubscriptionRelease();
|
|
328
|
+
this._keepAlive.stop();
|
|
329
|
+
if (this._subscriptions.size === 0) return;
|
|
330
|
+
this._reconnectPending = true;
|
|
331
|
+
const scheduled = this._policy.schedule(() => {
|
|
332
|
+
this._reconnectPending = false;
|
|
333
|
+
if (!this._disconnected && this._subscriptions.size > 0) {
|
|
334
|
+
this.ensureConnected();
|
|
335
|
+
}
|
|
336
|
+
}, this._url);
|
|
337
|
+
if (!scheduled) {
|
|
338
|
+
this._reconnectPending = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
323
342
|
private cancelPendingReconnect(): void {
|
|
324
343
|
if (!this._reconnectPending) return;
|
|
325
344
|
|