@cratis/arc 22.138.1 → 22.138.3

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 (42) hide show
  1. package/dist/cjs/queries/WebSocketHubConnection.d.ts +5 -0
  2. package/dist/cjs/queries/WebSocketHubConnection.d.ts.map +1 -1
  3. package/dist/cjs/queries/WebSocketHubConnection.js +39 -11
  4. package/dist/cjs/queries/WebSocketHubConnection.js.map +1 -1
  5. package/dist/esm/queries/WebSocketHubConnection.d.ts +5 -0
  6. package/dist/esm/queries/WebSocketHubConnection.d.ts.map +1 -1
  7. package/dist/esm/queries/WebSocketHubConnection.js +39 -11
  8. package/dist/esm/queries/WebSocketHubConnection.js.map +1 -1
  9. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_server_validation_failing.js +1 -0
  10. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_server_validation_failing.js.map +1 -1
  11. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_web_socket_hub_payload_without_readiness.js +1 -0
  12. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_web_socket_hub_payload_without_readiness.js.map +1 -1
  13. package/dist/esm/queries/for_WebSocketHubConnection/given/a_web_socket_hub_connection.d.ts +2 -1
  14. package/dist/esm/queries/for_WebSocketHubConnection/given/a_web_socket_hub_connection.d.ts.map +1 -1
  15. package/dist/esm/queries/for_WebSocketHubConnection/given/a_web_socket_hub_connection.js +8 -1
  16. package/dist/esm/queries/for_WebSocketHubConnection/given/a_web_socket_hub_connection.js.map +1 -1
  17. package/dist/esm/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.js +2 -6
  18. package/dist/esm/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.js.map +1 -1
  19. package/dist/esm/queries/for_WebSocketHubConnection/when_reconnecting/resubscribes_all_queries.js +4 -17
  20. package/dist/esm/queries/for_WebSocketHubConnection/when_reconnecting/resubscribes_all_queries.js.map +1 -1
  21. package/dist/esm/queries/for_WebSocketHubConnection/when_replacing_a_subscription/ignores_stale_result_unauthorized_and_error.js +3 -7
  22. package/dist/esm/queries/for_WebSocketHubConnection/when_replacing_a_subscription/ignores_stale_result_unauthorized_and_error.js.map +1 -1
  23. package/dist/esm/queries/for_WebSocketHubConnection/when_subscribing/before_the_server_announces_itself.d.ts +2 -0
  24. package/dist/esm/queries/for_WebSocketHubConnection/when_subscribing/before_the_server_announces_itself.d.ts.map +1 -0
  25. package/dist/esm/queries/for_WebSocketHubConnection/when_subscribing/before_the_server_announces_itself.js +31 -0
  26. package/dist/esm/queries/for_WebSocketHubConnection/when_subscribing/before_the_server_announces_itself.js.map +1 -0
  27. package/dist/esm/queries/for_WebSocketHubConnection/when_subscription_revisions_are_supported/upgrades_active_subscriptions_to_strict_mode.js +7 -11
  28. package/dist/esm/queries/for_WebSocketHubConnection/when_subscription_revisions_are_supported/upgrades_active_subscriptions_to_strict_mode.js.map +1 -1
  29. package/dist/esm/queries/for_WebSocketHubConnection/when_unsubscribing/the_only_query.js +1 -5
  30. package/dist/esm/queries/for_WebSocketHubConnection/when_unsubscribing/the_only_query.js.map +1 -1
  31. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +1 -1
  33. package/queries/WebSocketHubConnection.ts +44 -15
  34. package/queries/for_ObservableQueryFor/when_subscribing/with_server_validation_failing.ts +2 -0
  35. package/queries/for_ObservableQueryFor/when_subscribing/with_web_socket_hub_payload_without_readiness.ts +2 -1
  36. package/queries/for_WebSocketHubConnection/given/a_web_socket_hub_connection.ts +14 -1
  37. package/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.ts +2 -6
  38. package/queries/for_WebSocketHubConnection/when_reconnecting/resubscribes_all_queries.ts +5 -19
  39. package/queries/for_WebSocketHubConnection/when_replacing_a_subscription/ignores_stale_result_unauthorized_and_error.ts +3 -7
  40. package/queries/for_WebSocketHubConnection/when_subscribing/before_the_server_announces_itself.ts +43 -0
  41. package/queries/for_WebSocketHubConnection/when_subscription_revisions_are_supported/upgrades_active_subscriptions_to_strict_mode.ts +7 -14
  42. package/queries/for_WebSocketHubConnection/when_unsubscribing/the_only_query.ts +1 -5
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@cratis/arc",
4
- "version": "22.138.1",
4
+ "version": "22.138.3",
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",
@@ -80,6 +80,9 @@ interface ActiveSubscription {
80
80
  * revision-aware operations when the server advertises support in a Connected message.
81
81
  */
82
82
  export class WebSocketHubConnection {
83
+ /** How long to wait for the server's Connected message before subscribing the way older servers expect. */
84
+ static readonly ConnectedGraceMs = 1000;
85
+
83
86
  private _socket?: WebSocket;
84
87
  private _disconnected = false;
85
88
  private _subscriptions: Map<string, ActiveSubscription> = new Map();
@@ -90,6 +93,8 @@ export class WebSocketHubConnection {
90
93
  private _nextRevision = 0;
91
94
  private _supportsSubscriptionRevisions = false;
92
95
  private _reconnectPending = false;
96
+ private _subscriptionsReleased = false;
97
+ private _releaseTimer?: ReturnType<typeof setTimeout>;
93
98
 
94
99
  /**
95
100
  * Initializes a new instance of {@link WebSocketHubConnection}.
@@ -162,10 +167,10 @@ export class WebSocketHubConnection {
162
167
  this._subscriptions.set(queryId, subscription);
163
168
  this.ensureConnected();
164
169
 
165
- if (this._socket?.readyState === WebSocket.OPEN) {
170
+ if (this._socket?.readyState === WebSocket.OPEN && this._subscriptionsReleased) {
166
171
  this.sendSubscribeMessage(queryId, subscription);
167
172
  }
168
- // If not yet open, sendAllSubscriptions will fire in onopen.
173
+ // Until the server has said what it supports, subscriptions are held back and sent once, by releaseSubscriptions.
169
174
  }
170
175
 
171
176
  /**
@@ -176,7 +181,7 @@ export class WebSocketHubConnection {
176
181
  const subscription = this._subscriptions.get(queryId);
177
182
  this._subscriptions.delete(queryId);
178
183
 
179
- if (this._socket?.readyState === WebSocket.OPEN && subscription) {
184
+ if (this._socket?.readyState === WebSocket.OPEN && this._subscriptionsReleased && subscription) {
180
185
  const message: HubMessage = {
181
186
  type: HubMessageType.Unsubscribe,
182
187
  queryId,
@@ -227,6 +232,7 @@ export class WebSocketHubConnection {
227
232
  this._keepAlive.stop();
228
233
  this._policy.cancel();
229
234
  this._reconnectPending = false;
235
+ this.resetSubscriptionRelease();
230
236
  const socket = this._socket;
231
237
  this._socket = undefined;
232
238
  this._supportsSubscriptionRevisions = false;
@@ -251,6 +257,7 @@ export class WebSocketHubConnection {
251
257
  }
252
258
 
253
259
  this._supportsSubscriptionRevisions = false;
260
+ this.resetSubscriptionRelease();
254
261
 
255
262
  let url = this._url;
256
263
  if (this._microservice?.length > 0) {
@@ -267,7 +274,17 @@ export class WebSocketHubConnection {
267
274
  this.cancelPendingReconnect();
268
275
  this._policy.reset();
269
276
  this._keepAlive.start();
270
- this.sendAllSubscriptions();
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);
271
288
  };
272
289
 
273
290
  socket.onclose = () => {
@@ -275,6 +292,7 @@ export class WebSocketHubConnection {
275
292
  console.log('Hub connection closed', url);
276
293
  this._socket = undefined;
277
294
  this._supportsSubscriptionRevisions = false;
295
+ this.resetSubscriptionRelease();
278
296
  this._keepAlive.stop();
279
297
  if (this._subscriptions.size === 0) return;
280
298
  this._reconnectPending = true;
@@ -317,6 +335,24 @@ export class WebSocketHubConnection {
317
335
  socket.close();
318
336
  }
319
337
 
338
+ private resetSubscriptionRelease(): void {
339
+ this._subscriptionsReleased = false;
340
+ if (this._releaseTimer !== undefined) {
341
+ clearTimeout(this._releaseTimer);
342
+ this._releaseTimer = undefined;
343
+ }
344
+ }
345
+
346
+ private releaseSubscriptions(): void {
347
+ if (this._subscriptionsReleased) return;
348
+ this._subscriptionsReleased = true;
349
+ if (this._releaseTimer !== undefined) {
350
+ clearTimeout(this._releaseTimer);
351
+ this._releaseTimer = undefined;
352
+ }
353
+ this.sendAllSubscriptions();
354
+ }
355
+
320
356
  private sendAllSubscriptions(): void {
321
357
  for (const [queryId, sub] of this._subscriptions) {
322
358
  this.sendSubscribeMessage(queryId, sub);
@@ -382,18 +418,11 @@ export class WebSocketHubConnection {
382
418
  }
383
419
 
384
420
  private handleConnected(message: HubMessage): void {
385
- if (
386
- message.supportsSubscriptionRevisions !== true ||
387
- this._supportsSubscriptionRevisions
388
- ) {
389
- return;
390
- }
421
+ if (this._subscriptionsReleased) return;
391
422
 
392
- this._supportsSubscriptionRevisions = true;
393
-
394
- // The legacy subscriptions sent on socket open keep this client compatible with older servers.
395
- // Ordered WebSocket delivery means these revision-aware replacements are processed afterwards.
396
- this.sendAllSubscriptions();
423
+ // Capability is known before anything is sent, so every subscription goes out exactly once, in its final form.
424
+ this._supportsSubscriptionRevisions = message.supportsSubscriptionRevisions === true;
425
+ this.releaseSubscriptions();
397
426
  }
398
427
 
399
428
  private handleQueryResult(message: HubMessage): void {
@@ -54,6 +54,8 @@ for (const direct of [true, false]) {
54
54
  stream.onmessage?.({ data: JSON.stringify({ type: 'Connected', payload: 'connection-1' }) });
55
55
  return JSON.parse(fetchStub.firstCall.args[1].body).queryId;
56
56
  }
57
+ // The server announces itself before any subscription is sent.
58
+ stream.onmessage?.({ data: JSON.stringify({ type: 'Connected', payload: 'connection-1' }) });
57
59
  return JSON.parse(stream.send.firstCall.args[0]).queryId;
58
60
  };
59
61
 
@@ -62,8 +62,9 @@ describe(
62
62
  socket.readyState = 1;
63
63
  socket.onopen?.();
64
64
 
65
- // An older Arc server sends no Connected capability message, so this fixture intentionally
65
+ // An older Arc server announces itself without the revision capability, so this fixture intentionally
66
66
  // remains in legacy mode and accepts only result frames without a revision.
67
+ socket.onmessage?.({ data: JSON.stringify({ type: HubMessageType.Connected, payload: 'connection-1' }) });
67
68
  let subscribeMessage: { queryId: string };
68
69
  try {
69
70
  subscribeMessage = JSON.parse(socket.send.firstCall.args[0]);
@@ -65,7 +65,20 @@ export class a_web_socket_hub_connection {
65
65
  );
66
66
  }
67
67
 
68
- simulateOpen(): void {
68
+ /**
69
+ * Opens the socket and has the server announce itself, as a real server does straight after accepting it.
70
+ * Subscriptions are only sent once that announcement arrives.
71
+ * @param supportsSubscriptionRevisions Whether the announcing server supports revision-aware subscriptions.
72
+ */
73
+ simulateOpen(supportsSubscriptionRevisions = false): void {
74
+ this.simulateOpenWithoutAnnouncement();
75
+ this.simulateMessage({
76
+ type: 'Connected',
77
+ ...(supportsSubscriptionRevisions ? { supportsSubscriptionRevisions: true } : {}),
78
+ });
79
+ }
80
+
81
+ simulateOpenWithoutAnnouncement(): void {
69
82
  this.fakeSocket.readyState = 1; // OPEN
70
83
  this.fakeSocket.onopen?.();
71
84
  }
@@ -19,11 +19,7 @@ describe(
19
19
  callback = sinon.stub();
20
20
 
21
21
  context.connection.subscribe(queryId, { queryName: 'SecureQuery' }, callback);
22
- context.simulateOpen();
23
- context.simulateMessage({
24
- type: HubMessageType.Connected,
25
- supportsSubscriptionRevisions: true,
26
- });
22
+ context.simulateOpen(true);
27
23
  const subscribe = getSubscribeMessage();
28
24
 
29
25
  // Server replies with Unauthorized for this exact subscription.
@@ -44,7 +40,7 @@ describe(
44
40
 
45
41
  function getSubscribeMessage(): { revision: number } {
46
42
  try {
47
- return JSON.parse(context.fakeSocket.send.getCall(1).args[0]) as {
43
+ return JSON.parse(context.fakeSocket.send.getCall(0).args[0]) as {
48
44
  revision: number;
49
45
  };
50
46
  } catch (error) {
@@ -17,11 +17,7 @@ describe(
17
17
  callbackStub = sinon.stub();
18
18
  context.setup();
19
19
  context.connection.subscribe(queryId, { queryName: 'MyQuery' }, callbackStub);
20
- context.simulateOpen();
21
- context.simulateMessage({
22
- type: HubMessageType.Connected,
23
- supportsSubscriptionRevisions: true,
24
- });
20
+ context.simulateOpen(true);
25
21
 
26
22
  // Simulate drop and policy-driven reconnect
27
23
  context.simulateClose();
@@ -32,27 +28,17 @@ describe(
32
28
  (context.policy.reset as sinon.SinonStub).reset();
33
29
  reconnectCallback();
34
30
 
35
- // The new socket opens in legacy mode, then upgrades when the new handshake arrives.
36
- context.simulateOpen();
37
- context.simulateMessage({
38
- type: HubMessageType.Connected,
39
- supportsSubscriptionRevisions: true,
40
- });
31
+ // The new socket announces itself again, and only then are the subscriptions sent.
32
+ context.simulateOpen(true);
41
33
  });
42
34
 
43
35
  afterEach(() => sinon.restore());
44
36
 
45
- it('should reset to a legacy subscription on the new socket', () => {
37
+ it('should resubscribe once, in revision-aware form, on the new socket', () => {
38
+ context.fakeSocket.send.calledOnce.should.equal(true);
46
39
  const msg = getSentMessage(0);
47
40
  msg.type.should.equal(HubMessageType.Subscribe);
48
41
  msg.queryId.should.equal(queryId);
49
- (msg.revision === undefined).should.equal(true);
50
- });
51
-
52
- it('should upgrade the reconnect subscription after the new handshake', () => {
53
- const msg = getSentMessage(1);
54
- msg.type.should.equal(HubMessageType.Subscribe);
55
- msg.queryId.should.equal(queryId);
56
42
  (msg.revision ?? 0).should.be.greaterThan(0);
57
43
  });
58
44
 
@@ -25,19 +25,15 @@ describe(
25
25
  { queryName: 'Original' },
26
26
  originalCallback,
27
27
  );
28
- context.simulateOpen();
29
- context.simulateMessage({
30
- type: HubMessageType.Connected,
31
- supportsSubscriptionRevisions: true,
32
- });
33
- const originalRevision = sentMessage(1).revision!;
28
+ context.simulateOpen(true);
29
+ const originalRevision = sentMessage(0).revision!;
34
30
 
35
31
  context.connection.subscribe(
36
32
  queryId,
37
33
  { queryName: 'Replacement' },
38
34
  replacementCallback,
39
35
  );
40
- const replacementRevision = sentMessage(2).revision!;
36
+ const replacementRevision = sentMessage(1).revision!;
41
37
 
42
38
  context.simulateMessage({
43
39
  type: HubMessageType.QueryResult,
@@ -0,0 +1,43 @@
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_web_socket_hub_connection } from '../given/a_web_socket_hub_connection.js';
6
+ import { given } from '../../../given.js';
7
+ import { HubMessageType, WebSocketHubConnection } from '../../WebSocketHubConnection.js';
8
+
9
+ describe(
10
+ 'when the socket opens but the server has not announced itself',
11
+ given(a_web_socket_hub_connection, (context) => {
12
+ let clock: sinon.SinonFakeTimers;
13
+
14
+ beforeEach(() => {
15
+ clock = sinon.useFakeTimers();
16
+ context.setup();
17
+ context.connection.subscribe('q1', { queryName: 'MyQuery' }, sinon.stub());
18
+ context.simulateOpenWithoutAnnouncement();
19
+ });
20
+
21
+ afterEach(() => {
22
+ clock.restore();
23
+ sinon.restore();
24
+ });
25
+
26
+ it('should hold the subscription back', () =>
27
+ context.fakeSocket.send.called.should.equal(false));
28
+
29
+ it('should send nothing more than a subscription without a revision once the grace period passes', () => {
30
+ clock.tick(WebSocketHubConnection.ConnectedGraceMs);
31
+ context.fakeSocket.send.calledOnce.should.equal(true);
32
+ const message = JSON.parse(context.fakeSocket.send.firstCall.args[0]);
33
+ message.type.should.equal(HubMessageType.Subscribe);
34
+ (message.revision === undefined).should.equal(true);
35
+ });
36
+
37
+ it('should not send it a second time when the announcement arrives late', () => {
38
+ clock.tick(WebSocketHubConnection.ConnectedGraceMs);
39
+ context.simulateMessage({ type: HubMessageType.Connected, supportsSubscriptionRevisions: true });
40
+ context.fakeSocket.send.calledOnce.should.equal(true);
41
+ });
42
+ }),
43
+ );
@@ -17,15 +17,10 @@ describe(
17
17
  context.setup();
18
18
  callback = sinon.stub();
19
19
  context.connection.subscribe(queryId, { queryName: 'MyQuery' }, callback);
20
- context.simulateOpen();
21
-
22
- context.simulateMessage({
23
- type: HubMessageType.Connected,
24
- supportsSubscriptionRevisions: true,
25
- });
26
- const upgradedRevision = sentMessage(1).revision;
20
+ context.simulateOpen(true);
21
+ const upgradedRevision = sentMessage(0).revision;
27
22
  if (upgradedRevision === undefined) {
28
- throw new Error('Expected the upgraded subscription to carry a revision');
23
+ throw new Error('Expected the subscription to carry a revision');
29
24
  }
30
25
  revision = upgradedRevision;
31
26
 
@@ -44,17 +39,15 @@ describe(
44
39
 
45
40
  afterEach(() => sinon.restore());
46
41
 
47
- it('should initially subscribe without a revision for older servers', () =>
48
- (sentMessage(0).revision === undefined).should.equal(true));
49
-
50
- it('should replace the legacy subscription with its current revision', () => {
51
- const message = sentMessage(1);
42
+ it('should send the subscription once, with its revision, and not before the server has announced itself', () => {
43
+ context.fakeSocket.send.calledOnce.should.equal(true);
44
+ const message = sentMessage(0);
52
45
  message.type.should.equal(HubMessageType.Subscribe);
53
46
  (message.queryId === queryId).should.equal(true);
54
47
  revision.should.be.greaterThan(0);
55
48
  });
56
49
 
57
- it('should ignore the temporary legacy result and deliver the strict result once', () => {
50
+ it('should ignore a result without a revision and deliver the strict result once', () => {
58
51
  callback.calledOnce.should.equal(true);
59
52
  callback.firstCall.args[0].should.deep.equal({ data: ['current'] });
60
53
  });
@@ -14,11 +14,7 @@ describe(
14
14
  beforeEach(() => {
15
15
  context.setup();
16
16
  context.connection.subscribe(queryId, { queryName: 'MyQuery' }, sinon.stub());
17
- context.simulateOpen();
18
- context.simulateMessage({
19
- type: HubMessageType.Connected,
20
- supportsSubscriptionRevisions: true,
21
- });
17
+ context.simulateOpen(true);
22
18
  context.fakeSocket.send.reset();
23
19
  context.connection.unsubscribe(queryId);
24
20
  });