@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
@@ -2,6 +2,11 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import * as grpc from '@grpc/grpc-js';
5
+ import { formatServerAddress } from './formatServerAddress';
6
+ import { LoadBalancerMode } from './LoadBalancerMode';
7
+
8
+ const DEFAULT_PORT = 35000;
9
+ const SRV_SCHEME = 'chronicle+srv';
5
10
 
6
11
  /**
7
12
  * Authentication mode for Chronicle connection.
@@ -19,22 +24,94 @@ export interface ChronicleServerAddress {
19
24
  port: number;
20
25
  }
21
26
 
27
+ /**
28
+ * Splits a `host1:port1,host2:port2` host list on top-level commas, leaving commas that
29
+ * appear inside IPv6 bracket literals (`[::1]:35000`) intact.
30
+ */
31
+ function splitHostList(value: string): string[] {
32
+ const entries: string[] = [];
33
+ let depth = 0;
34
+ let current = '';
35
+
36
+ for (const character of value) {
37
+ if (character === '[') {
38
+ depth++;
39
+ } else if (character === ']') {
40
+ depth--;
41
+ }
42
+
43
+ if (character === ',' && depth === 0) {
44
+ entries.push(current);
45
+ current = '';
46
+ continue;
47
+ }
48
+
49
+ current += character;
50
+ }
51
+
52
+ if (current.length > 0) {
53
+ entries.push(current);
54
+ }
55
+
56
+ return entries;
57
+ }
58
+
59
+ /**
60
+ * Parses a single `host:port` or `[ipv6]:port` entry, defaulting the port when omitted.
61
+ */
62
+ function parseHostAndPort(entry: string): ChronicleServerAddress {
63
+ const trimmed = entry.trim();
64
+ let host: string;
65
+ let portPart: string;
66
+
67
+ if (trimmed.startsWith('[')) {
68
+ const closingIndex = trimmed.indexOf(']');
69
+ if (closingIndex === -1) {
70
+ throw new Error(`Invalid server address: ${trimmed}`);
71
+ }
72
+
73
+ host = trimmed.slice(1, closingIndex);
74
+ portPart = trimmed.slice(closingIndex + 1).replace(/^:/, '');
75
+ } else {
76
+ const colonIndex = trimmed.lastIndexOf(':');
77
+ host = colonIndex === -1 ? trimmed : trimmed.slice(0, colonIndex);
78
+ portPart = colonIndex === -1 ? '' : trimmed.slice(colonIndex + 1);
79
+ }
80
+
81
+ if (host.length === 0) {
82
+ throw new Error(`Invalid server address: ${trimmed}`);
83
+ }
84
+
85
+ if (portPart.length === 0) {
86
+ return { host, port: DEFAULT_PORT };
87
+ }
88
+
89
+ const port = Number(portPart);
90
+ if (!Number.isInteger(port)) {
91
+ throw new Error(`Invalid server address: ${trimmed}`);
92
+ }
93
+
94
+ return { host, port };
95
+ }
96
+
22
97
  /**
23
98
  * Builder for constructing Chronicle connection strings.
24
99
  */
25
100
  export class ChronicleConnectionStringBuilder {
26
- private static readonly _defaultPort = 35000;
27
- private static readonly _hostKey = 'Host';
28
- private static readonly _portKey = 'Port';
101
+ private static readonly _defaultPort = DEFAULT_PORT;
29
102
  private static readonly _usernameKey = 'Username';
30
103
  private static readonly _passwordKey = 'Password';
31
104
  private static readonly _schemeKey = 'Scheme';
32
105
  private static readonly _apiKeyKey = 'apiKey';
33
106
  private static readonly _disableTlsKey = 'disableTls';
107
+ private static readonly _skipTlsValidationKey = 'skipTlsValidation';
34
108
  private static readonly _certificatePathKey = 'certificatePath';
35
109
  private static readonly _certificatePasswordKey = 'certificatePassword';
110
+ private static readonly _loadBalancerKey = 'loadBalancer';
111
+ private static readonly _srvNameServerKey = 'srvNameServer';
36
112
 
37
113
  private readonly _properties = new Map<string, string>();
114
+ private _hosts: ChronicleServerAddress[] = [];
38
115
 
39
116
  constructor(connectionString?: string) {
40
117
  if (connectionString) {
@@ -42,21 +119,39 @@ export class ChronicleConnectionStringBuilder {
42
119
  }
43
120
  }
44
121
 
122
+ get hosts(): ChronicleServerAddress[] {
123
+ return this._hosts.length > 0 ? this._hosts : [{ host: 'localhost', port: ChronicleConnectionStringBuilder._defaultPort }];
124
+ }
125
+
126
+ set hosts(value: ChronicleServerAddress[]) {
127
+ this._hosts = value;
128
+ }
129
+
130
+ /**
131
+ * Convenience accessor for the first host in {@link hosts}. Setting it collapses the
132
+ * configuration to that single host, discarding any others previously set via
133
+ * {@link hosts} - the same "single server" reset a plain host/port connection string
134
+ * implies.
135
+ */
45
136
  get host(): string {
46
- return this._properties.get(ChronicleConnectionStringBuilder._hostKey) || 'localhost';
137
+ return this.hosts[0].host;
47
138
  }
48
139
 
49
140
  set host(value: string) {
50
- this._properties.set(ChronicleConnectionStringBuilder._hostKey, value);
141
+ this._hosts = [{ host: value, port: this.hosts[0].port }];
51
142
  }
52
143
 
144
+ /**
145
+ * Convenience accessor for the first host's port in {@link hosts}. Setting it collapses
146
+ * the configuration to that single host, discarding any others previously set via
147
+ * {@link hosts}.
148
+ */
53
149
  get port(): number {
54
- const port = this._properties.get(ChronicleConnectionStringBuilder._portKey);
55
- return port ? parseInt(port, 10) : ChronicleConnectionStringBuilder._defaultPort;
150
+ return this.hosts[0].port;
56
151
  }
57
152
 
58
153
  set port(value: number) {
59
- this._properties.set(ChronicleConnectionStringBuilder._portKey, value.toString());
154
+ this._hosts = [{ host: this.hosts[0].host, port: value }];
60
155
  }
61
156
 
62
157
  get username(): string | undefined {
@@ -93,6 +188,14 @@ export class ChronicleConnectionStringBuilder {
93
188
  this._properties.set(ChronicleConnectionStringBuilder._schemeKey, value);
94
189
  }
95
190
 
191
+ /**
192
+ * Whether the connection string uses the DNS SRV lookup scheme (`chronicle+srv://`).
193
+ * Compared case-insensitively, matching {@link scheme}'s own parsing.
194
+ */
195
+ get isSrv(): boolean {
196
+ return this.scheme.toLowerCase() === SRV_SCHEME;
197
+ }
198
+
96
199
  get authenticationMode(): AuthenticationMode {
97
200
  const hasClientCredentials = !!this.username && !!this.password;
98
201
  const hasApiKey = !!this.apiKey;
@@ -133,6 +236,21 @@ export class ChronicleConnectionStringBuilder {
133
236
  this._properties.set(ChronicleConnectionStringBuilder._disableTlsKey, value.toString());
134
237
  }
135
238
 
239
+ /**
240
+ * Whether TLS certificate validation is skipped for the gRPC channel and OAuth token
241
+ * requests. Distinct from {@link disableTls}, which controls whether TLS is used at
242
+ * all. Defaults to `true` - set to `false` to require full certificate validation
243
+ * whenever TLS is on.
244
+ */
245
+ get skipTlsValidation(): boolean {
246
+ const raw = this._properties.get(ChronicleConnectionStringBuilder._skipTlsValidationKey);
247
+ return raw === undefined || raw === 'true';
248
+ }
249
+
250
+ set skipTlsValidation(value: boolean) {
251
+ this._properties.set(ChronicleConnectionStringBuilder._skipTlsValidationKey, value.toString());
252
+ }
253
+
136
254
  get certificatePath(): string | undefined {
137
255
  return this._properties.get(ChronicleConnectionStringBuilder._certificatePathKey);
138
256
  }
@@ -159,7 +277,52 @@ export class ChronicleConnectionStringBuilder {
159
277
  this._properties.delete(ChronicleConnectionStringBuilder._certificatePasswordKey);
160
278
  }
161
279
 
280
+ /**
281
+ * The strategy used to select one address from {@link hosts} (or the addresses
282
+ * resolved from a `chronicle+srv://` host) on every connect/reconnect attempt.
283
+ * Defaults to {@link LoadBalancerMode.LeastConnections}.
284
+ */
285
+ get loadBalancer(): LoadBalancerMode {
286
+ const raw = this._properties.get(ChronicleConnectionStringBuilder._loadBalancerKey);
287
+ if (!raw) {
288
+ return LoadBalancerMode.LeastConnections;
289
+ }
290
+
291
+ const knownModes = Object.values(LoadBalancerMode) as string[];
292
+ if (!knownModes.includes(raw)) {
293
+ throw new Error(`Unknown loadBalancer strategy '${raw}'. Expected one of: ${knownModes.join(', ')}`);
294
+ }
295
+
296
+ return raw as LoadBalancerMode;
297
+ }
298
+
299
+ set loadBalancer(value: LoadBalancerMode) {
300
+ this._properties.set(ChronicleConnectionStringBuilder._loadBalancerKey, value);
301
+ }
302
+
303
+ /**
304
+ * The `host[:port]` DNS name server used to resolve `chronicle+srv://` SRV records.
305
+ * Defaults to port 53 when no port is given. Undefined uses the system default
306
+ * resolver.
307
+ */
308
+ get srvNameServer(): string | undefined {
309
+ return this._properties.get(ChronicleConnectionStringBuilder._srvNameServerKey);
310
+ }
311
+
312
+ set srvNameServer(value: string | undefined) {
313
+ if (value) {
314
+ this._properties.set(ChronicleConnectionStringBuilder._srvNameServerKey, value);
315
+ return;
316
+ }
317
+
318
+ this._properties.delete(ChronicleConnectionStringBuilder._srvNameServerKey);
319
+ }
320
+
162
321
  build(): string {
322
+ if (this.isSrv && this.hosts.length > 1) {
323
+ throw new Error(`${SRV_SCHEME} connection strings support only a single host, found ${this.hosts.length}`);
324
+ }
325
+
163
326
  let result = `${this.scheme}://`;
164
327
 
165
328
  if (this.username) {
@@ -170,8 +333,7 @@ export class ChronicleConnectionStringBuilder {
170
333
  result += '@';
171
334
  }
172
335
 
173
- result += this.host;
174
- result += `:${this.port}`;
336
+ result += this.hosts.map(formatServerAddress).join(',');
175
337
 
176
338
  const queryParameters: string[] = [];
177
339
 
@@ -183,6 +345,10 @@ export class ChronicleConnectionStringBuilder {
183
345
  queryParameters.push('disableTls=true');
184
346
  }
185
347
 
348
+ if (!this.skipTlsValidation) {
349
+ queryParameters.push('skipTlsValidation=false');
350
+ }
351
+
186
352
  if (this.certificatePath) {
187
353
  queryParameters.push(`certificatePath=${encodeURIComponent(this.certificatePath)}`);
188
354
  }
@@ -191,17 +357,26 @@ export class ChronicleConnectionStringBuilder {
191
357
  queryParameters.push(`certificatePassword=${encodeURIComponent(this.certificatePassword)}`);
192
358
  }
193
359
 
360
+ if (this._properties.has(ChronicleConnectionStringBuilder._loadBalancerKey)) {
361
+ queryParameters.push(`loadBalancer=${encodeURIComponent(this.loadBalancer)}`);
362
+ }
363
+
364
+ if (this.srvNameServer) {
365
+ queryParameters.push(`srvNameServer=${encodeURIComponent(this.srvNameServer)}`);
366
+ }
367
+
194
368
  for (const [key, value] of this._properties) {
195
369
  if (
196
- key !== ChronicleConnectionStringBuilder._hostKey &&
197
- key !== ChronicleConnectionStringBuilder._portKey &&
198
370
  key !== ChronicleConnectionStringBuilder._usernameKey &&
199
371
  key !== ChronicleConnectionStringBuilder._passwordKey &&
200
372
  key !== ChronicleConnectionStringBuilder._schemeKey &&
201
373
  key !== ChronicleConnectionStringBuilder._apiKeyKey &&
202
374
  key !== ChronicleConnectionStringBuilder._disableTlsKey &&
375
+ key !== ChronicleConnectionStringBuilder._skipTlsValidationKey &&
203
376
  key !== ChronicleConnectionStringBuilder._certificatePathKey &&
204
- key !== ChronicleConnectionStringBuilder._certificatePasswordKey
377
+ key !== ChronicleConnectionStringBuilder._certificatePasswordKey &&
378
+ key !== ChronicleConnectionStringBuilder._loadBalancerKey &&
379
+ key !== ChronicleConnectionStringBuilder._srvNameServerKey
205
380
  ) {
206
381
  queryParameters.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
207
382
  }
@@ -215,32 +390,54 @@ export class ChronicleConnectionStringBuilder {
215
390
  }
216
391
 
217
392
  private parseConnectionString(connectionString: string): void {
218
- if (
219
- connectionString.startsWith('chronicle://') ||
220
- connectionString.startsWith('chronicle+srv://')
221
- ) {
393
+ const lowerCased = connectionString.toLowerCase();
394
+ if (lowerCased.startsWith('chronicle://') || lowerCased.startsWith(`${SRV_SCHEME}://`)) {
222
395
  this.parseUrl(connectionString);
223
396
  }
224
397
  }
225
398
 
226
399
  private parseUrl(value: string): void {
227
400
  try {
228
- const parsed = new URL(value);
229
- this.scheme = parsed.protocol.replace(':', '');
230
- this.host = parsed.hostname;
231
-
232
- if (parsed.port) {
233
- this.port = parseInt(parsed.port, 10);
401
+ const schemeSeparatorIndex = value.indexOf('://');
402
+ const scheme = value.slice(0, schemeSeparatorIndex);
403
+ this.scheme = scheme;
404
+
405
+ const rest = value.slice(schemeSeparatorIndex + 3);
406
+
407
+ // The authority (userinfo + hosts) ends at the first '/' or '?'; parsing tolerates
408
+ // an optional trailing slash immediately before the query string this way.
409
+ const pathIndex = [rest.indexOf('/'), rest.indexOf('?')]
410
+ .filter(index => index >= 0)
411
+ .sort((first, second) => first - second)[0];
412
+ const authority = pathIndex === undefined ? rest : rest.slice(0, pathIndex);
413
+ let query = '';
414
+ if (pathIndex !== undefined) {
415
+ const queryIndex = rest.indexOf('?', pathIndex);
416
+ query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
234
417
  }
235
418
 
236
- if (parsed.username) {
237
- this.username = decodeURIComponent(parsed.username);
238
- if (parsed.password) {
239
- this.password = decodeURIComponent(parsed.password);
419
+ // The userinfo separator is the LAST '@' in the authority, so a host portion never
420
+ // legitimately containing '@' can't be confused for one.
421
+ const authoritySeparatorIndex = authority.lastIndexOf('@');
422
+ let hostList = authority;
423
+ if (authoritySeparatorIndex >= 0) {
424
+ const userInfo = authority.slice(0, authoritySeparatorIndex);
425
+ hostList = authority.slice(authoritySeparatorIndex + 1);
426
+
427
+ const parts = userInfo.split(':');
428
+ this.username = decodeURIComponent(parts[0]);
429
+ if (parts.length > 1) {
430
+ this.password = decodeURIComponent(parts[1]);
240
431
  }
241
432
  }
242
433
 
243
- for (const [key, queryValue] of new URLSearchParams(parsed.search)) {
434
+ const hosts = splitHostList(hostList).map(parseHostAndPort);
435
+ if (this.isSrv && hosts.length > 1) {
436
+ throw new Error(`${SRV_SCHEME} connection strings support only a single host, found ${hosts.length}`);
437
+ }
438
+ this.hosts = hosts;
439
+
440
+ for (const [key, queryValue] of new URLSearchParams(query)) {
244
441
  this._properties.set(key, decodeURIComponent(queryValue));
245
442
  }
246
443
  } catch (error) {
@@ -256,23 +453,49 @@ export class ChronicleConnectionString {
256
453
  static readonly DEVELOPMENT_CLIENT = 'chronicle-dev-client';
257
454
  static readonly DEVELOPMENT_CLIENT_SECRET = 'chronicle-dev-secret';
258
455
  static readonly Default = new ChronicleConnectionString('chronicle://localhost:35000');
456
+ // skipTlsValidation defaults to true, so no explicit query parameter is needed here for
457
+ // this to connect to the Kernel's self-signed development certificate.
259
458
  static readonly Development = new ChronicleConnectionString(
260
459
  `chronicle://${ChronicleConnectionString.DEVELOPMENT_CLIENT}:${ChronicleConnectionString.DEVELOPMENT_CLIENT_SECRET}@localhost:35000`
261
460
  );
262
461
 
263
462
  private readonly _builder: ChronicleConnectionStringBuilder;
264
- private readonly _serverAddress: ChronicleServerAddress;
463
+ private readonly _serverAddresses: ChronicleServerAddress[];
265
464
 
266
465
  constructor(connectionString: string) {
267
466
  this._builder = new ChronicleConnectionStringBuilder(connectionString);
268
- this._serverAddress = {
269
- host: this._builder.host,
270
- port: this._builder.port
271
- };
467
+ this._serverAddresses = this._builder.hosts;
468
+ }
469
+
470
+ /**
471
+ * The connection string's scheme, either `chronicle` or `chronicle+srv`.
472
+ */
473
+ get scheme(): string {
474
+ return this._builder.scheme;
272
475
  }
273
476
 
477
+ /**
478
+ * Whether the connection string uses the DNS SRV lookup scheme (`chronicle+srv://`).
479
+ */
480
+ get isSrv(): boolean {
481
+ return this._builder.isSrv;
482
+ }
483
+
484
+ /**
485
+ * Convenience accessor for the first entry in {@link serverAddresses}, for callers
486
+ * that only ever expect a single server address.
487
+ */
274
488
  get serverAddress(): ChronicleServerAddress {
275
- return this._serverAddress;
489
+ return this._serverAddresses[0];
490
+ }
491
+
492
+ /**
493
+ * All server addresses parsed from a multi-host `chronicle://` connection string.
494
+ * For `chronicle+srv://` connection strings, this holds the single SRV lookup host,
495
+ * not the resolved addresses - resolve those via {@link ChronicleServerAddressResolver}.
496
+ */
497
+ get serverAddresses(): ChronicleServerAddress[] {
498
+ return this._serverAddresses;
276
499
  }
277
500
 
278
501
  get username(): string | undefined {
@@ -295,6 +518,14 @@ export class ChronicleConnectionString {
295
518
  return this._builder.disableTls;
296
519
  }
297
520
 
521
+ /**
522
+ * Whether TLS certificate validation is skipped. See
523
+ * {@link ChronicleConnectionStringBuilder.skipTlsValidation}.
524
+ */
525
+ get skipTlsValidation(): boolean {
526
+ return this._builder.skipTlsValidation;
527
+ }
528
+
298
529
  get certificatePath(): string | undefined {
299
530
  return this._builder.certificatePath;
300
531
  }
@@ -303,6 +534,20 @@ export class ChronicleConnectionString {
303
534
  return this._builder.certificatePassword;
304
535
  }
305
536
 
537
+ /**
538
+ * The strategy used to select one server address on every connect/reconnect attempt.
539
+ */
540
+ get loadBalancer(): LoadBalancerMode {
541
+ return this._builder.loadBalancer;
542
+ }
543
+
544
+ /**
545
+ * The `host[:port]` DNS name server used to resolve `chronicle+srv://` SRV records.
546
+ */
547
+ get srvNameServer(): string | undefined {
548
+ return this._builder.srvNameServer;
549
+ }
550
+
306
551
  withCredentials(username: string, password: string): ChronicleConnectionString {
307
552
  const builder = new ChronicleConnectionStringBuilder(this.toString());
308
553
  builder.username = username;
@@ -321,10 +566,19 @@ export class ChronicleConnectionString {
321
566
  return grpc.credentials.createInsecure();
322
567
  }
323
568
 
569
+ if (this.skipTlsValidation) {
570
+ // Chronicle generates a self-signed certificate in development, and skipTlsValidation
571
+ // defaults to true so this works without extra configuration. grpc-js has no
572
+ // per-error hook to accept only self-signed/untrusted-root chains the way a
573
+ // finer-grained validator would, so skipTlsValidation bypasses chain validation
574
+ // entirely. Set skipTlsValidation=false for a server with a verifiable certificate.
575
+ return grpc.credentials.createSsl(null, null, null, { rejectUnauthorized: false });
576
+ }
577
+
324
578
  return grpc.credentials.createSsl();
325
579
  }
326
580
 
327
581
  toString(): string {
328
582
  return this._builder.build();
329
583
  }
330
- }
584
+ }
@@ -0,0 +1,41 @@
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 { ChronicleConnectionString } from './ChronicleConnectionString';
6
+ import { ChronicleServerAddressResolver } from './ChronicleServerAddressResolver';
7
+ import type { ChronicleSrvResolver } from './ChronicleSrvResolver';
8
+
9
+ describe('ChronicleServerAddressResolver', () => {
10
+ describe('when resolving a plain multi-host connection string', () => {
11
+ const fakeSrvResolver = { resolve: vi.fn() };
12
+ const resolver = new ChronicleServerAddressResolver(fakeSrvResolver as unknown as ChronicleSrvResolver);
13
+ const connectionString = new ChronicleConnectionString('chronicle://host1:35001,host2:35002');
14
+
15
+ it('should pass through the parsed hosts unchanged', async () => {
16
+ const addresses = await resolver.resolve(connectionString);
17
+ expect(addresses).toEqual([{ host: 'host1', port: 35001 }, { host: 'host2', port: 35002 }]);
18
+ });
19
+
20
+ it('should never touch DNS', async () => {
21
+ await resolver.resolve(connectionString);
22
+ expect(fakeSrvResolver.resolve).not.toHaveBeenCalled();
23
+ });
24
+ });
25
+
26
+ describe('when resolving a chronicle+srv connection string', () => {
27
+ const fakeSrvResolver = { resolve: vi.fn().mockResolvedValue([{ host: 'node-a', port: 35001 }, { host: 'node-b', port: 35002 }]) };
28
+ const resolver = new ChronicleServerAddressResolver(fakeSrvResolver as unknown as ChronicleSrvResolver);
29
+ const connectionString = new ChronicleConnectionString('chronicle+srv://cluster.example.com/?srvNameServer=127.0.0.1:5353');
30
+
31
+ it('should delegate to the srv resolver with the srv host and name server', async () => {
32
+ await resolver.resolve(connectionString);
33
+ expect(fakeSrvResolver.resolve).toHaveBeenCalledWith('cluster.example.com', '127.0.0.1:5353');
34
+ });
35
+
36
+ it('should return the resolved addresses', async () => {
37
+ const addresses = await resolver.resolve(connectionString);
38
+ expect(addresses).toEqual([{ host: 'node-a', port: 35001 }, { host: 'node-b', port: 35002 }]);
39
+ });
40
+ });
41
+ });
@@ -0,0 +1,33 @@
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 type { ChronicleConnectionString, ChronicleServerAddress } from './ChronicleConnectionString';
5
+ import { ChronicleSrvResolver } from './ChronicleSrvResolver';
6
+
7
+ /**
8
+ * Resolves the effective list of Chronicle server addresses for a connection string.
9
+ * `chronicle+srv://` connection strings are resolved via DNS SRV lookup on every call;
10
+ * plain `chronicle://` connection strings pass their parsed host list through unchanged
11
+ * and never touch DNS.
12
+ */
13
+ export class ChronicleServerAddressResolver {
14
+ /**
15
+ * Initializes a new instance of {@link ChronicleServerAddressResolver}.
16
+ * @param _srvResolver - The SRV resolver used for `chronicle+srv://` connection strings.
17
+ */
18
+ constructor(private readonly _srvResolver: ChronicleSrvResolver = new ChronicleSrvResolver()) {}
19
+
20
+ /**
21
+ * Resolves the server addresses for a connection string.
22
+ * @param connectionString - The connection string to resolve addresses for.
23
+ * @returns The resolved addresses.
24
+ */
25
+ async resolve(connectionString: ChronicleConnectionString): Promise<ChronicleServerAddress[]> {
26
+ if (!connectionString.isSrv) {
27
+ return connectionString.serverAddresses;
28
+ }
29
+
30
+ const [srvHost] = connectionString.serverAddresses;
31
+ return this._srvResolver.resolve(srvHost.host, connectionString.srvNameServer);
32
+ }
33
+ }
@@ -0,0 +1,13 @@
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
+ /**
5
+ * Thrown when a `chronicle+srv://` connection string's DNS zone publishes no SRV records
6
+ * for the queried name.
7
+ */
8
+ export class ChronicleSrvResolutionError extends Error {
9
+ constructor(name: string) {
10
+ super(`No SRV records found for '${name}'. Verify the DNS zone publishes _chronicle._tcp SRV records for this host.`);
11
+ this.name = 'ChronicleSrvResolutionError';
12
+ }
13
+ }
@@ -0,0 +1,103 @@
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 type { SrvRecord } from 'dns';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { ChronicleSrvResolutionError } from './ChronicleSrvResolutionError';
7
+ import { ChronicleSrvResolver } from './ChronicleSrvResolver';
8
+
9
+ function createFakeResolver(records: SrvRecord[]) {
10
+ const setServers = vi.fn();
11
+ const resolveSrv = vi.fn().mockResolvedValue(records);
12
+ return { setServers, resolveSrv };
13
+ }
14
+
15
+ describe('ChronicleSrvResolver', () => {
16
+ describe('when resolving a host with multiple srv records', () => {
17
+ const records: SrvRecord[] = [
18
+ { name: 'node-b.example.com', port: 35002, priority: 10, weight: 5 },
19
+ { name: 'node-a.example.com', port: 35001, priority: 0, weight: 5 },
20
+ { name: 'node-c.example.com', port: 35003, priority: 0, weight: 10 }
21
+ ];
22
+ const fakeResolver = createFakeResolver(records);
23
+ const resolver = new ChronicleSrvResolver(() => fakeResolver as never);
24
+
25
+ it('should query the _chronicle._tcp SRV name', async () => {
26
+ await resolver.resolve('cluster.example.com');
27
+ expect(fakeResolver.resolveSrv).toHaveBeenCalledWith('_chronicle._tcp.cluster.example.com');
28
+ });
29
+
30
+ it('should sort ascending by priority then descending by weight', async () => {
31
+ const addresses = await resolver.resolve('cluster.example.com');
32
+ expect(addresses).toEqual([
33
+ { host: 'node-c.example.com', port: 35003 },
34
+ { host: 'node-a.example.com', port: 35001 },
35
+ { host: 'node-b.example.com', port: 35002 }
36
+ ]);
37
+ });
38
+ });
39
+
40
+ describe('when a srv target has a trailing dot', () => {
41
+ const fakeResolver = createFakeResolver([{ name: 'node-a.example.com.', port: 35001, priority: 0, weight: 0 }]);
42
+ const resolver = new ChronicleSrvResolver(() => fakeResolver as never);
43
+
44
+ it('should strip the trailing dot', async () => {
45
+ const addresses = await resolver.resolve('cluster.example.com');
46
+ expect(addresses[0].host).toBe('node-a.example.com');
47
+ });
48
+ });
49
+
50
+ describe('when no srv records are found', () => {
51
+ const fakeResolver = createFakeResolver([]);
52
+ const resolver = new ChronicleSrvResolver(() => fakeResolver as never);
53
+
54
+ it('should throw a ChronicleSrvResolutionError', async () => {
55
+ await expect(resolver.resolve('cluster.example.com')).rejects.toThrow(ChronicleSrvResolutionError);
56
+ });
57
+ });
58
+
59
+ describe('when a srvNameServer is specified without a port', () => {
60
+ const fakeResolver = createFakeResolver([{ name: 'node-a.example.com', port: 35001, priority: 0, weight: 0 }]);
61
+ const resolver = new ChronicleSrvResolver(() => fakeResolver as never);
62
+
63
+ it('should default the name server port to 53', async () => {
64
+ await resolver.resolve('cluster.example.com', '127.0.0.1');
65
+ expect(fakeResolver.setServers).toHaveBeenCalledWith(['127.0.0.1:53']);
66
+ });
67
+ });
68
+
69
+ describe('when a srvNameServer is specified with a port', () => {
70
+ const fakeResolver = createFakeResolver([{ name: 'node-a.example.com', port: 35001, priority: 0, weight: 0 }]);
71
+ const resolver = new ChronicleSrvResolver(() => fakeResolver as never);
72
+
73
+ it('should use the given port', async () => {
74
+ await resolver.resolve('cluster.example.com', '127.0.0.1:5353');
75
+ expect(fakeResolver.setServers).toHaveBeenCalledWith(['127.0.0.1:5353']);
76
+ });
77
+ });
78
+
79
+ describe('when no srvNameServer is specified', () => {
80
+ const fakeResolver = createFakeResolver([{ name: 'node-a.example.com', port: 35001, priority: 0, weight: 0 }]);
81
+ const resolver = new ChronicleSrvResolver(() => fakeResolver as never);
82
+
83
+ it('should not configure custom servers', async () => {
84
+ await resolver.resolve('cluster.example.com');
85
+ expect(fakeResolver.setServers).not.toHaveBeenCalled();
86
+ });
87
+ });
88
+
89
+ describe('when resolving twice', () => {
90
+ const records: SrvRecord[] = [{ name: 'node-a.example.com', port: 35001, priority: 0, weight: 0 }];
91
+ let resolverCreationCount = 0;
92
+ const resolver = new ChronicleSrvResolver(() => {
93
+ resolverCreationCount++;
94
+ return createFakeResolver(records) as never;
95
+ });
96
+
97
+ it('should create a fresh resolver for each call, never caching results', async () => {
98
+ await resolver.resolve('cluster.example.com');
99
+ await resolver.resolve('cluster.example.com');
100
+ expect(resolverCreationCount).toBe(2);
101
+ });
102
+ });
103
+ });