@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
|
@@ -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 =
|
|
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.
|
|
137
|
+
return this.hosts[0].host;
|
|
47
138
|
}
|
|
48
139
|
|
|
49
140
|
set host(value: string) {
|
|
50
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
this.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
463
|
+
private readonly _serverAddresses: ChronicleServerAddress[];
|
|
265
464
|
|
|
266
465
|
constructor(connectionString: string) {
|
|
267
466
|
this._builder = new ChronicleConnectionStringBuilder(connectionString);
|
|
268
|
-
this.
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
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,14 +566,19 @@ export class ChronicleConnectionString {
|
|
|
321
566
|
return grpc.credentials.createInsecure();
|
|
322
567
|
}
|
|
323
568
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
|
|
578
|
+
return grpc.credentials.createSsl();
|
|
329
579
|
}
|
|
330
580
|
|
|
331
581
|
toString(): string {
|
|
332
582
|
return this._builder.build();
|
|
333
583
|
}
|
|
334
|
-
}
|
|
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
|
+
});
|