@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.
- package/dist/cjs/queries/ObservableQueryConnection.js +24 -4
- package/dist/cjs/queries/ObservableQueryConnection.js.map +1 -1
- package/dist/cjs/queries/ServerSentEventQueryConnection.js +24 -2
- package/dist/cjs/queries/ServerSentEventQueryConnection.js.map +1 -1
- package/dist/esm/queries/ObservableQueryConnection.d.ts +2 -0
- package/dist/esm/queries/ObservableQueryConnection.d.ts.map +1 -1
- package/dist/esm/queries/ObservableQueryConnection.js +24 -4
- package/dist/esm/queries/ObservableQueryConnection.js.map +1 -1
- package/dist/esm/queries/ServerSentEventQueryConnection.d.ts +2 -0
- package/dist/esm/queries/ServerSentEventQueryConnection.d.ts.map +1 -1
- package/dist/esm/queries/ServerSentEventQueryConnection.js +24 -2
- package/dist/esm/queries/ServerSentEventQueryConnection.js.map +1 -1
- package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.d.ts +25 -0
- package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.d.ts.map +1 -0
- package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.js +45 -0
- package/dist/esm/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.js.map +1 -0
- package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts +2 -0
- package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts.map +1 -0
- package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js +44 -0
- package/dist/esm/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js.map +1 -0
- package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts +2 -0
- package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.d.ts.map +1 -0
- package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js +43 -0
- package/dist/esm/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.js.map +1 -0
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/queries/ObservableQueryConnection.ts +28 -4
- package/queries/ServerSentEventQueryConnection.ts +26 -2
- package/queries/for_ObservableQueryConnection/given/an_observable_query_connection_with_a_reconnect_policy.ts +72 -0
- package/queries/for_ObservableQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.ts +57 -0
- package/queries/for_ServerSentEventQueryConnection/when_receiving_unauthorized/terminates_without_reconnecting.ts +63 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|