@cratis/chronicle 2.0.0 → 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/connection/ChronicleConnection.ts +32 -9
- package/connection/ChronicleConnectionString.spec.ts +227 -0
- package/connection/ChronicleConnectionString.ts +290 -40
- 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 +5 -4
- 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/connection/ChronicleConnection.d.ts +4 -1
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +29 -9
- 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 +251 -37
- 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 +6 -4
- 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> {
|
|
@@ -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
|
/**
|
|
@@ -87,7 +91,10 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
87
91
|
private _connections!: ConnectionServiceClient;
|
|
88
92
|
private readonly _connectionString: ChronicleConnectionString;
|
|
89
93
|
private readonly _tokenProvider: ITokenProvider;
|
|
94
|
+
private readonly _addressResolver: ChronicleServerAddressResolver;
|
|
95
|
+
private readonly _loadBalancerStrategy: ILoadBalancerStrategy;
|
|
90
96
|
private _isConnected = false;
|
|
97
|
+
private _clientsReady: Promise<void>;
|
|
91
98
|
|
|
92
99
|
constructor(private readonly _options: ChronicleConnectionOptions) {
|
|
93
100
|
if (_options.connectionString) {
|
|
@@ -101,7 +108,15 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
this._tokenProvider = this.createTokenProvider();
|
|
104
|
-
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(() => {});
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
get connectionString(): ChronicleConnectionString {
|
|
@@ -197,37 +212,39 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
async connect(): Promise<void> {
|
|
215
|
+
await this._clientsReady;
|
|
200
216
|
const deadline = new Date(Date.now() + (this._options.connectTimeout ?? 10_000));
|
|
201
217
|
await waitForChannelReady(this._channel, deadline);
|
|
202
218
|
this._isConnected = true;
|
|
203
219
|
}
|
|
204
220
|
|
|
205
|
-
resetChannel(): void {
|
|
221
|
+
async resetChannel(): Promise<void> {
|
|
206
222
|
try {
|
|
207
|
-
this._channel
|
|
223
|
+
this._channel?.close();
|
|
208
224
|
} catch {
|
|
209
225
|
// Best-effort shutdown before recreating the channel.
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
this._isConnected = false;
|
|
213
|
-
this.createClients();
|
|
229
|
+
this._clientsReady = this.createClients();
|
|
230
|
+
await this._clientsReady;
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
async reconnect(): Promise<void> {
|
|
217
|
-
this.resetChannel();
|
|
234
|
+
await this.resetChannel();
|
|
218
235
|
await this.connect();
|
|
219
236
|
}
|
|
220
237
|
|
|
221
238
|
disconnect(): void {
|
|
222
239
|
this._isConnected = false;
|
|
223
|
-
this._channel
|
|
240
|
+
this._channel?.close();
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
dispose(): void {
|
|
227
244
|
this.disconnect();
|
|
228
245
|
}
|
|
229
246
|
|
|
230
|
-
private createClients(): void {
|
|
247
|
+
private async createClients(): Promise<void> {
|
|
231
248
|
const channelOptions: ChannelOptions = {};
|
|
232
249
|
|
|
233
250
|
if (this._options.maxReceiveMessageSize !== undefined) {
|
|
@@ -238,7 +255,12 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
238
255
|
channelOptions['grpc.max_send_message_length'] = this._options.maxSendMessageSize;
|
|
239
256
|
}
|
|
240
257
|
|
|
241
|
-
|
|
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);
|
|
242
264
|
const credentials = this._options.credentials ?? this._connectionString.createCredentials();
|
|
243
265
|
|
|
244
266
|
this._channel = createChannel(serverAddress, credentials, channelOptions);
|
|
@@ -312,7 +334,8 @@ export class ChronicleConnection implements ChronicleServices {
|
|
|
312
334
|
return new OAuthTokenProvider(
|
|
313
335
|
`${scheme}://${authorityHost}:${authorityPort}/connect/token`,
|
|
314
336
|
username,
|
|
315
|
-
password
|
|
337
|
+
password,
|
|
338
|
+
this._connectionString.skipTlsValidation
|
|
316
339
|
);
|
|
317
340
|
}
|
|
318
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
|
+
});
|