@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.
Files changed (102) hide show
  1. package/ChronicleClient.ts +69 -60
  2. package/ChronicleOptions.ts +5 -6
  3. package/README.md +1 -1
  4. package/connection/ChronicleConnection.ts +37 -17
  5. package/connection/ChronicleConnectionString.spec.ts +227 -0
  6. package/connection/ChronicleConnectionString.ts +289 -35
  7. package/connection/ChronicleServerAddressResolver.spec.ts +41 -0
  8. package/connection/ChronicleServerAddressResolver.ts +33 -0
  9. package/connection/ChronicleSrvResolutionError.ts +13 -0
  10. package/connection/ChronicleSrvResolver.spec.ts +103 -0
  11. package/connection/ChronicleSrvResolver.ts +57 -0
  12. package/connection/ConnectionLifecycle.spec.ts +109 -0
  13. package/connection/ILoadBalancerStrategy.ts +16 -0
  14. package/connection/KernelKeepAlive.spec.ts +175 -0
  15. package/connection/KernelKeepAlive.ts +161 -0
  16. package/connection/LeastConnectionsLoadBalancerStrategy.spec.ts +159 -0
  17. package/connection/LeastConnectionsLoadBalancerStrategy.ts +117 -0
  18. package/connection/LoadBalancerMode.ts +25 -0
  19. package/connection/LoadBalancerStrategyFactory.ts +28 -0
  20. package/connection/RandomLoadBalancerStrategy.spec.ts +50 -0
  21. package/connection/RandomLoadBalancerStrategy.ts +19 -0
  22. package/connection/RoundRobinLoadBalancerStrategy.spec.ts +61 -0
  23. package/connection/RoundRobinLoadBalancerStrategy.ts +29 -0
  24. package/connection/TokenProvider.ts +8 -3
  25. package/connection/formatServerAddress.ts +16 -0
  26. package/dist/ChronicleClient.d.ts +8 -1
  27. package/dist/ChronicleClient.d.ts.map +1 -1
  28. package/dist/ChronicleClient.js +63 -56
  29. package/dist/ChronicleClient.js.map +1 -1
  30. package/dist/ChronicleOptions.d.ts +4 -2
  31. package/dist/ChronicleOptions.d.ts.map +1 -1
  32. package/dist/ChronicleOptions.js +5 -3
  33. package/dist/ChronicleOptions.js.map +1 -1
  34. package/dist/connection/ChronicleConnection.d.ts +4 -5
  35. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  36. package/dist/connection/ChronicleConnection.js +34 -12
  37. package/dist/connection/ChronicleConnection.js.map +1 -1
  38. package/dist/connection/ChronicleConnectionString.d.ts +77 -3
  39. package/dist/connection/ChronicleConnectionString.d.ts.map +1 -1
  40. package/dist/connection/ChronicleConnectionString.js +250 -32
  41. package/dist/connection/ChronicleConnectionString.js.map +1 -1
  42. package/dist/connection/ChronicleServerAddressResolver.d.ts +23 -0
  43. package/dist/connection/ChronicleServerAddressResolver.d.ts.map +1 -0
  44. package/dist/connection/ChronicleServerAddressResolver.js +32 -0
  45. package/dist/connection/ChronicleServerAddressResolver.js.map +1 -0
  46. package/dist/connection/ChronicleSrvResolutionError.d.ts +8 -0
  47. package/dist/connection/ChronicleSrvResolutionError.d.ts.map +1 -0
  48. package/dist/connection/ChronicleSrvResolutionError.js +13 -0
  49. package/dist/connection/ChronicleSrvResolutionError.js.map +1 -0
  50. package/dist/connection/ChronicleSrvResolver.d.ts +28 -0
  51. package/dist/connection/ChronicleSrvResolver.d.ts.map +1 -0
  52. package/dist/connection/ChronicleSrvResolver.js +51 -0
  53. package/dist/connection/ChronicleSrvResolver.js.map +1 -0
  54. package/dist/connection/ILoadBalancerStrategy.d.ts +13 -0
  55. package/dist/connection/ILoadBalancerStrategy.d.ts.map +1 -0
  56. package/dist/connection/ILoadBalancerStrategy.js +4 -0
  57. package/dist/connection/ILoadBalancerStrategy.js.map +1 -0
  58. package/dist/connection/KernelKeepAlive.d.ts +73 -0
  59. package/dist/connection/KernelKeepAlive.d.ts.map +1 -0
  60. package/dist/connection/KernelKeepAlive.js +115 -0
  61. package/dist/connection/KernelKeepAlive.js.map +1 -0
  62. package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts +30 -0
  63. package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts.map +1 -0
  64. package/dist/connection/LeastConnectionsLoadBalancerStrategy.js +95 -0
  65. package/dist/connection/LeastConnectionsLoadBalancerStrategy.js.map +1 -0
  66. package/dist/connection/LoadBalancerMode.d.ts +21 -0
  67. package/dist/connection/LoadBalancerMode.d.ts.map +1 -0
  68. package/dist/connection/LoadBalancerMode.js +24 -0
  69. package/dist/connection/LoadBalancerMode.js.map +1 -0
  70. package/dist/connection/LoadBalancerStrategyFactory.d.ts +11 -0
  71. package/dist/connection/LoadBalancerStrategyFactory.d.ts.map +1 -0
  72. package/dist/connection/LoadBalancerStrategyFactory.js +26 -0
  73. package/dist/connection/LoadBalancerStrategyFactory.js.map +1 -0
  74. package/dist/connection/RandomLoadBalancerStrategy.d.ts +9 -0
  75. package/dist/connection/RandomLoadBalancerStrategy.d.ts.map +1 -0
  76. package/dist/connection/RandomLoadBalancerStrategy.js +15 -0
  77. package/dist/connection/RandomLoadBalancerStrategy.js.map +1 -0
  78. package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts +12 -0
  79. package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts.map +1 -0
  80. package/dist/connection/RoundRobinLoadBalancerStrategy.js +22 -0
  81. package/dist/connection/RoundRobinLoadBalancerStrategy.js.map +1 -0
  82. package/dist/connection/TokenProvider.d.ts +2 -1
  83. package/dist/connection/TokenProvider.d.ts.map +1 -1
  84. package/dist/connection/TokenProvider.js +9 -3
  85. package/dist/connection/TokenProvider.js.map +1 -1
  86. package/dist/connection/formatServerAddress.d.ts +10 -0
  87. package/dist/connection/formatServerAddress.d.ts.map +1 -0
  88. package/dist/connection/formatServerAddress.js +14 -0
  89. package/dist/connection/formatServerAddress.js.map +1 -0
  90. package/dist/reactors/Reactors.d.ts +13 -0
  91. package/dist/reactors/Reactors.d.ts.map +1 -1
  92. package/dist/reactors/Reactors.js +42 -4
  93. package/dist/reactors/Reactors.js.map +1 -1
  94. package/dist/reducers/Reducers.d.ts +14 -0
  95. package/dist/reducers/Reducers.d.ts.map +1 -1
  96. package/dist/reducers/Reducers.js +43 -8
  97. package/dist/reducers/Reducers.js.map +1 -1
  98. package/dist/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +8 -5
  100. package/reactors/Reactors.ts +53 -4
  101. package/reducers/Reducers.ts +55 -8
  102. package/vitest.config.ts +12 -0
@@ -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
- if (attempt > 0) {
216
- // Recreate the gRPC channel so we start from IDLE. A failed
217
- // probe can leave the channel in TRANSIENT_FAILURE, which gRPC
218
- // won't recover without a fresh channel. The contracts connect()
219
- // is also bypassed here it uses watchConnectivityState and
220
- // rejects as soon as the state changes to CONNECTING (not READY),
221
- // making it unreliable for initial connection establishment.
222
- this._connection.resetChannel();
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 = Math.min(1000 * Math.pow(2, attempt - 1), 30_000);
244
- this._logger.warn('Connection attempt failed, retrying', {
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
- const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000);
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
- if (this._isDisposed || !this._lifecycle.isConnected) {
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
- const keepAliveStream = this._connection.connections.connect(
394
- { ConnectionId: this._lifecycle.connectionId, ClientVersion: '1.0.0', IsRunningWithDebugger: false },
395
- { signal }
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> {
@@ -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 and with TLS disabled, matching the default Chronicle
111
- * development server configuration.
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 -p 8080:8080 cratis/chronicle:latest-development
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.createClients();
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.close();
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.close();
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
- const serverAddress = `${this._connectionString.serverAddress.host}:${this._connectionString.serverAddress.port}`;
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
- const managementPort = this._options.managementPort ?? 8080;
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) : managementPort;
327
+ authorityPort = authority.port ? parseInt(authority.port, 10) : serverPort;
309
328
  } else {
310
329
  authorityHost = this._connectionString.serverAddress.host;
311
- authorityPort = managementPort;
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
+ });