@cratis/arc 21.2.0 → 21.3.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 (31) hide show
  1. package/dist/cjs/queries/ObservableQueryConnection.js +24 -4
  2. package/dist/cjs/queries/ObservableQueryConnection.js.map +1 -1
  3. package/dist/cjs/queries/ServerSentEventQueryConnection.js +24 -2
  4. package/dist/cjs/queries/ServerSentEventQueryConnection.js.map +1 -1
  5. package/dist/esm/queries/ObservableQueryConnection.d.ts +2 -0
  6. package/dist/esm/queries/ObservableQueryConnection.d.ts.map +1 -1
  7. package/dist/esm/queries/ObservableQueryConnection.js +24 -4
  8. package/dist/esm/queries/ObservableQueryConnection.js.map +1 -1
  9. package/dist/esm/queries/ServerSentEventQueryConnection.d.ts +2 -0
  10. package/dist/esm/queries/ServerSentEventQueryConnection.d.ts.map +1 -1
  11. package/dist/esm/queries/ServerSentEventQueryConnection.js +24 -2
  12. package/dist/esm/queries/ServerSentEventQueryConnection.js.map +1 -1
  13. package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.d.ts +25 -0
  14. package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.d.ts.map +1 -0
  15. package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.js +45 -0
  16. package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.js.map +1 -0
  17. package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts +2 -0
  18. package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts.map +1 -0
  19. package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js +44 -0
  20. package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js.map +1 -0
  21. package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts +2 -0
  22. package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts.map +1 -0
  23. package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js +43 -0
  24. package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js.map +1 -0
  25. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +1 -1
  27. package/queries/ObservableQueryConnection.ts +28 -4
  28. package/queries/ServerSentEventQueryConnection.ts +26 -2
  29. package/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.ts +72 -0
  30. package/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.ts +57 -0
  31. package/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.ts +63 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/arc",
3
- "version": "21.2.0",
3
+ "version": "21.3.1",
4
4
  "description": "",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@ export class ObservableQueryConnection<TDataType> implements IObservableQueryCon
17
17
 
18
18
  private _socket!: WebSocket;
19
19
  private _disconnected = false;
20
+ private _unauthorized = false;
20
21
  private _url: string;
21
22
  private _pingInterval?: ReturnType<typeof setInterval>;
22
23
  private _pingIntervalMs: number = 10000;
@@ -95,13 +96,13 @@ export class ObservableQueryConnection<TDataType> implements IObservableQueryCon
95
96
  this.startPinging();
96
97
  };
97
98
  this._socket.onclose = () => {
98
- if (this._disconnected) return;
99
+ if (this._disconnected || this._unauthorized) return;
99
100
  console.log(`Unexpected connection closed for route '${url}'`);
100
101
  this.stopPinging();
101
102
  this._reconnectPolicy.schedule(connectSocket, url);
102
103
  };
103
104
  this._socket.onerror = (error) => {
104
- if (this._disconnected) return;
105
+ if (this._disconnected || this._unauthorized) return;
105
106
  console.log(`Error with connection for '${url}' - ${error}`);
106
107
  this.stopPinging();
107
108
  this._reconnectPolicy.schedule(connectSocket, url);
@@ -115,7 +116,7 @@ export class ObservableQueryConnection<TDataType> implements IObservableQueryCon
115
116
  };
116
117
  };
117
118
 
118
- if (this._disconnected) return;
119
+ if (this._disconnected || this._unauthorized) return;
119
120
  connectSocket();
120
121
  }
121
122
 
@@ -172,13 +173,36 @@ export class ObservableQueryConnection<TDataType> implements IObservableQueryCon
172
173
  // For new-style messages (type === 'Data') the query result is wrapped in message.data.
173
174
  // For legacy/backward-compatible messages (no type) the entire message IS the query result.
174
175
  const data = message.type === WebSocketMessageType.Data ? message.data : message;
175
- dataReceived(data as QueryResult<TDataType>);
176
+ const result = data as QueryResult<TDataType>;
177
+
178
+ if (result?.isAuthorized === false) {
179
+ this.handleUnauthorized(result, dataReceived);
180
+ return;
181
+ }
182
+
183
+ dataReceived(result);
176
184
  }
177
185
  } catch (error) {
178
186
  console.error('Error parsing WebSocket message:', error);
179
187
  }
180
188
  }
181
189
 
190
+ /**
191
+ * Handles the terminal result the server sends when the caller is no longer authorized for the query it is
192
+ * subscribed to. The subscription is over for good, so the result is delivered and the automatic reconnect is
193
+ * suppressed — reconnecting would re-subscribe, be denied again, and loop.
194
+ * @param {QueryResult<TDataType>} result The unauthorized result to deliver.
195
+ * @param {DataReceived<TDataType>} dataReceived Callback that receives the result.
196
+ */
197
+ private handleUnauthorized(result: QueryResult<TDataType>, dataReceived: DataReceived<TDataType>) {
198
+ console.warn(`Connection for '${this._url}' was ended by the server - no longer authorized`);
199
+ this._unauthorized = true;
200
+ this.stopPinging();
201
+ this._reconnectPolicy.cancel();
202
+ dataReceived(result);
203
+ this._socket?.close();
204
+ }
205
+
182
206
  private handlePong(message: WebSocketMessage) {
183
207
  if (message.timestamp && this._lastPingSentTime) {
184
208
  const latency = Date.now() - message.timestamp;
@@ -23,6 +23,7 @@ export const SSE_HUB_ROUTE = '/.cratis/queries/sse';
23
23
  export class ServerSentEventQueryConnection<TDataType> implements IObservableQueryConnection<TDataType> {
24
24
  private _eventSource?: EventSource;
25
25
  private _disconnected = false;
26
+ private _unauthorized = false;
26
27
 
27
28
  /** @inheritdoc */
28
29
  readonly lastPingLatency: number = 0;
@@ -38,7 +39,7 @@ export class ServerSentEventQueryConnection<TDataType> implements IObservableQue
38
39
 
39
40
  /** @inheritdoc */
40
41
  connect(dataReceived: DataReceived<TDataType>, queryArguments?: object): void {
41
- if (this._disconnected) return;
42
+ if (this._disconnected || this._unauthorized) return;
42
43
 
43
44
  // Guard against environments where EventSource is not available (e.g. Node.js, SSR)
44
45
  // and no custom factory has been supplied to substitute it.
@@ -64,6 +65,10 @@ export class ServerSentEventQueryConnection<TDataType> implements IObservableQue
64
65
  if (this._disconnected) return;
65
66
  try {
66
67
  const result = JSON.parse(event.data as string) as QueryResult<TDataType>;
68
+ if ((result as unknown as { isAuthorized?: boolean })?.isAuthorized === false) {
69
+ this.handleUnauthorized(url, result, dataReceived);
70
+ return;
71
+ }
67
72
  dataReceived(result);
68
73
  } catch (error) {
69
74
  console.error('SSE: error parsing message', error);
@@ -71,7 +76,7 @@ export class ServerSentEventQueryConnection<TDataType> implements IObservableQue
71
76
  };
72
77
 
73
78
  this._eventSource.onerror = () => {
74
- if (this._disconnected) return;
79
+ if (this._disconnected || this._unauthorized) return;
75
80
  console.warn(`SSE: connection error for '${url}', EventSource will retry automatically.`);
76
81
  };
77
82
  }
@@ -83,4 +88,23 @@ export class ServerSentEventQueryConnection<TDataType> implements IObservableQue
83
88
  this._eventSource?.close();
84
89
  this._eventSource = undefined;
85
90
  }
91
+
92
+ /**
93
+ * Handles the terminal result the server sends when the caller is no longer authorized for the query it is
94
+ * subscribed to.
95
+ *
96
+ * Unlike a WebSocket, an `EventSource` re-establishes a stream the server ended - the server closing its response
97
+ * is precisely the signal the browser retries on, roughly every three seconds. Each retry re-runs the whole query
98
+ * pipeline server side just to be denied again, so the stream is only actually terminal if this client closes it.
99
+ * @param {string} url The stream url, for diagnostics.
100
+ * @param {QueryResult<TDataType>} result The unauthorized result to deliver.
101
+ * @param {DataReceived<TDataType>} dataReceived Callback that receives the result.
102
+ */
103
+ private handleUnauthorized(url: string, result: QueryResult<TDataType>, dataReceived: DataReceived<TDataType>): void {
104
+ console.warn(`SSE: stream for '${url}' was ended by the server - no longer authorized`);
105
+ this._unauthorized = true;
106
+ this._eventSource?.close();
107
+ this._eventSource = undefined;
108
+ dataReceived(result);
109
+ }
86
110
  }
@@ -0,0 +1,72 @@
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 { ObservableQueryConnection } from '../../ObservableQueryConnection';
6
+ import { IReconnectPolicy } from '../../IReconnectPolicy';
7
+
8
+ /* eslint-disable @typescript-eslint/no-explicit-any */
9
+
10
+ export type FakeWebSocketWithPolicy = {
11
+ onopen: (() => void) | null;
12
+ onclose: (() => void) | null;
13
+ onerror: ((error: unknown) => void) | null;
14
+ onmessage: ((event: { data: string }) => void) | null;
15
+ close: sinon.SinonStub;
16
+ send: sinon.SinonStub;
17
+ readyState: number;
18
+ };
19
+
20
+ export class an_observable_query_connection_with_a_reconnect_policy {
21
+ connection: ObservableQueryConnection<unknown>;
22
+ fakeWebSocket!: FakeWebSocketWithPolicy;
23
+ reconnectPolicy: IReconnectPolicy;
24
+ schedule: sinon.SinonStub;
25
+ cancel: sinon.SinonStub;
26
+
27
+ constructor() {
28
+ this.fakeWebSocket = {
29
+ onopen: null,
30
+ onclose: null,
31
+ onerror: null,
32
+ onmessage: null,
33
+ close: sinon.stub(),
34
+ send: sinon.stub(),
35
+ readyState: WebSocket.OPEN,
36
+ };
37
+
38
+ const fakeWebSocket = this.fakeWebSocket;
39
+ const FakeWebSocketClass = function (this: any) {
40
+ fakeWebSocket.onopen = null;
41
+ fakeWebSocket.onclose = null;
42
+ fakeWebSocket.onerror = null;
43
+ fakeWebSocket.onmessage = null;
44
+ return fakeWebSocket;
45
+ };
46
+ (globalThis as any)['WebSocket'] = FakeWebSocketClass;
47
+
48
+ this.schedule = sinon.stub().returns(true);
49
+ this.cancel = sinon.stub();
50
+ this.reconnectPolicy = {
51
+ attempt: 0,
52
+ schedule: this.schedule,
53
+ reset: sinon.stub(),
54
+ cancel: this.cancel,
55
+ } as unknown as IReconnectPolicy;
56
+
57
+ this.connection = new ObservableQueryConnection<unknown>(
58
+ new URL('https://example.com/api/test'),
59
+ 'test-microservice',
60
+ 10000,
61
+ this.reconnectPolicy
62
+ );
63
+ }
64
+
65
+ simulateMessage(payload: object): void {
66
+ this.fakeWebSocket.onmessage?.({ data: JSON.stringify(payload) });
67
+ }
68
+
69
+ simulateClose(): void {
70
+ this.fakeWebSocket.onclose?.();
71
+ }
72
+ }
@@ -0,0 +1,57 @@
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 { given } from '../../../given';
5
+ import { an_observable_query_connection_with_a_reconnect_policy } from '../given/an_observable_query_connection_with_a_reconnect_policy';
6
+ import { QueryResult } from '../../QueryResult';
7
+ import { WebSocketMessageType } from '../../WebSocketMessage';
8
+
9
+ describe('when receiving unauthorized it terminates without reconnecting', given(an_observable_query_connection_with_a_reconnect_policy, context => {
10
+ const unauthorizedResult = {
11
+ data: null,
12
+ isSuccess: false,
13
+ isAuthorized: false,
14
+ isValid: true,
15
+ hasExceptions: false,
16
+ validationResults: [],
17
+ exceptionMessages: [],
18
+ exceptionStackTrace: '',
19
+ paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }
20
+ };
21
+
22
+ let receivedResults: QueryResult<unknown>[] = [];
23
+
24
+ beforeEach(() => {
25
+ receivedResults = [];
26
+ context.connection.connect((data) => {
27
+ receivedResults.push(data as QueryResult<unknown>);
28
+ });
29
+ context.simulateMessage({
30
+ type: WebSocketMessageType.Data,
31
+ data: unauthorizedResult
32
+ });
33
+
34
+ // The server closes the stream right after the terminal result.
35
+ context.simulateClose();
36
+ });
37
+
38
+ it('should deliver the unauthorized result', () => {
39
+ receivedResults.should.have.lengthOf(1);
40
+ });
41
+
42
+ it('should deliver it as not authorized', () => {
43
+ (receivedResults[0] as unknown as { isAuthorized: boolean }).isAuthorized.should.be.false;
44
+ });
45
+
46
+ it('should not schedule a reconnect', () => {
47
+ context.schedule.called.should.be.false;
48
+ });
49
+
50
+ it('should cancel any pending reconnect', () => {
51
+ context.cancel.called.should.be.true;
52
+ });
53
+
54
+ it('should close the socket', () => {
55
+ context.fakeWebSocket.close.called.should.be.true;
56
+ });
57
+ }));
@@ -0,0 +1,63 @@
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 { ServerSentEventQueryConnection } from '../../ServerSentEventQueryConnection';
6
+ import { QueryResult } from '../../QueryResult';
7
+
8
+ /**
9
+ * Unlike a WebSocket, an EventSource treats the server ending its response as the signal to retry - roughly every
10
+ * three seconds, and each retry re-runs the entire query pipeline server side just to be denied again. The terminal
11
+ * unauthorized result is only terminal if this client closes the stream itself.
12
+ */
13
+ describe('when receiving unauthorized it terminates without reconnecting', () => {
14
+ const unauthorizedResult = {
15
+ data: null,
16
+ isSuccess: false,
17
+ isAuthorized: false,
18
+ isValid: true,
19
+ hasExceptions: false,
20
+ validationResults: [],
21
+ exceptionMessages: [],
22
+ exceptionStackTrace: '',
23
+ paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }
24
+ };
25
+
26
+ let fakeEventSource: Record<string, unknown>;
27
+ let closeStub: sinon.SinonStub;
28
+ let constructedCount: number;
29
+ let connection: ServerSentEventQueryConnection<string[]>;
30
+ let receivedResults: QueryResult<string[]>[];
31
+
32
+ beforeEach(() => {
33
+ closeStub = sinon.stub();
34
+ constructedCount = 0;
35
+ fakeEventSource = { onmessage: null, onerror: null, close: closeStub };
36
+
37
+ (globalThis as Record<string, unknown>)['EventSource'] = function () {
38
+ constructedCount++;
39
+ return fakeEventSource;
40
+ };
41
+
42
+ receivedResults = [];
43
+ connection = new ServerSentEventQueryConnection<string[]>(new URL('http://localhost/api/queries/latest'));
44
+ connection.connect((result: QueryResult<string[]>) => receivedResults.push(result));
45
+
46
+ (fakeEventSource['onmessage'] as (event: MessageEvent) => void)(
47
+ { data: JSON.stringify(unauthorizedResult) } as MessageEvent
48
+ );
49
+
50
+ // Anything that would normally bring the stream back up must now be refused.
51
+ connection.connect(() => { });
52
+ });
53
+
54
+ afterEach(() => {
55
+ delete (globalThis as Record<string, unknown>)['EventSource'];
56
+ sinon.restore();
57
+ });
58
+
59
+ it('should deliver the unauthorized result', () => receivedResults.length.should.equal(1));
60
+ it('should deliver it as not authorized', () => (receivedResults[0] as unknown as { isAuthorized: boolean }).isAuthorized.should.be.false);
61
+ it('should close the event source', () => closeStub.calledOnce.should.be.true);
62
+ it('should not re-establish the stream', () => constructedCount.should.equal(1));
63
+ });