@cratis/arc 22.185.1 → 22.186.1

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.
Files changed (47) hide show
  1. package/commands/Command.ts +13 -20
  2. package/dist/cjs/commands/Command.d.ts +1 -0
  3. package/dist/cjs/commands/Command.d.ts.map +1 -1
  4. package/dist/cjs/commands/Command.js +15 -16
  5. package/dist/cjs/commands/Command.js.map +1 -1
  6. package/dist/cjs/queries/ObservableQueryFor.d.ts +2 -0
  7. package/dist/cjs/queries/ObservableQueryFor.d.ts.map +1 -1
  8. package/dist/cjs/queries/ObservableQueryFor.js +34 -27
  9. package/dist/cjs/queries/ObservableQueryFor.js.map +1 -1
  10. package/dist/cjs/queries/QueryInstanceCache.d.ts +1 -0
  11. package/dist/cjs/queries/QueryInstanceCache.d.ts.map +1 -1
  12. package/dist/cjs/queries/QueryInstanceCache.js +11 -16
  13. package/dist/cjs/queries/QueryInstanceCache.js.map +1 -1
  14. package/dist/cjs/queries/ServerSentEventHubConnection.d.ts +7 -0
  15. package/dist/cjs/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  16. package/dist/cjs/queries/ServerSentEventHubConnection.js +97 -57
  17. package/dist/cjs/queries/ServerSentEventHubConnection.js.map +1 -1
  18. package/dist/cjs/queries/WebSocketHubConnection.d.ts +6 -0
  19. package/dist/cjs/queries/WebSocketHubConnection.d.ts.map +1 -1
  20. package/dist/cjs/queries/WebSocketHubConnection.js +62 -43
  21. package/dist/cjs/queries/WebSocketHubConnection.js.map +1 -1
  22. package/dist/esm/commands/Command.d.ts +1 -0
  23. package/dist/esm/commands/Command.d.ts.map +1 -1
  24. package/dist/esm/commands/Command.js +15 -16
  25. package/dist/esm/commands/Command.js.map +1 -1
  26. package/dist/esm/queries/ObservableQueryFor.d.ts +2 -0
  27. package/dist/esm/queries/ObservableQueryFor.d.ts.map +1 -1
  28. package/dist/esm/queries/ObservableQueryFor.js +35 -28
  29. package/dist/esm/queries/ObservableQueryFor.js.map +1 -1
  30. package/dist/esm/queries/QueryInstanceCache.d.ts +1 -0
  31. package/dist/esm/queries/QueryInstanceCache.d.ts.map +1 -1
  32. package/dist/esm/queries/QueryInstanceCache.js +11 -16
  33. package/dist/esm/queries/QueryInstanceCache.js.map +1 -1
  34. package/dist/esm/queries/ServerSentEventHubConnection.d.ts +7 -0
  35. package/dist/esm/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  36. package/dist/esm/queries/ServerSentEventHubConnection.js +97 -57
  37. package/dist/esm/queries/ServerSentEventHubConnection.js.map +1 -1
  38. package/dist/esm/queries/WebSocketHubConnection.d.ts +6 -0
  39. package/dist/esm/queries/WebSocketHubConnection.d.ts.map +1 -1
  40. package/dist/esm/queries/WebSocketHubConnection.js +62 -43
  41. package/dist/esm/queries/WebSocketHubConnection.js.map +1 -1
  42. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/queries/ObservableQueryFor.ts +43 -32
  45. package/queries/QueryInstanceCache.ts +12 -18
  46. package/queries/ServerSentEventHubConnection.ts +120 -113
  47. 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.185.1",
4
+ "version": "22.186.1",
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
- const clientValidationErrors =
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
- if (entry.pendingCleanup !== undefined) {
382
- clearTimeout(entry.pendingCleanup);
383
- entry.pendingCleanup = undefined;
384
- }
381
+ this.teardownEntry(entry);
382
+ }
383
+ }
385
384
 
386
- entry.subscribed = false;
387
- entry.teardown?.();
388
- entry.teardown = undefined;
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
- for (const [, entry] of this._entries) {
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
- let url = this._sseUrl;
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._disconnected || this._eventSource !== eventSource) return;
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._disconnected || this._eventSource !== eventSource) return;
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._disconnected || this._eventSource !== eventSource) return;
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
- if (this.isMessageForCurrentSubscription(message)) {
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
- if (this.isMessageForCurrentSubscription(message)) {
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
- if (
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
- if (!this.isCurrentSubscription(queryId, subscription, connectionId)) {
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._socket?.readyState === WebSocket.OPEN && this._subscriptionsReleased) {
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._socket?.readyState === WebSocket.OPEN && this._subscriptionsReleased && subscription) {
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
- let url = this._url;
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._socket !== socket || this._disconnected) return;
273
- console.log('Hub connection established', url);
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._socket !== socket || this._disconnected) return;
292
- console.log('Hub connection closed', url);
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._socket !== socket || this._disconnected) return;
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._socket !== socket || this._disconnected) return;
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