@cratis/chronicle 1.0.2 → 2.1.0
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/ChronicleClient.ts +69 -60
- package/ChronicleOptions.ts +5 -6
- package/README.md +1 -1
- package/connection/ChronicleConnection.ts +37 -17
- package/connection/ChronicleConnectionString.spec.ts +227 -0
- package/connection/ChronicleConnectionString.ts +289 -35
- package/connection/ChronicleServerAddressResolver.spec.ts +41 -0
- package/connection/ChronicleServerAddressResolver.ts +33 -0
- package/connection/ChronicleSrvResolutionError.ts +13 -0
- package/connection/ChronicleSrvResolver.spec.ts +103 -0
- package/connection/ChronicleSrvResolver.ts +57 -0
- package/connection/ConnectionLifecycle.spec.ts +109 -0
- package/connection/ILoadBalancerStrategy.ts +16 -0
- package/connection/KernelKeepAlive.spec.ts +175 -0
- package/connection/KernelKeepAlive.ts +161 -0
- package/connection/LeastConnectionsLoadBalancerStrategy.spec.ts +159 -0
- package/connection/LeastConnectionsLoadBalancerStrategy.ts +117 -0
- package/connection/LoadBalancerMode.ts +25 -0
- package/connection/LoadBalancerStrategyFactory.ts +28 -0
- package/connection/RandomLoadBalancerStrategy.spec.ts +50 -0
- package/connection/RandomLoadBalancerStrategy.ts +19 -0
- package/connection/RoundRobinLoadBalancerStrategy.spec.ts +61 -0
- package/connection/RoundRobinLoadBalancerStrategy.ts +29 -0
- package/connection/TokenProvider.ts +8 -3
- package/connection/formatServerAddress.ts +16 -0
- package/dist/ChronicleClient.d.ts +8 -1
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +63 -56
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/ChronicleOptions.d.ts +4 -2
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +5 -3
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/connection/ChronicleConnection.d.ts +4 -5
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +34 -12
- package/dist/connection/ChronicleConnection.js.map +1 -1
- package/dist/connection/ChronicleConnectionString.d.ts +77 -3
- package/dist/connection/ChronicleConnectionString.d.ts.map +1 -1
- package/dist/connection/ChronicleConnectionString.js +250 -32
- package/dist/connection/ChronicleConnectionString.js.map +1 -1
- package/dist/connection/ChronicleServerAddressResolver.d.ts +23 -0
- package/dist/connection/ChronicleServerAddressResolver.d.ts.map +1 -0
- package/dist/connection/ChronicleServerAddressResolver.js +32 -0
- package/dist/connection/ChronicleServerAddressResolver.js.map +1 -0
- package/dist/connection/ChronicleSrvResolutionError.d.ts +8 -0
- package/dist/connection/ChronicleSrvResolutionError.d.ts.map +1 -0
- package/dist/connection/ChronicleSrvResolutionError.js +13 -0
- package/dist/connection/ChronicleSrvResolutionError.js.map +1 -0
- package/dist/connection/ChronicleSrvResolver.d.ts +28 -0
- package/dist/connection/ChronicleSrvResolver.d.ts.map +1 -0
- package/dist/connection/ChronicleSrvResolver.js +51 -0
- package/dist/connection/ChronicleSrvResolver.js.map +1 -0
- package/dist/connection/ILoadBalancerStrategy.d.ts +13 -0
- package/dist/connection/ILoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/ILoadBalancerStrategy.js +4 -0
- package/dist/connection/ILoadBalancerStrategy.js.map +1 -0
- package/dist/connection/KernelKeepAlive.d.ts +73 -0
- package/dist/connection/KernelKeepAlive.d.ts.map +1 -0
- package/dist/connection/KernelKeepAlive.js +115 -0
- package/dist/connection/KernelKeepAlive.js.map +1 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts +30 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.js +95 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.js.map +1 -0
- package/dist/connection/LoadBalancerMode.d.ts +21 -0
- package/dist/connection/LoadBalancerMode.d.ts.map +1 -0
- package/dist/connection/LoadBalancerMode.js +24 -0
- package/dist/connection/LoadBalancerMode.js.map +1 -0
- package/dist/connection/LoadBalancerStrategyFactory.d.ts +11 -0
- package/dist/connection/LoadBalancerStrategyFactory.d.ts.map +1 -0
- package/dist/connection/LoadBalancerStrategyFactory.js +26 -0
- package/dist/connection/LoadBalancerStrategyFactory.js.map +1 -0
- package/dist/connection/RandomLoadBalancerStrategy.d.ts +9 -0
- package/dist/connection/RandomLoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/RandomLoadBalancerStrategy.js +15 -0
- package/dist/connection/RandomLoadBalancerStrategy.js.map +1 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts +12 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.js +22 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.js.map +1 -0
- package/dist/connection/TokenProvider.d.ts +2 -1
- package/dist/connection/TokenProvider.d.ts.map +1 -1
- package/dist/connection/TokenProvider.js +9 -3
- package/dist/connection/TokenProvider.js.map +1 -1
- package/dist/connection/formatServerAddress.d.ts +10 -0
- package/dist/connection/formatServerAddress.d.ts.map +1 -0
- package/dist/connection/formatServerAddress.js +14 -0
- package/dist/connection/formatServerAddress.js.map +1 -0
- package/dist/reactors/Reactors.d.ts +13 -0
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +42 -4
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reducers/Reducers.d.ts +14 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +43 -8
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -5
- package/reactors/Reactors.ts +53 -4
- package/reducers/Reducers.ts +55 -8
- package/vitest.config.ts +12 -0
package/ChronicleClient.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
+
import * as os from 'os';
|
|
4
5
|
import { diag } from '@opentelemetry/api';
|
|
5
6
|
import { SpanStatusCode } from '@opentelemetry/api';
|
|
6
7
|
import { ChronicleOptions } from './ChronicleOptions';
|
|
7
8
|
import { ChronicleConnection } from './connection';
|
|
8
9
|
import { ConnectionLifecycle } from './connection/ConnectionLifecycle';
|
|
10
|
+
import { KernelKeepAlive } from './connection/KernelKeepAlive';
|
|
9
11
|
import { EventStore } from './EventStore';
|
|
10
12
|
import { EventStoreName } from './EventStoreName';
|
|
11
13
|
import { EventStoreNamespaceName } from './EventStoreNamespaceName';
|
|
@@ -30,6 +32,7 @@ import { TypeDiscoverer } from './types';
|
|
|
30
32
|
*/
|
|
31
33
|
export class ChronicleClient implements IChronicleClient {
|
|
32
34
|
private static readonly _healthCheckIntervalMs = 5000;
|
|
35
|
+
private static readonly _maxBackoffMs = 30_000;
|
|
33
36
|
|
|
34
37
|
private readonly _connection: ChronicleConnection;
|
|
35
38
|
private readonly _stores: Map<string, EventStore> = new Map();
|
|
@@ -45,6 +48,7 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
45
48
|
private _discoveryOperation?: Promise<void>;
|
|
46
49
|
private _isDisposed = false;
|
|
47
50
|
private _keepAliveAbortController?: AbortController;
|
|
51
|
+
private _healthCheckInFlight = false;
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* Creates a new {@link ChronicleClient} using the provided options.
|
|
@@ -212,15 +216,16 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
212
216
|
|
|
213
217
|
while (!this._isDisposed) {
|
|
214
218
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
// Resolve (DNS SRV, when applicable) and select (load balancer strategy) a
|
|
220
|
+
// server address and rebuild the gRPC channel from scratch on every attempt,
|
|
221
|
+
// including the first — not just once at startup — so membership and load
|
|
222
|
+
// changes are always picked up. This also guarantees a fresh IDLE channel: a
|
|
223
|
+
// failed probe can leave a channel in TRANSIENT_FAILURE, which gRPC won't
|
|
224
|
+
// recover from without a new channel. The contracts connect() is bypassed
|
|
225
|
+
// here — it uses watchConnectivityState and rejects as soon as the state
|
|
226
|
+
// changes to CONNECTING (not READY), making it unreliable for initial
|
|
227
|
+
// connection establishment.
|
|
228
|
+
await this._connection.resetChannel();
|
|
224
229
|
|
|
225
230
|
this._logger.debug('Connecting to Chronicle kernel', { attempt: attempt + 1 });
|
|
226
231
|
|
|
@@ -240,13 +245,8 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
240
245
|
|
|
241
246
|
} catch (error) {
|
|
242
247
|
attempt++;
|
|
243
|
-
const delayMs =
|
|
244
|
-
this._logger.
|
|
245
|
-
attempt,
|
|
246
|
-
delayMs,
|
|
247
|
-
error: this.toErrorMessage(error)
|
|
248
|
-
});
|
|
249
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
248
|
+
const delayMs = await this.backOff(attempt, 'Connection attempt failed, retrying', error);
|
|
249
|
+
this._logger.verbose('Backed off before next connection attempt', { attempt, delayMs });
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
@@ -290,7 +290,7 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
290
290
|
let attempt = 0;
|
|
291
291
|
while (!this._isDisposed) {
|
|
292
292
|
try {
|
|
293
|
-
this._connection.resetChannel();
|
|
293
|
+
await this._connection.resetChannel();
|
|
294
294
|
await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
|
|
295
295
|
this._logger.info('Reconnected to Chronicle kernel', { attempt: attempt + 1 });
|
|
296
296
|
await this.startKernelKeepAlive();
|
|
@@ -302,13 +302,7 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
302
302
|
return;
|
|
303
303
|
} catch (reconnectError) {
|
|
304
304
|
attempt++;
|
|
305
|
-
|
|
306
|
-
this._logger.warn('Reconnect attempt failed, retrying', {
|
|
307
|
-
attempt,
|
|
308
|
-
delayMs,
|
|
309
|
-
error: this.toErrorMessage(reconnectError)
|
|
310
|
-
});
|
|
311
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
305
|
+
await this.backOff(attempt, 'Reconnect attempt failed, retrying', reconnectError);
|
|
312
306
|
}
|
|
313
307
|
}
|
|
314
308
|
})().finally(() => {
|
|
@@ -319,6 +313,25 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
319
313
|
await this._reconnectOperation;
|
|
320
314
|
}
|
|
321
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Waits out the exponential backoff for a failed attempt and reports why.
|
|
318
|
+
* The delay is jittered so a fleet of clients that lost the same kernel does
|
|
319
|
+
* not come back in lockstep and knock it over again.
|
|
320
|
+
*/
|
|
321
|
+
private async backOff(attempt: number, message: string, error: unknown): Promise<number> {
|
|
322
|
+
const ceiling = Math.min(1000 * Math.pow(2, attempt - 1), ChronicleClient._maxBackoffMs);
|
|
323
|
+
const delayMs = Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
|
|
324
|
+
|
|
325
|
+
this._logger.warn(message, {
|
|
326
|
+
attempt,
|
|
327
|
+
delayMs,
|
|
328
|
+
error: this.toErrorMessage(error)
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
332
|
+
return delayMs;
|
|
333
|
+
}
|
|
334
|
+
|
|
322
335
|
private async withReconnect<T>(operation: string, action: () => Promise<T>): Promise<T> {
|
|
323
336
|
try {
|
|
324
337
|
return await action();
|
|
@@ -373,15 +386,20 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
373
386
|
}
|
|
374
387
|
|
|
375
388
|
private async runHealthCheck(): Promise<void> {
|
|
376
|
-
|
|
389
|
+
// A black-holed connection makes the probe itself hang, so without the
|
|
390
|
+
// in-flight guard every tick would stack another one that never returns.
|
|
391
|
+
if (this._isDisposed || !this._lifecycle.isConnected || this._healthCheckInFlight) {
|
|
377
392
|
return;
|
|
378
393
|
}
|
|
379
394
|
|
|
395
|
+
this._healthCheckInFlight = true;
|
|
380
396
|
try {
|
|
381
|
-
await this._connection.server.getVersionInfo({});
|
|
397
|
+
await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
|
|
382
398
|
this._logger.verbose('Connection health check passed');
|
|
383
399
|
} catch (error) {
|
|
384
400
|
await this.reconnect('watchdog-health-check', error);
|
|
401
|
+
} finally {
|
|
402
|
+
this._healthCheckInFlight = false;
|
|
385
403
|
}
|
|
386
404
|
}
|
|
387
405
|
|
|
@@ -390,45 +408,36 @@ export class ChronicleClient implements IChronicleClient {
|
|
|
390
408
|
this._keepAliveAbortController = new AbortController();
|
|
391
409
|
const { signal } = this._keepAliveAbortController;
|
|
392
410
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
411
|
+
// Losing the keep-alive means the kernel has stopped counting us as
|
|
412
|
+
// connected, so observers are already being torn down server-side.
|
|
413
|
+
// Reconnecting is the only way back — without this the client sits on a
|
|
414
|
+
// dead session, appends keep working, and reactors never fire again.
|
|
415
|
+
const keepAlive = new KernelKeepAlive(this._connection.connections, (reason, error) => {
|
|
416
|
+
if (this._isDisposed || signal.aborted) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
void this.reconnect(reason, error);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await keepAlive.start(
|
|
424
|
+
{
|
|
425
|
+
ConnectionId: this._lifecycle.connectionId,
|
|
426
|
+
// TODO: Not derived from this package's own version anywhere yet; kept as
|
|
427
|
+
// the pre-existing hardcoded placeholder until such a mechanism exists.
|
|
428
|
+
ClientVersion: '1.0.0',
|
|
429
|
+
IsRunningWithDebugger: false,
|
|
430
|
+
ProcessId: process.pid,
|
|
431
|
+
ProcessPath: process.execPath,
|
|
432
|
+
MachineName: os.hostname(),
|
|
433
|
+
ClientType: 'TypeScript'
|
|
434
|
+
},
|
|
435
|
+
signal
|
|
396
436
|
);
|
|
397
|
-
const iterator = keepAliveStream[Symbol.asyncIterator]();
|
|
398
|
-
const firstResult = await iterator.next();
|
|
399
|
-
if (firstResult.done) {
|
|
400
|
-
throw new Error('Connection service stream ended before sending first keep-alive');
|
|
401
|
-
}
|
|
402
437
|
|
|
403
|
-
await this._connection.connections.connectionKeepAlive(firstResult.value);
|
|
404
438
|
this._logger.info('Client registered with kernel keep-alive mechanism', {
|
|
405
439
|
connectionId: this._lifecycle.connectionId
|
|
406
440
|
});
|
|
407
|
-
|
|
408
|
-
void this.runKeepAliveLoop(iterator, signal);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
private async runKeepAliveLoop(iterator: AsyncIterator<unknown, void>, signal: AbortSignal): Promise<void> {
|
|
412
|
-
try {
|
|
413
|
-
while (!signal.aborted) {
|
|
414
|
-
const result = await iterator.next();
|
|
415
|
-
if (result.done) {
|
|
416
|
-
this._logger.info('Keep-alive stream ended');
|
|
417
|
-
break;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
await this._connection.connections.connectionKeepAlive(result.value as object);
|
|
421
|
-
this._logger.verbose('Keep-alive ping responded', {
|
|
422
|
-
connectionId: this._lifecycle.connectionId
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
} catch (err) {
|
|
426
|
-
if (!signal.aborted) {
|
|
427
|
-
this._logger.warn('Keep-alive loop ended with error', {
|
|
428
|
-
error: this.toErrorMessage(err)
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
441
|
}
|
|
433
442
|
|
|
434
443
|
private async registerArtifactsForStore(store: EventStore, reason: string): Promise<void> {
|
package/ChronicleOptions.ts
CHANGED
|
@@ -107,14 +107,13 @@ export class ChronicleOptions {
|
|
|
107
107
|
/**
|
|
108
108
|
* Creates a {@link ChronicleOptions} instance for local development.
|
|
109
109
|
* Connects to Chronicle on localhost:35000 using the standard development
|
|
110
|
-
* client credentials
|
|
111
|
-
*
|
|
110
|
+
* client credentials, matching the default Chronicle development server
|
|
111
|
+
* configuration. The Chronicle server requires TLS on its single port, so
|
|
112
|
+
* this connects over TLS against the server's self-signed development
|
|
113
|
+
* certificate.
|
|
112
114
|
* @returns A new ChronicleOptions instance for development.
|
|
113
115
|
*/
|
|
114
116
|
static development(options?: ChronicleOptionsFactoryParams): ChronicleOptions {
|
|
115
|
-
return ChronicleOptions.fromConnectionString(
|
|
116
|
-
'chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000?disableTls=true',
|
|
117
|
-
options
|
|
118
|
-
);
|
|
117
|
+
return ChronicleOptions.fromConnectionString(ChronicleConnectionString.Development, options);
|
|
119
118
|
}
|
|
120
119
|
}
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ You need a Chronicle Kernel available before running samples or application code
|
|
|
26
26
|
The easiest local setup is the development Docker image:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
docker run -p 35000:35000
|
|
29
|
+
docker run -p 35000:35000 cratis/chronicle:latest-development
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Getting Started
|
|
@@ -30,7 +30,11 @@ import type { ClientMiddleware } from 'nice-grpc-common';
|
|
|
30
30
|
import { Metadata } from 'nice-grpc-common';
|
|
31
31
|
import { EventStoreSubscriptionsDefinition } from '../eventStoreSubscriptions/contracts';
|
|
32
32
|
import { AuthenticationMode, ChronicleConnectionString } from './ChronicleConnectionString';
|
|
33
|
+
import { ChronicleServerAddressResolver } from './ChronicleServerAddressResolver';
|
|
33
34
|
import { ChronicleServices } from './ChronicleServices';
|
|
35
|
+
import { formatServerAddress } from './formatServerAddress';
|
|
36
|
+
import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
|
|
37
|
+
import { createLoadBalancerStrategy } from './LoadBalancerStrategyFactory';
|
|
34
38
|
import { ITokenProvider, NoOpTokenProvider, OAuthTokenProvider } from './TokenProvider';
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -76,11 +80,6 @@ export interface ChronicleConnectionOptions {
|
|
|
76
80
|
* Optional authentication authority URL. If not set, uses the Chronicle server itself.
|
|
77
81
|
*/
|
|
78
82
|
authority?: string;
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Optional management port for authentication endpoint. Defaults to 8080.
|
|
82
|
-
*/
|
|
83
|
-
managementPort?: number;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
/**
|
|
@@ -92,7 +91,10 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
92
91
|
private _connections!: ConnectionServiceClient;
|
|
93
92
|
private readonly _connectionString: ChronicleConnectionString;
|
|
94
93
|
private readonly _tokenProvider: ITokenProvider;
|
|
94
|
+
private readonly _addressResolver: ChronicleServerAddressResolver;
|
|
95
|
+
private readonly _loadBalancerStrategy: ILoadBalancerStrategy;
|
|
95
96
|
private _isConnected = false;
|
|
97
|
+
private _clientsReady: Promise<void>;
|
|
96
98
|
|
|
97
99
|
constructor(private readonly _options: ChronicleConnectionOptions) {
|
|
98
100
|
if (_options.connectionString) {
|
|
@@ -106,7 +108,15 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
this._tokenProvider = this.createTokenProvider();
|
|
109
|
-
this.
|
|
111
|
+
this._addressResolver = new ChronicleServerAddressResolver();
|
|
112
|
+
this._loadBalancerStrategy = createLoadBalancerStrategy(this._connectionString.loadBalancer, this._connectionString.skipTlsValidation);
|
|
113
|
+
|
|
114
|
+
this._clientsReady = this.createClients();
|
|
115
|
+
// Building the initial channel is async (address resolution + load balancer
|
|
116
|
+
// selection), so the constructor cannot await it. Real failures still surface to
|
|
117
|
+
// callers that await connect()/resetChannel(); this only prevents an unhandled
|
|
118
|
+
// rejection warning from the fire-and-forget initial build.
|
|
119
|
+
this._clientsReady.catch(() => {});
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
get connectionString(): ChronicleConnectionString {
|
|
@@ -202,37 +212,39 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
202
212
|
}
|
|
203
213
|
|
|
204
214
|
async connect(): Promise<void> {
|
|
215
|
+
await this._clientsReady;
|
|
205
216
|
const deadline = new Date(Date.now() + (this._options.connectTimeout ?? 10_000));
|
|
206
217
|
await waitForChannelReady(this._channel, deadline);
|
|
207
218
|
this._isConnected = true;
|
|
208
219
|
}
|
|
209
220
|
|
|
210
|
-
resetChannel(): void {
|
|
221
|
+
async resetChannel(): Promise<void> {
|
|
211
222
|
try {
|
|
212
|
-
this._channel
|
|
223
|
+
this._channel?.close();
|
|
213
224
|
} catch {
|
|
214
225
|
// Best-effort shutdown before recreating the channel.
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
this._isConnected = false;
|
|
218
|
-
this.createClients();
|
|
229
|
+
this._clientsReady = this.createClients();
|
|
230
|
+
await this._clientsReady;
|
|
219
231
|
}
|
|
220
232
|
|
|
221
233
|
async reconnect(): Promise<void> {
|
|
222
|
-
this.resetChannel();
|
|
234
|
+
await this.resetChannel();
|
|
223
235
|
await this.connect();
|
|
224
236
|
}
|
|
225
237
|
|
|
226
238
|
disconnect(): void {
|
|
227
239
|
this._isConnected = false;
|
|
228
|
-
this._channel
|
|
240
|
+
this._channel?.close();
|
|
229
241
|
}
|
|
230
242
|
|
|
231
243
|
dispose(): void {
|
|
232
244
|
this.disconnect();
|
|
233
245
|
}
|
|
234
246
|
|
|
235
|
-
private createClients(): void {
|
|
247
|
+
private async createClients(): Promise<void> {
|
|
236
248
|
const channelOptions: ChannelOptions = {};
|
|
237
249
|
|
|
238
250
|
if (this._options.maxReceiveMessageSize !== undefined) {
|
|
@@ -243,7 +255,12 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
243
255
|
channelOptions['grpc.max_send_message_length'] = this._options.maxSendMessageSize;
|
|
244
256
|
}
|
|
245
257
|
|
|
246
|
-
|
|
258
|
+
// Re-resolved (DNS SRV, when applicable) and re-selected (load balancer strategy)
|
|
259
|
+
// on every call, so every connect/reconnect attempt picks up membership and load
|
|
260
|
+
// changes rather than pinning to whatever was selected at startup.
|
|
261
|
+
const candidates = await this._addressResolver.resolve(this._connectionString);
|
|
262
|
+
const selected = await this._loadBalancerStrategy.select(candidates);
|
|
263
|
+
const serverAddress = formatServerAddress(selected);
|
|
247
264
|
const credentials = this._options.credentials ?? this._connectionString.createCredentials();
|
|
248
265
|
|
|
249
266
|
this._channel = createChannel(serverAddress, credentials, channelOptions);
|
|
@@ -298,24 +315,27 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
298
315
|
}
|
|
299
316
|
|
|
300
317
|
private createOAuthTokenProvider(username: string, password: string): ITokenProvider {
|
|
301
|
-
|
|
318
|
+
// Chronicle serves the authentication endpoint on the same port as the rest of the
|
|
319
|
+
// Kernel, so the authority defaults to the connection string's server address.
|
|
320
|
+
const serverPort = this._connectionString.serverAddress.port;
|
|
302
321
|
let authorityHost: string;
|
|
303
322
|
let authorityPort: number;
|
|
304
323
|
|
|
305
324
|
if (this._options.authority) {
|
|
306
325
|
const authority = new URL(this._options.authority);
|
|
307
326
|
authorityHost = authority.hostname;
|
|
308
|
-
authorityPort = authority.port ? parseInt(authority.port, 10) :
|
|
327
|
+
authorityPort = authority.port ? parseInt(authority.port, 10) : serverPort;
|
|
309
328
|
} else {
|
|
310
329
|
authorityHost = this._connectionString.serverAddress.host;
|
|
311
|
-
authorityPort =
|
|
330
|
+
authorityPort = serverPort;
|
|
312
331
|
}
|
|
313
332
|
|
|
314
333
|
const scheme = this._connectionString.disableTls ? 'http' : 'https';
|
|
315
334
|
return new OAuthTokenProvider(
|
|
316
335
|
`${scheme}://${authorityHost}:${authorityPort}/connect/token`,
|
|
317
336
|
username,
|
|
318
|
-
password
|
|
337
|
+
password,
|
|
338
|
+
this._connectionString.skipTlsValidation
|
|
319
339
|
);
|
|
320
340
|
}
|
|
321
341
|
|
|
@@ -0,0 +1,227 @@
|
|
|
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 { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { AuthenticationMode, ChronicleConnectionString, ChronicleConnectionStringBuilder } from './ChronicleConnectionString';
|
|
6
|
+
import { LoadBalancerMode } from './LoadBalancerMode';
|
|
7
|
+
|
|
8
|
+
describe('ChronicleConnectionStringBuilder', () => {
|
|
9
|
+
describe('when parsing a single host', () => {
|
|
10
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000');
|
|
11
|
+
|
|
12
|
+
it('should have the host', () => expect(builder.host).toBe('localhost'));
|
|
13
|
+
it('should have the port', () => expect(builder.port).toBe(35000));
|
|
14
|
+
it('should have one server address', () => expect(builder.hosts).toEqual([{ host: 'localhost', port: 35000 }]));
|
|
15
|
+
it('should not be srv', () => expect(builder.isSrv).toBe(false));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('when parsing a host without an explicit port', () => {
|
|
19
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://localhost');
|
|
20
|
+
|
|
21
|
+
it('should default the port to 35000', () => expect(builder.port).toBe(35000));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('when parsing multiple hosts', () => {
|
|
25
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://host1:35001,host2,host3:35003');
|
|
26
|
+
|
|
27
|
+
it('should have the first host as host', () => expect(builder.host).toBe('host1'));
|
|
28
|
+
it('should have the first port as port', () => expect(builder.port).toBe(35001));
|
|
29
|
+
it('should have three server addresses', () => expect(builder.hosts).toHaveLength(3));
|
|
30
|
+
it('should have the first server address', () => expect(builder.hosts[0]).toEqual({ host: 'host1', port: 35001 }));
|
|
31
|
+
it('should default the second host port', () => expect(builder.hosts[1]).toEqual({ host: 'host2', port: 35000 }));
|
|
32
|
+
it('should have the third server address', () => expect(builder.hosts[2]).toEqual({ host: 'host3', port: 35003 }));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('when parsing multiple hosts with authentication and options', () => {
|
|
36
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://admin:secret@host1,host2:35002/?skipTlsValidation=true');
|
|
37
|
+
|
|
38
|
+
it('should have the username', () => expect(builder.username).toBe('admin'));
|
|
39
|
+
it('should have the password', () => expect(builder.password).toBe('secret'));
|
|
40
|
+
it('should have two server addresses', () => expect(builder.hosts).toHaveLength(2));
|
|
41
|
+
it('should default the first host port', () => expect(builder.hosts[0]).toEqual({ host: 'host1', port: 35000 }));
|
|
42
|
+
it('should have the second server address', () => expect(builder.hosts[1]).toEqual({ host: 'host2', port: 35002 }));
|
|
43
|
+
it('should skip tls validation', () => expect(builder.skipTlsValidation).toBe(true));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('when parsing an IPv6 host in a multi-host list', () => {
|
|
47
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://[::1]:35001,host2');
|
|
48
|
+
|
|
49
|
+
it('should have the bracketed host without brackets', () => expect(builder.hosts[0]).toEqual({ host: '::1', port: 35001 }));
|
|
50
|
+
it('should have the second host', () => expect(builder.hosts[1]).toEqual({ host: 'host2', port: 35000 }));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('when parsing an IPv6 host without an explicit port', () => {
|
|
54
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://[::1]');
|
|
55
|
+
|
|
56
|
+
it('should default the port', () => expect(builder.hosts[0]).toEqual({ host: '::1', port: 35000 }));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('when parsing a srv url', () => {
|
|
60
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle+srv://cluster.example.com');
|
|
61
|
+
|
|
62
|
+
it('should have the srv scheme', () => expect(builder.scheme).toBe('chronicle+srv'));
|
|
63
|
+
it('should be srv', () => expect(builder.isSrv).toBe(true));
|
|
64
|
+
it('should have the correct host', () => expect(builder.host).toBe('cluster.example.com'));
|
|
65
|
+
it('should have the default port', () => expect(builder.port).toBe(35000));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('when parsing a srv url with multiple hosts', () => {
|
|
69
|
+
it('should throw', () => expect(() => new ChronicleConnectionStringBuilder('chronicle+srv://host1,host2')).toThrow());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('when parsing a srv url with a name server', () => {
|
|
73
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle+srv://cluster.example.com/?srvNameServer=127.0.0.1:5353');
|
|
74
|
+
|
|
75
|
+
it('should be srv', () => expect(builder.isSrv).toBe(true));
|
|
76
|
+
it('should have the name server', () => expect(builder.srvNameServer).toBe('127.0.0.1:5353'));
|
|
77
|
+
it('should include the name server when building', () =>
|
|
78
|
+
expect(builder.build()).toBe('chronicle+srv://cluster.example.com:35000?srvNameServer=127.0.0.1%3A5353'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('when parsing a url with a trailing slash before the query string', () => {
|
|
82
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000/?apiKey=my-key');
|
|
83
|
+
|
|
84
|
+
it('should still parse the host', () => expect(builder.host).toBe('localhost'));
|
|
85
|
+
it('should still parse the query string', () => expect(builder.apiKey).toBe('my-key'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('when parsing a url with a load balancer option', () => {
|
|
89
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://host1,host2/?loadBalancer=random');
|
|
90
|
+
|
|
91
|
+
it('should have the load balancer', () => expect(builder.loadBalancer).toBe(LoadBalancerMode.Random));
|
|
92
|
+
it('should include the load balancer when building', () =>
|
|
93
|
+
expect(builder.build()).toBe('chronicle://host1:35000,host2:35000?loadBalancer=random'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('when no load balancer option is given', () => {
|
|
97
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000');
|
|
98
|
+
|
|
99
|
+
it('should default to least-connections', () => expect(builder.loadBalancer).toBe(LoadBalancerMode.LeastConnections));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('when parsing a url with an unknown load balancer option', () => {
|
|
103
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000/?loadBalancer=nonsense');
|
|
104
|
+
|
|
105
|
+
it('should throw', () => expect(() => builder.loadBalancer).toThrow());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('when building a connection string with multiple hosts', () => {
|
|
109
|
+
const builder = new ChronicleConnectionStringBuilder();
|
|
110
|
+
builder.hosts = [{ host: 'host1', port: 35001 }, { host: 'host2', port: 35000 }];
|
|
111
|
+
|
|
112
|
+
it('should include all hosts in the url', () => expect(builder.build()).toBe('chronicle://host1:35001,host2:35000'));
|
|
113
|
+
it('should round-trip through parsing', () => expect(new ChronicleConnectionStringBuilder(builder.build()).hosts).toEqual(builder.hosts));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('when building a connection string with an IPv6 host', () => {
|
|
117
|
+
const builder = new ChronicleConnectionStringBuilder();
|
|
118
|
+
builder.hosts = [{ host: '::1', port: 35000 }];
|
|
119
|
+
|
|
120
|
+
it('should bracket the IPv6 host', () => expect(builder.build()).toBe('chronicle://[::1]:35000'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('when building a connection string with tls validation not skipped', () => {
|
|
124
|
+
const builder = new ChronicleConnectionStringBuilder();
|
|
125
|
+
builder.host = 'localhost';
|
|
126
|
+
builder.port = 35000;
|
|
127
|
+
builder.skipTlsValidation = false;
|
|
128
|
+
|
|
129
|
+
it('should include skipTlsValidation in the query string', () => expect(builder.build()).toBe('chronicle://localhost:35000?skipTlsValidation=false'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('when setting host after hosts were set to multiple servers', () => {
|
|
133
|
+
const builder = new ChronicleConnectionStringBuilder();
|
|
134
|
+
builder.hosts = [{ host: 'host1', port: 35001 }, { host: 'host2', port: 35002 }];
|
|
135
|
+
builder.host = 'newhost';
|
|
136
|
+
|
|
137
|
+
it('should collapse to a single server', () => expect(builder.hosts).toEqual([{ host: 'newhost', port: 35001 }]));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('when getting authentication mode with client credentials', () => {
|
|
141
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://user:pass@localhost:35000');
|
|
142
|
+
|
|
143
|
+
it('should be client credentials', () => expect(builder.authenticationMode).toBe(AuthenticationMode.ClientCredentials));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('when getting authentication mode with an api key', () => {
|
|
147
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000/?apiKey=my-key');
|
|
148
|
+
|
|
149
|
+
it('should be api key', () => expect(builder.authenticationMode).toBe(AuthenticationMode.ApiKey));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('when getting authentication mode with both credentials and an api key', () => {
|
|
153
|
+
const builder = new ChronicleConnectionStringBuilder('chronicle://user:pass@localhost:35000/?apiKey=my-key');
|
|
154
|
+
|
|
155
|
+
it('should throw', () => expect(() => builder.authenticationMode).toThrow());
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('ChronicleConnectionString', () => {
|
|
160
|
+
describe('when parsing a connection string with multiple hosts', () => {
|
|
161
|
+
const connectionString = new ChronicleConnectionString('chronicle://host1:35001,host2:35002');
|
|
162
|
+
|
|
163
|
+
it('should have all server addresses', () =>
|
|
164
|
+
expect(connectionString.serverAddresses).toEqual([{ host: 'host1', port: 35001 }, { host: 'host2', port: 35002 }]));
|
|
165
|
+
it('should have the first as the convenience server address', () =>
|
|
166
|
+
expect(connectionString.serverAddress).toEqual({ host: 'host1', port: 35001 }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('when using the development connection string', () => {
|
|
170
|
+
const { Development } = ChronicleConnectionString;
|
|
171
|
+
|
|
172
|
+
it('should skip tls validation', () => expect(Development.skipTlsValidation).toBe(true));
|
|
173
|
+
it('should use the development client credentials', () => expect(Development.username).toBe(ChronicleConnectionString.DEVELOPMENT_CLIENT));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('when creating credentials without skipTlsValidation specified', () => {
|
|
177
|
+
it('should skip certificate chain validation by default', async () => {
|
|
178
|
+
vi.resetModules();
|
|
179
|
+
const createSsl = vi.fn().mockReturnValue('insecure-tls-credentials');
|
|
180
|
+
const createInsecure = vi.fn();
|
|
181
|
+
vi.doMock('@grpc/grpc-js', () => ({ credentials: { createSsl, createInsecure } }));
|
|
182
|
+
|
|
183
|
+
const { ChronicleConnectionString: MockedChronicleConnectionString } = await import('./ChronicleConnectionString');
|
|
184
|
+
const connectionString = new MockedChronicleConnectionString('chronicle://localhost:35000');
|
|
185
|
+
const credentials = connectionString.createCredentials();
|
|
186
|
+
|
|
187
|
+
expect(createSsl).toHaveBeenCalledWith(null, null, null, { rejectUnauthorized: false });
|
|
188
|
+
expect(credentials).toBe('insecure-tls-credentials');
|
|
189
|
+
vi.doUnmock('@grpc/grpc-js');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('when creating credentials with skipTlsValidation explicitly false', () => {
|
|
194
|
+
it('should validate the certificate chain', async () => {
|
|
195
|
+
vi.resetModules();
|
|
196
|
+
const createSsl = vi.fn().mockReturnValue('secure-credentials');
|
|
197
|
+
const createInsecure = vi.fn();
|
|
198
|
+
vi.doMock('@grpc/grpc-js', () => ({ credentials: { createSsl, createInsecure } }));
|
|
199
|
+
|
|
200
|
+
const { ChronicleConnectionString: MockedChronicleConnectionString } = await import('./ChronicleConnectionString');
|
|
201
|
+
const connectionString = new MockedChronicleConnectionString('chronicle://localhost:35000/?skipTlsValidation=false');
|
|
202
|
+
const credentials = connectionString.createCredentials();
|
|
203
|
+
|
|
204
|
+
expect(createSsl).toHaveBeenCalledWith();
|
|
205
|
+
expect(credentials).toBe('secure-credentials');
|
|
206
|
+
vi.doUnmock('@grpc/grpc-js');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('when creating credentials with disableTls', () => {
|
|
211
|
+
it('should create insecure credentials', async () => {
|
|
212
|
+
vi.resetModules();
|
|
213
|
+
const createSsl = vi.fn();
|
|
214
|
+
const createInsecure = vi.fn().mockReturnValue('plaintext-credentials');
|
|
215
|
+
vi.doMock('@grpc/grpc-js', () => ({ credentials: { createSsl, createInsecure } }));
|
|
216
|
+
|
|
217
|
+
const { ChronicleConnectionString: MockedChronicleConnectionString } = await import('./ChronicleConnectionString');
|
|
218
|
+
const connectionString = new MockedChronicleConnectionString('chronicle://localhost:35000/?disableTls=true');
|
|
219
|
+
const credentials = connectionString.createCredentials();
|
|
220
|
+
|
|
221
|
+
expect(createInsecure).toHaveBeenCalled();
|
|
222
|
+
expect(createSsl).not.toHaveBeenCalled();
|
|
223
|
+
expect(credentials).toBe('plaintext-credentials');
|
|
224
|
+
vi.doUnmock('@grpc/grpc-js');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|