@cratis/arc 20.1.4 → 20.1.6

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 (49) hide show
  1. package/dist/cjs/queries/HubConnectionKeepAlive.d.ts +2 -1
  2. package/dist/cjs/queries/HubConnectionKeepAlive.d.ts.map +1 -1
  3. package/dist/cjs/queries/HubConnectionKeepAlive.js +4 -2
  4. package/dist/cjs/queries/HubConnectionKeepAlive.js.map +1 -1
  5. package/dist/cjs/queries/ReconnectPolicy.d.ts.map +1 -1
  6. package/dist/cjs/queries/ReconnectPolicy.js +1 -0
  7. package/dist/cjs/queries/ReconnectPolicy.js.map +1 -1
  8. package/dist/cjs/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  9. package/dist/cjs/queries/ServerSentEventHubConnection.js +10 -3
  10. package/dist/cjs/queries/ServerSentEventHubConnection.js.map +1 -1
  11. package/dist/cjs/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.d.ts +2 -0
  12. package/dist/cjs/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.d.ts.map +1 -0
  13. package/dist/cjs/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.d.ts +2 -0
  14. package/dist/cjs/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.d.ts.map +1 -0
  15. package/dist/cjs/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.d.ts +2 -0
  16. package/dist/cjs/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.d.ts.map +1 -0
  17. package/dist/esm/queries/HubConnectionKeepAlive.d.ts +2 -1
  18. package/dist/esm/queries/HubConnectionKeepAlive.d.ts.map +1 -1
  19. package/dist/esm/queries/HubConnectionKeepAlive.js +4 -2
  20. package/dist/esm/queries/HubConnectionKeepAlive.js.map +1 -1
  21. package/dist/esm/queries/ReconnectPolicy.d.ts.map +1 -1
  22. package/dist/esm/queries/ReconnectPolicy.js +1 -0
  23. package/dist/esm/queries/ReconnectPolicy.js.map +1 -1
  24. package/dist/esm/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  25. package/dist/esm/queries/ServerSentEventHubConnection.js +10 -3
  26. package/dist/esm/queries/ServerSentEventHubConnection.js.map +1 -1
  27. package/dist/esm/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.d.ts +2 -0
  28. package/dist/esm/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.d.ts.map +1 -0
  29. package/dist/esm/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.js +33 -0
  30. package/dist/esm/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.js.map +1 -0
  31. package/dist/esm/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.d.ts +2 -0
  32. package/dist/esm/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.d.ts.map +1 -0
  33. package/dist/esm/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.js +24 -0
  34. package/dist/esm/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.js.map +1 -0
  35. package/dist/esm/queries/for_ServerSentEventHubConnection/when_keep_alive_is_idle/triggers_reconnect.js +1 -1
  36. package/dist/esm/queries/for_ServerSentEventHubConnection/when_keep_alive_is_idle/triggers_reconnect.js.map +1 -1
  37. package/dist/esm/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.d.ts +2 -0
  38. package/dist/esm/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.d.ts.map +1 -0
  39. package/dist/esm/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.js +33 -0
  40. package/dist/esm/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.js.map +1 -0
  41. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +1 -1
  43. package/queries/HubConnectionKeepAlive.ts +20 -6
  44. package/queries/ReconnectPolicy.ts +4 -0
  45. package/queries/ServerSentEventHubConnection.ts +17 -4
  46. package/queries/for_HubConnectionKeepAlive/when_started_with_custom_idle_threshold/respects_threshold.ts +46 -0
  47. package/queries/for_ReconnectPolicy/when_scheduling/and_rescheduled_before_first_fires/cancels_previous_timer.ts +36 -0
  48. package/queries/for_ServerSentEventHubConnection/when_keep_alive_is_idle/triggers_reconnect.ts +5 -2
  49. package/queries/for_ServerSentEventHubConnection/when_subscribing/and_subscribe_post_fails.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/arc",
3
- "version": "20.1.4",
3
+ "version": "20.1.6",
4
4
  "description": "",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -5,9 +5,15 @@
5
5
  * Manages keep-alive behavior for hub connections (both WebSocket and Server-Sent Events).
6
6
  *
7
7
  * Records connection activity (any message received or sent). An interval fires every
8
- * {@link intervalMs} milliseconds; if no activity has been recorded since the last tick the
9
- * provided {@link onIdle} callback is invoked — the caller decides whether to send a ping,
10
- * trigger a reconnect, or take some other action.
8
+ * {@link intervalMs} milliseconds; if no activity has been recorded since the last
9
+ * {@link idleThresholdMs} milliseconds the provided {@link onIdle} callback is invoked —
10
+ * the caller decides whether to send a ping, trigger a reconnect, or take some other action.
11
+ *
12
+ * The idle threshold defaults to the check interval but can be set higher to tolerate
13
+ * network latency between the server's keep-alive ping and the client's idle check.
14
+ * For SSE connections, where the idle callback triggers a reconnect, a tolerance of
15
+ * 1.5× the server's keep-alive interval prevents spurious reconnects caused by the
16
+ * client's timer firing just before the server's ping arrives.
11
17
  *
12
18
  * Both {@link WebSocketHubConnection} and {@link ServerSentEventHubConnection} own one instance
13
19
  * of this class so the keep-alive logic is written once and behaves identically for both
@@ -16,17 +22,25 @@
16
22
  export class HubConnectionKeepAlive {
17
23
  private _lastActivityTime = Date.now();
18
24
  private _timer?: ReturnType<typeof setInterval>;
25
+ private readonly _idleThresholdMs: number;
19
26
 
20
27
  /**
21
28
  * Initializes a new instance of {@link HubConnectionKeepAlive}.
22
29
  * @param {number} intervalMs How often (in milliseconds) to check for idle connections.
23
30
  * @param {() => void} onIdle Callback invoked when the interval fires and no activity has
24
- * been recorded in the last {@link intervalMs} milliseconds.
31
+ * been recorded within the idle threshold.
32
+ * @param {number} idleThresholdMs Optional. How long (in milliseconds) without activity
33
+ * before the connection is considered idle. Defaults to {@link intervalMs}. Set this
34
+ * higher than {@link intervalMs} when the peer sends keep-alive messages on a similar
35
+ * cadence to account for network latency and timer jitter.
25
36
  */
26
37
  constructor(
27
38
  private readonly _intervalMs: number,
28
39
  private readonly _onIdle: () => void,
29
- ) {}
40
+ idleThresholdMs?: number,
41
+ ) {
42
+ this._idleThresholdMs = idleThresholdMs ?? _intervalMs;
43
+ }
30
44
 
31
45
  /**
32
46
  * Start the keep-alive timer. Safe to call multiple times — a running timer is stopped first.
@@ -35,7 +49,7 @@ export class HubConnectionKeepAlive {
35
49
  this.stop();
36
50
  this._lastActivityTime = Date.now();
37
51
  this._timer = setInterval(() => {
38
- if (Date.now() - this._lastActivityTime >= this._intervalMs) {
52
+ if (Date.now() - this._lastActivityTime >= this._idleThresholdMs) {
39
53
  this._onIdle();
40
54
  }
41
55
  }, this._intervalMs);
@@ -45,6 +45,10 @@ export class ReconnectPolicy implements IReconnectPolicy {
45
45
  return false;
46
46
  }
47
47
 
48
+ // Cancel any pending reconnect timer so we don't fire multiple
49
+ // concurrent reconnect attempts when schedule() is called rapidly.
50
+ this.cancel();
51
+
48
52
  this._attempt++;
49
53
  const delay = Math.min(this._initialDelayMs + this._delayStepMs * this._attempt, this._maxDelayMs);
50
54
  console.log(`Reconnect: attempt ${this._attempt} in ${delay}ms for '${label}'`);
@@ -62,13 +62,20 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
62
62
  ) {
63
63
  // SSE is server→client only: the client cannot send pings. Instead we watch for
64
64
  // inactivity — if the server stops sending messages (including its own keep-alive
65
- // pings) for the entire interval, the connection is stale and we reconnect.
65
+ // pings) for the entire idle threshold, the connection is stale and we reconnect.
66
+ //
67
+ // The idle threshold is set to 1.5× the check interval so the server's keep-alive
68
+ // ping (which fires on the same cadence) has time to arrive over the network before
69
+ // the client declares the connection dead. Without this tolerance the client's timer
70
+ // and the server's timer race — the client often fires first and reconnects
71
+ // unnecessarily.
72
+ const idleThresholdMs = Math.round(keepAliveIntervalMs * 1.5);
66
73
  this._keepAlive = new HubConnectionKeepAlive(keepAliveIntervalMs, () => {
67
74
  if (!this._disconnected && this._subscriptions.size > 0) {
68
- console.warn(`SSE hub: no messages received for ${keepAliveIntervalMs}ms, reconnecting '${this._sseUrl}'`);
75
+ console.warn(`SSE hub: no messages received for ${idleThresholdMs}ms, reconnecting '${this._sseUrl}'`);
69
76
  this.reconnect();
70
77
  }
71
- });
78
+ }, idleThresholdMs);
72
79
  }
73
80
 
74
81
  /** @inheritdoc */
@@ -299,8 +306,14 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
299
306
  method: 'POST',
300
307
  headers: { 'Content-Type': 'application/json', ...customHeaders },
301
308
  body: JSON.stringify(body),
309
+ }).then(response => {
310
+ if (!response.ok) {
311
+ console.warn(`SSE hub: subscribe POST for '${queryId}' returned ${response.status}, reconnecting`);
312
+ this.reconnect();
313
+ }
302
314
  }).catch(error => {
303
- console.error(`SSE hub: subscribe POST failed for '${queryId}'`, error);
315
+ console.error(`SSE hub: subscribe POST failed for '${queryId}', reconnecting`, error);
316
+ this.reconnect();
304
317
  });
305
318
  }
306
319
 
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { HubConnectionKeepAlive } from '../../HubConnectionKeepAlive';
6
+
7
+ describe('when started with a custom idle threshold larger than the check interval', () => {
8
+ let clock: sinon.SinonFakeTimers;
9
+ let onIdle: sinon.SinonStub;
10
+ let keepAlive: HubConnectionKeepAlive;
11
+
12
+ const checkIntervalMs = 500;
13
+ const idleThresholdMs = 750;
14
+
15
+ beforeEach(() => {
16
+ clock = sinon.useFakeTimers();
17
+ onIdle = sinon.stub();
18
+ keepAlive = new HubConnectionKeepAlive(checkIntervalMs, onIdle, idleThresholdMs);
19
+ keepAlive.start();
20
+ });
21
+
22
+ afterEach(() => {
23
+ keepAlive.stop();
24
+ clock.restore();
25
+ sinon.restore();
26
+ });
27
+
28
+ describe('and the check interval elapses but idle threshold has not', () => {
29
+ beforeEach(() => {
30
+ // Advance past the check interval (500ms) but not the idle threshold (750ms).
31
+ clock.tick(checkIntervalMs + 1);
32
+ });
33
+
34
+ it('should not invoke the onIdle callback', () => onIdle.called.should.be.false);
35
+ });
36
+
37
+ describe('and the idle threshold elapses', () => {
38
+ beforeEach(() => {
39
+ // Advance past the idle threshold. The second interval tick at 1000ms
40
+ // sees 1000ms of inactivity which exceeds the 750ms threshold.
41
+ clock.tick(checkIntervalMs * 2 + 1);
42
+ });
43
+
44
+ it('should invoke the onIdle callback', () => onIdle.calledOnce.should.be.true);
45
+ });
46
+ });
@@ -0,0 +1,36 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { ReconnectPolicy } from '../../../ReconnectPolicy';
6
+
7
+ describe('when scheduling twice before the first timer fires', () => {
8
+ let policy: ReconnectPolicy;
9
+ let clock: sinon.SinonFakeTimers;
10
+ let firstCallback: sinon.SinonStub;
11
+ let secondCallback: sinon.SinonStub;
12
+
13
+ beforeEach(() => {
14
+ clock = sinon.useFakeTimers();
15
+ policy = new ReconnectPolicy(100, 500, 500, 10_000);
16
+ firstCallback = sinon.stub();
17
+ secondCallback = sinon.stub();
18
+
19
+ policy.schedule(firstCallback, 'first');
20
+ // Schedule again before the first timer fires.
21
+ policy.schedule(secondCallback, 'second');
22
+
23
+ // Advance past both potential delays:
24
+ // Attempt 1: min(500+500*1, 10_000) = 1000ms
25
+ // Attempt 2: min(500+500*2, 10_000) = 1500ms
26
+ clock.tick(2000);
27
+ });
28
+
29
+ afterEach(() => {
30
+ clock.restore();
31
+ sinon.restore();
32
+ });
33
+
34
+ it('should cancel the first timer so it never fires', () => firstCallback.called.should.be.false);
35
+ it('should fire the second timer', () => secondCallback.calledOnce.should.be.true);
36
+ });
@@ -33,8 +33,11 @@ describe('when keep-alive interval elapses without any server message', given(a_
33
33
  // Deliver Connected so the connection is fully established.
34
34
  context.simulateMessage({ type: HubMessageType.Connected, payload: 'conn-123' });
35
35
 
36
- // Advance past the keep-alive interval without any further messages.
37
- clock.tick(KEEP_ALIVE_MS + 1);
36
+ // Advance past the idle threshold (1.5× keep-alive interval) without any further messages.
37
+ // The check interval fires every KEEP_ALIVE_MS; the idle threshold is KEEP_ALIVE_MS * 1.5.
38
+ // After the first interval tick (500ms) the elapsed idle time (500ms) is below the
39
+ // threshold (750ms). At the second tick (1000ms) the elapsed time exceeds the threshold.
40
+ clock.tick(KEEP_ALIVE_MS * 2 + 1);
38
41
  });
39
42
 
40
43
  afterEach(() => {
@@ -0,0 +1,51 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { a_server_sent_event_hub_connection } from '../given/a_server_sent_event_hub_connection';
6
+ import { given } from '../../../given';
7
+ import { HubMessageType } from '../../WebSocketHubConnection';
8
+
9
+ describe('when subscribe POST returns a non-OK status', given(a_server_sent_event_hub_connection, context => {
10
+ beforeEach(async () => {
11
+ context.setup();
12
+
13
+ // Make the subscribe POST resolve with a 404 (connection not found on server).
14
+ context.fetchStub.resolves({ ok: false, status: 404 });
15
+
16
+ context.connection.subscribe('q1', { queryName: 'MyQuery' }, sinon.stub());
17
+ context.simulateOpen();
18
+ context.simulateMessage({ type: HubMessageType.Connected, payload: 'conn-abc' });
19
+
20
+ // Allow the fetch promise chain (.then) to execute.
21
+ await new Promise(resolve => setTimeout(resolve, 0));
22
+ });
23
+
24
+ afterEach(() => sinon.restore());
25
+
26
+ it('should schedule a reconnect via the policy', () => {
27
+ (context.policy.schedule as sinon.SinonStub).calledOnce.should.be.true;
28
+ });
29
+ }));
30
+
31
+ describe('when subscribe POST rejects with a network error', given(a_server_sent_event_hub_connection, context => {
32
+ beforeEach(async () => {
33
+ context.setup();
34
+
35
+ // Make the subscribe POST reject (network failure).
36
+ context.fetchStub.rejects(new Error('Network error'));
37
+
38
+ context.connection.subscribe('q1', { queryName: 'MyQuery' }, sinon.stub());
39
+ context.simulateOpen();
40
+ context.simulateMessage({ type: HubMessageType.Connected, payload: 'conn-abc' });
41
+
42
+ // Allow the fetch promise chain (.catch) to execute.
43
+ await new Promise(resolve => setTimeout(resolve, 0));
44
+ });
45
+
46
+ afterEach(() => sinon.restore());
47
+
48
+ it('should schedule a reconnect via the policy', () => {
49
+ (context.policy.schedule as sinon.SinonStub).calledOnce.should.be.true;
50
+ });
51
+ }));