This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 90e38b10f feat(node): support connection string syntax for nodejs
client creation (#3917)
90e38b10f is described below
commit 90e38b10f036ea174f8c049a586e5baadac8c0d5
Author: T1B0 <[email protected]>
AuthorDate: Fri Aug 28 17:58:02 2026 +0200
feat(node): support connection string syntax for nodejs client creation
(#3917)
---
foreign/node/README.md | 39 ++
foreign/node/src/client/client.config.test.ts | 44 +++
foreign/node/src/client/client.config.ts | 33 +-
.../src/client/client.connection-string.test.ts | 310 ++++++++++++++++
.../node/src/client/client.connection-string.ts | 226 ++++++++++++
foreign/node/src/client/client.connection.test.ts | 117 +++++-
foreign/node/src/client/client.connection.ts | 39 +-
foreign/node/src/client/client.socket.ts | 5 +-
foreign/node/src/client/client.ts | 57 ++-
foreign/node/src/client/client.type.ts | 25 +-
foreign/node/src/client/index.ts | 1 +
foreign/node/src/{client/index.ts => constant.ts} | 7 +-
foreign/node/src/duration.utils.test.ts | 180 ++++++++++
foreign/node/src/duration.utils.ts | 393 +++++++++++++++++++++
foreign/node/src/e2e/tcp.connection-string.e2e.ts | 146 ++++++++
foreign/node/src/e2e/tls.system.e2e.ts | 64 ++++
foreign/node/src/stream/consumer-stream.ts | 6 +-
17 files changed, 1629 insertions(+), 63 deletions(-)
diff --git a/foreign/node/README.md b/foreign/node/README.md
index 169500437..5c0f9052f 100644
--- a/foreign/node/README.md
+++ b/foreign/node/README.md
@@ -67,6 +67,8 @@ new session.
The client pings every `heartbeatInterval` milliseconds, 5000 by default, which
keeps an idle session alive when the server's `[heartbeat]` eviction is
enabled.
+`heartbeatInterval` also accepts a duration expression such as `"10s"` or
+`"1h 30m"`, like the Rust SDK.
The server evicts a connection silent for 36 s, which is 1.2 x its 30 s
heartbeat interval. Raising the client interval past that window, or setting it
to 0 to disable client heartbeats, exposes an idle consumer-group member to
@@ -105,6 +107,43 @@ const client = new Client({
const stats = await client.system.getStats();
```
+### Connection strings
+
+Every client constructor (except `SimpleClient` see note) also accepts a
+connection string instead of a config object:
+
+```ts
+import { Client } from "apache-iggy";
+
+const client = new Client("iggy://iggy:[email protected]:8090");
+const stats = await client.system.getStats();
+```
+
+Supported schemes are `iggy://` (TCP, default) and `iggy+tcp://`. Credentials
+are `username:password` or a single personal access token. Options mirror the
+other SDKs: `tls`, `tls_domain`, `tls_ca_file`, `reconnection_retries`,
+`reconnection_interval`, `heartbeat_interval` and `nodelay`.
`reestablish_after`
+is accepted for format compatibility but has no Node equivalent.
+
+note: `SimpleClient` does not accept a connection string: it wraps an existing
+`RawClient` instance rather than building one from configuration. Pass the
+connection string to `Client`, `SingleClient` or `getRawClient` and hand the
+resulting raw client to `SimpleClient` if needed.
+
+### option limits
+
+| option | limit |
+| --- | --- |
+| `reconnection_retries` | integer up to `4294967295` (u32 max); larger values
are rejected like Rust's u32 overflow, and `unlimited` maps to this ceiling.
Defaults to unlimited |
+| `heartbeat_interval` | duration up to `2147483647ms` (Node's largest timer
delay); `0` disables heartbeats |
+| `reconnection_interval` | positive duration (`ms`, `s`, `m`, `h`) up to
`2147483647ms` (Node's largest timer delay); zero spellings are rejected.
Defaults to `1s` |
+| port in the authority | decimal up to `65535` |
+
+Durations accept the same expressions as the Rust SDK, for example `500ms`,
+`10s`, `1h 30m`, `5d`, `2w`, `1y`; matching is case-insensitive and
+`0`, `unlimited`, `disabled` and `none` map to zero. Unit-less numbers such as
+`5` are rejected.
+
## use sources
### Install
diff --git a/foreign/node/src/client/client.config.test.ts
b/foreign/node/src/client/client.config.test.ts
index 9022ab080..f2404c703 100644
--- a/foreign/node/src/client/client.config.test.ts
+++ b/foreign/node/src/client/client.config.test.ts
@@ -17,6 +17,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
+import { MAX_U32 } from '../constant.js';
import type { ClientConfig } from './client.type.js';
import {
DEFAULT_HEARTBEAT_INTERVAL,
@@ -74,6 +75,49 @@ describe('normalizeClientConfig', () => {
);
});
+ it('accepts a usable reconnect interval', () => {
+ const reconnect = { enabled: true, interval: 1000, maxRetries: 3 };
+ assert.deepEqual(
+ normalizeClientConfig({ ...config(), reconnect }).reconnect,
+ reconnect
+ );
+ });
+
+ it('rejects unusable reconnect intervals', () => {
+ for (const interval of [
+ 0, -1000, Number.NaN, 1.5, 2_147_483_648, Number.MAX_VALUE
+ ])
+ assert.throws(
+ () => normalizeClientConfig({
+ ...config(),
+ reconnect: { enabled: true, interval, maxRetries: 1 }
+ }),
+ /reconnect\.interval/
+ );
+ });
+
+ it('skips the interval check when reconnect is disabled', () => {
+ assert.doesNotThrow(() =>
+ normalizeClientConfig({
+ ...config(),
+ reconnect: { enabled: false, interval: 0, maxRetries: 0 }
+ })
+ );
+ });
+
+ it('rejects unusable reconnect maxRetries', () => {
+ for (const maxRetries of [
+ -1, Number.NaN, 1.5, MAX_U32 + 1, Number.MAX_VALUE
+ ])
+ assert.throws(
+ () => normalizeClientConfig({
+ ...config(),
+ reconnect: { enabled: true, interval: 1000, maxRetries }
+ }),
+ /reconnect\.maxRetries/
+ );
+ });
+
it('restricts the client to one pooled connection', () => {
const normalized = normalizeClientConfig(config());
assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
diff --git a/foreign/node/src/client/client.config.ts
b/foreign/node/src/client/client.config.ts
index 7c94bdbe4..8fe48e658 100644
--- a/foreign/node/src/client/client.config.ts
+++ b/foreign/node/src/client/client.config.ts
@@ -15,7 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-import type { ClientConfig } from './client.type.js';
+import { MAX_U32 } from '../constant.js';
+import type { ClientConfig, ClientConfigOrString } from './client.type.js';
+import { parseConnectionString } from './client.connection-string.js';
export const DEFAULT_MAX_RESPONSE_FRAME_SIZE = 64 * 1024 * 1024;
@@ -29,8 +31,11 @@ export const DEFAULT_HEARTBEAT_INTERVAL = 5 * 1000;
export const MAX_HEARTBEAT_INTERVAL = 2_147_483_647;
export const normalizeClientConfig = (
- config: ClientConfig
+ config: ClientConfigOrString
): ClientConfig => {
+ if (typeof config === 'string')
+ config = parseConnectionString(config);
+
const maxResponseFrameSize =
config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE;
if (!Number.isSafeInteger(maxResponseFrameSize) ||
@@ -57,6 +62,30 @@ export const normalizeClientConfig = (
`heartbeatInterval must be a safe integer of milliseconds between 0 and
${MAX_HEARTBEAT_INTERVAL} (0 disables heartbeats)`
);
+ // Unlike the heartbeat, 0 is not a disable here: an immediate retry delay
+ // turns reconnection into a hot loop. The ceiling guards the same
+ // setInterval clamp, which would turn a long backoff into a 1 ms spin.
+ // A disabled reconnect never schedules a retry, so the interval check
+ // applies only when enabled; callers commonly pass a zero interval with
+ // enabled: false. maxRetries stays bounded either way, matching the
+ // connection-string path.
+ if (config.reconnect !== undefined) {
+ const { enabled, interval, maxRetries } = config.reconnect;
+ if (!Number.isSafeInteger(maxRetries) ||
+ maxRetries < 0 ||
+ maxRetries > MAX_U32)
+ throw new TypeError(
+ `reconnect.maxRetries must be a non-negative integer of at most
${MAX_U32}`
+ );
+ if (enabled &&
+ (!Number.isSafeInteger(interval) ||
+ interval < 1 ||
+ interval > MAX_HEARTBEAT_INTERVAL))
+ throw new TypeError(
+ `reconnect.interval must be a safe integer of milliseconds between 1
and ${MAX_HEARTBEAT_INTERVAL}`
+ );
+ }
+
return {
...config,
options: { ...config.options },
diff --git a/foreign/node/src/client/client.connection-string.test.ts
b/foreign/node/src/client/client.connection-string.test.ts
new file mode 100644
index 000000000..09198e781
--- /dev/null
+++ b/foreign/node/src/client/client.connection-string.test.ts
@@ -0,0 +1,310 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { MAX_U32 } from '../constant.js';
+import {
+ parseConnectionString,
+ parseDuration
+} from './client.connection-string.js';
+import {
+ DEFAULT_HEARTBEAT_INTERVAL,
+ normalizeClientConfig
+} from './client.config.js';
+
+describe('parseConnectionString', () => {
+ it('parses the default scheme with password credentials', () => {
+ assert.deepEqual(
+ parseConnectionString('iggy://iggy:[email protected]:8090'),
+ {
+ transport: 'TCP',
+ options: { host: '127.0.0.1', port: 8090 },
+ credentials: { username: 'iggy', password: 'secret' },
+ reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 }
+ }
+ );
+ });
+
+ it('parses the explicit tcp scheme with a personal access token', () => {
+ assert.deepEqual(
+
parseConnectionString('iggy+tcp://iggypat-1234567890abcdef@localhost:8090'),
+ {
+ transport: 'TCP',
+ options: { host: 'localhost', port: 8090 },
+ credentials: { token: 'iggypat-1234567890abcdef' },
+ reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 }
+ }
+ );
+ });
+
+ it('maps tls options to the TLS transport', () => {
+ assert.deepEqual(
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090?tls=true&tls_domain=iggy.apache.org'
+ ),
+ {
+ transport: 'TLS',
+ options: {
+ host: 'localhost',
+ port: 8090,
+ servername: 'iggy.apache.org'
+ },
+ credentials: { username: 'iggy', password: 'secret' },
+ reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 }
+ }
+ );
+ });
+
+ it('maps reconnection and heartbeat options', () => {
+ assert.deepEqual(
+ parseConnectionString(
+ 'iggy+tcp://iggy:secret@localhost:8090' +
+
'?reconnection_retries=3&reconnection_interval=5s&heartbeat_interval=10s'
+ ),
+ {
+ transport: 'TCP',
+ options: { host: 'localhost', port: 8090 },
+ credentials: { username: 'iggy', password: 'secret' },
+ reconnect: {
+ enabled: true,
+ maxRetries: 3,
+ interval: 5000
+ },
+ heartbeatInterval: 10000
+ }
+ );
+ });
+
+ it('applies unlimited/1s reconnection defaults to partial options', () => {
+ // retries alone keep the 1s interval; interval alone keeps unlimited.
+ assert.deepEqual(
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090?reconnection_retries=3'
+ ).reconnect,
+ { enabled: true, interval: 1000, maxRetries: 3 }
+ );
+ assert.deepEqual(
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090?reconnection_interval=5s'
+ ).reconnect,
+ { enabled: true, interval: 5000, maxRetries: MAX_U32 }
+ );
+ });
+
+ it('maps nodelay to the socket option', () => {
+ assert.equal(
+ parseConnectionString('iggy://iggy:secret@localhost:8090?nodelay=true')
+ .options.noDelay,
+ true
+ );
+ });
+
+ it('maps unlimited retries to the u32 ceiling', () => {
+ assert.equal(
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090?reconnection_retries=unlimited'
+ ).reconnect?.maxRetries,
+ MAX_U32
+ );
+ });
+
+ it('accepts retry counts up to u32::MAX and rejects overflow', () => {
+ assert.equal(
+ parseConnectionString(
+ `iggy://iggy:secret@localhost:8090?reconnection_retries=${MAX_U32}`
+ ).reconnect?.maxRetries,
+ MAX_U32
+ );
+ for (const value of [
+ 'iggy://iggy:secret@localhost:8090?reconnection_retries=4294967296',
+ 'iggy://iggy:secret@localhost:8090?reconnection_retries=99999999999999'
+ ])
+ assert.throws(() => parseConnectionString(value), TypeError);
+ });
+
+ it('rejects a non-positive reconnection interval', () => {
+ // Zero spellings parse but are rejected by the positivity bound.
+ for (const value of ['0', '0ms', 'none'])
+ assert.throws(
+ () =>
+ parseConnectionString(
+ `iggy://iggy:secret@localhost:8090?reconnection_interval=${value}`
+ ),
+ /must be positive/
+ );
+ // Negative durations shall not parse
+ assert.throws(
+ () =>
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090?reconnection_interval=-1s'
+ ),
+ TypeError
+ );
+ });
+
+ it('ignores reestablish_after for format compatibility', () => {
+ assert.deepEqual(
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090?reestablish_after=10s'
+ ),
+ {
+ transport: 'TCP',
+ options: { host: 'localhost', port: 8090 },
+ credentials: { username: 'iggy', password: 'secret' },
+ reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 }
+ }
+ );
+ });
+
+ it('rejects unsupported transports', () => {
+ for (const value of [
+ 'iggy+quic://iggy:secret@localhost:8090',
+ 'iggy+ws://iggy:secret@localhost:8090'
+ ])
+ assert.throws(
+ () => parseConnectionString(value),
+ /unsupported transport/
+ );
+ });
+
+ it('rejects malformed connection strings', () => {
+ for (const value of [
+ '',
+ 'iggy',
+ 'iggy://',
+ 'iggy://:secret@localhost:8090',
+ 'iggy://iggy:@localhost:8090',
+ 'iggy://iggy:secret@localhost',
+ 'iggy://iggy:secret@:8090',
+ 'iggy://iggy:secret@localhost:port',
+ 'iggy://iggy:secret@localhost:70000',
+ 'iggy://iggy:secret@localhost:8090?unknown=value',
+ 'iggy://iggy:secret@localhost:8090?tls=maybe',
+ 'iggy://iggy:secret@localhost:8090?reconnection_retries=three',
+ 'iggy://iggy:secret@[::1:8090',
+ 'iggy://iggy:secret@[]:8090',
+ 'iggy://iggy:secret@[::1]x:8090',
+ 'iggy://iggy:secret@2001:db8::1:8090',
+ 'iggy://iggy:secret@host:8090:9090',
+ 'iggy://iggy:secret@localhost:8090?',
+ 'iggy://iggy:secret@localhost:8090?&',
+ 'iggy://iggy:secret@localhost:8090?reestablish_after=garbage'
+ ])
+ assert.throws(() => parseConnectionString(value), TypeError);
+ });
+
+ it('never includes the connection string in error messages', () => {
+ const secrets = ['hunter2', 'iggypat-1234567890abcdef'];
+ for (const value of [
+ 'iggy://iggy:hunter2@localhost',
+ `iggy+tcp://iggypat-1234567890abcdef@localhost`,
+ 'iggy://iggy:hunter2@localhost:8090?unknown=value',
+ 'iggy://iggy:hunter2@localhost:8090?tls=maybe',
+ 'iggy://iggy:hunter2@localhost:8090?reconnection_retries=three',
+ 'iggy://iggy:hunter2@localhost:70000'
+ ]) {
+ try {
+ parseConnectionString(value);
+ assert.fail(`expected "${value}" to be rejected`);
+ } catch (error) {
+ assert.ok(error instanceof TypeError);
+ for (const secret of secrets)
+ assert.ok(
+ !error.message.includes(secret),
+ `error message leaked a secret: ${error.message}`
+ );
+ }
+ }
+ });
+
+ it('parses IPv6 host addresses without their brackets', () => {
+ assert.deepEqual(
+ parseConnectionString('iggy://iggy:secret@[::1]:8090').options,
+ { host: '::1', port: 8090 }
+ );
+ });
+
+ it('stores tls_ca_file as a path without reading it at parse time', () => {
+ assert.deepEqual(
+ parseConnectionString(
+ 'iggy://iggy:secret@localhost:8090' +
+ '?tls=true&tls_ca_file=/does/not/exist.pem'
+ ).options,
+ {
+ host: 'localhost',
+ port: 8090,
+ caFile: '/does/not/exist.pem'
+ }
+ );
+ });
+});
+
+describe('parseDuration', () => {
+ it('converts supported units to milliseconds', () => {
+ assert.equal(parseDuration('500ms'), 500);
+ assert.equal(parseDuration('5s'), 5000);
+ assert.equal(parseDuration('2m'), 120000);
+ assert.equal(parseDuration('1h'), 3600000);
+ assert.equal(parseDuration('0.5s'), 500);
+ assert.equal(parseDuration('1h 1m 1s'), 3661000);
+ assert.equal(parseDuration('1h30m'), 5400000);
+ assert.equal(parseDuration('5d'), 432000000);
+ assert.equal(parseDuration('2w'), 1209600000);
+ assert.equal(parseDuration('1y'), 31557600000);
+ assert.equal(parseDuration('5sec'), 5000);
+ assert.equal(parseDuration('5msec'), 5);
+ // Fractional results are rounded to whole milliseconds.
+ assert.equal(parseDuration('1.005s'), 1005);
+ assert.equal(parseDuration('5usec'), 0);
+ assert.equal(parseDuration('500nsec'), 0);
+ for (const zero of ['0', 'unlimited', 'disabled', 'none', 'UNLIMITED'])
+ assert.equal(parseDuration(zero), 0);
+ });
+
+ it('rejects unsupported durations', () => {
+ for (const value of ['5', '-1s', 'ms', '', 'abc', 's'])
+ assert.throws(() => parseDuration(value), /invalid duration/);
+ });
+});
+
+describe('normalizeClientConfig with connection strings', () => {
+ it('applies client defaults to the parsed config', () => {
+ const normalized =
normalizeClientConfig('iggy://iggy:secret@localhost:8090');
+
+ assert.equal(normalized.transport, 'TCP');
+ assert.equal(normalized.options.host, 'localhost');
+ assert.equal(normalized.options.port, 8090);
+ assert.deepEqual(normalized.credentials, {
+ username: 'iggy',
+ password: 'secret'
+ });
+ assert.equal(normalized.heartbeatInterval, DEFAULT_HEARTBEAT_INTERVAL);
+ assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
+ });
+
+ it('rejects reconnect intervals beyond the node timer ceiling', () => {
+ // Parses to 3_600_000_000 ms; setInterval would clamp it back to 1 ms.
+ assert.throws(
+ () =>
+ normalizeClientConfig(
+ 'iggy://iggy:secret@localhost:8090?reconnection_interval=1000h'
+ ),
+ /reconnect\.interval/
+ );
+ });
+});
diff --git a/foreign/node/src/client/client.connection-string.ts
b/foreign/node/src/client/client.connection-string.ts
new file mode 100644
index 000000000..75d0b67e2
--- /dev/null
+++ b/foreign/node/src/client/client.connection-string.ts
@@ -0,0 +1,226 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { MAX_U32 } from '../constant.js';
+import {
+ nanosecondsToMilliseconds,
+ parseIggyDurationNanoseconds
+} from '../duration.utils.js';
+import type { ClientConfig, ReconnectOption } from './client.type.js';
+
+const DEFAULT_PROTOCOL = 'iggy';
+const SCHEME_PREFIX = 'iggy+';
+const SUPPORTED_PROTOCOLS = ['tcp'] as const;
+
+/** Reconnection defaults carried by every connection string.
+ *
+ * Mirrors TcpConnectionStringOptions: retries default to unlimited and the
+ * interval to 1s. Deliberately not DefaultReconnectOption, whose 12/5s pair
+ * only applies to object configs.
+ */
+const CONNECTION_STRING_RECONNECT: ReconnectOption = {
+ enabled: true,
+ interval: 1000,
+ maxRetries: MAX_U32
+};
+
+/** Parses a duration with the exact grammar of the Rust SDK's
+ * `IggyDuration::from_str`, returning whole milliseconds.
+ */
+export const parseDuration = (value: string): number => {
+ try {
+ return nanosecondsToMilliseconds(parseIggyDurationNanoseconds(value));
+ } catch (error) {
+ throw new TypeError(`invalid duration "${value}"`, { cause: error });
+ }
+};
+
+/**
+ * Parses an Iggy connection string into a client configuration.
+ *
+ * Supports `iggy://` and `iggy+tcp://`; the Node SDK implements TCP/TLS only.
+ * Credentials are either `username:password` or a single personal access
+ * token before the `@`. TLS is enabled with `tls=true`.
+ */
+export const parseConnectionString = (connectionString: string): ClientConfig
=> {
+ if (typeof connectionString !== 'string' || connectionString.length === 0)
+ throw new TypeError('connection string must be a non-empty string');
+
+ const protocolParts = connectionString.split('://');
+ if (protocolParts.length !== 2)
+ throw new TypeError('invalid connection string');
+
+ const scheme = protocolParts[0];
+ const protocol = scheme === DEFAULT_PROTOCOL
+ ? 'tcp'
+ : scheme.startsWith(SCHEME_PREFIX)
+ ? scheme.slice(SCHEME_PREFIX.length)
+ : undefined;
+ if (protocol === undefined)
+ throw new TypeError('invalid connection string');
+ if (!SUPPORTED_PROTOCOLS.includes(protocol as (typeof
SUPPORTED_PROTOCOLS)[number]))
+ throw new TypeError(
+ `unsupported transport "${protocol}", Node SDK supports tcp only`
+ );
+
+ const parts = protocolParts[1].split('@');
+ if (parts.length !== 2)
+ throw new TypeError('invalid connection string');
+
+ const credentials = parts[0].split(':');
+ const tokenCredentials = credentials.length === 1;
+ if (!tokenCredentials && credentials.length !== 2)
+ throw new TypeError('invalid connection string');
+
+ const username = credentials[0];
+ const password = credentials[1] ?? '';
+ if (!tokenCredentials && (username.length === 0 || password.length === 0))
+ throw new TypeError('invalid connection string');
+
+ const serverAndOptions = parts[1].split('?');
+ if (serverAndOptions.length > 2)
+ throw new TypeError('invalid connection string');
+
+ const serverAddress = serverAndOptions[0];
+ // One match covers both `[ipv6]:port` and `host:port`, where the
+ // unbracketed host may not contain a colon. Multi-colon authorities such
+ // as `2001:db8::1:8090` or `host:8090:9090` are rejected
+ const addressMatch =
/^(?:\[([^\]]+)\]|([^:\[\]]+)):(\d+)$/.exec(serverAddress);
+ if (!addressMatch)
+ throw new TypeError('invalid connection string');
+
+ const host = addressMatch[1] ?? addressMatch[2];
+ const port = Number(addressMatch[3]);
+ if (port > 65535)
+ throw new TypeError('invalid connection string');
+
+ const options: ParsedConnectionOptions = serverAndOptions.length === 2
+ ? parseConnectionOptions(serverAndOptions[1])
+ : {
+ tls: false,
+ reconnect: { ...CONNECTION_STRING_RECONNECT }
+ };
+ const { tls, reconnect, heartbeatInterval, ...transportOptions } = options;
+
+ const config: ClientConfig = {
+ transport: tls ? 'TLS' : 'TCP',
+ options: {
+ host,
+ port: Number(port),
+ ...transportOptions
+ },
+ credentials: tokenCredentials
+ ? { token: username }
+ : { username, password },
+ // Always present on connection strings: Rust applies its unlimited/1s
+ // reconnection defaults even without query options.
+ reconnect
+ };
+ if (heartbeatInterval !== undefined)
+ config.heartbeatInterval = heartbeatInterval;
+
+ return config;
+};
+
+type ParsedConnectionOptions = {
+ tls: boolean,
+ noDelay?: boolean,
+ servername?: string,
+ /** Path stored for connect-time reading, match Rust SDK. */
+ caFile?: string,
+ reconnect: ReconnectOption,
+ heartbeatInterval?: number
+};
+
+const parseConnectionOptions = (
+ optionsString: string
+): ParsedConnectionOptions => {
+ const parsed: ParsedConnectionOptions = {
+ tls: false,
+ reconnect: { ...CONNECTION_STRING_RECONNECT }
+ };
+ for (const option of optionsString.split('&')) {
+ const optionParts = option.split('=');
+ if (optionParts.length !== 2)
+ throw new TypeError('invalid connection string');
+ const [name, value] = optionParts;
+ switch (name) {
+ case 'tls':
+ parsed.tls = parseBoolean(name, value);
+ break;
+ case 'nodelay':
+ parsed.noDelay = parseBoolean(name, value);
+ break;
+ case 'tls_domain':
+ parsed.servername = value;
+ break;
+ case 'tls_ca_file':
+ // The path is stored unread; the certificate is loaded when the
+ // TLS socket is created, so plain-TCP configs never touch the
+ // filesystem (matches the Rust SDK).
+ parsed.caFile = value;
+ break;
+
+ case 'reconnection_retries': {
+ // Values above u32::MAX are rejected like the Rust SDK's u32
+ // overflow; otherwise they would act as a second, undocumented
+ // spelling of "unlimited".
+ const maxRetries = value === 'unlimited'
+ ? MAX_U32
+ : parseNumber(name, value);
+ if (maxRetries > MAX_U32)
+ throw new TypeError(
+ `option "${name}" must be at most ${MAX_U32}`
+ );
+ parsed.reconnect.maxRetries = maxRetries;
+ break;
+ }
+ case 'reconnection_interval': {
+ const interval = parseDuration(value);
+ // With retries defaulting to unlimited, a zero interval would turn
+ // the reconnect loop into an unbounded hot loop.
+ if (interval <= 0)
+ throw new TypeError(`option "${name}" must be positive`);
+ parsed.reconnect.interval = interval;
+ break;
+ }
+ case 'reestablish_after':
+ // No Node equivalent: validated as a duration like the Rust SDK
+ // does, then discarded.
+ parseDuration(value);
+ break;
+ case 'heartbeat_interval':
+ parsed.heartbeatInterval = parseDuration(value);
+ break;
+ default:
+ throw new TypeError(`unknown option "${name}"`);
+ }
+ }
+ return parsed;
+};
+
+const parseBoolean = (name: string, value: string): boolean => {
+ if (value !== 'true' && value !== 'false')
+ throw new TypeError(`option "${name}" must be true or false`);
+ return value === 'true';
+};
+
+const parseNumber = (name: string, value: string): number => {
+ if (!/^\d+$/.test(value))
+ throw new TypeError(`option "${name}" must be a non-negative integer`);
+ return Number(value);
+};
diff --git a/foreign/node/src/client/client.connection.test.ts
b/foreign/node/src/client/client.connection.test.ts
index b1269d19d..5e320b0ac 100644
--- a/foreign/node/src/client/client.connection.test.ts
+++ b/foreign/node/src/client/client.connection.test.ts
@@ -17,13 +17,18 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
+import { readFileSync } from 'node:fs';
import {
createServer,
type AddressInfo,
type Server,
- type Socket,
+ type Socket
} from 'node:net';
-import { describe, it } from 'node:test';
+import {
+ createServer as createTlsServer,
+ type TLSSocket
+} from 'node:tls';
+import { describe, it, before, after } from 'node:test';
import { ProtocolFrameError } from './client.frame.js';
import { IggyConnection } from './client.connection.js';
import type { ClientConfig } from './client.type.js';
@@ -31,6 +36,26 @@ import { Command, HEADER_SIZE, REPLY_OFFSET } from
'../wire/vsr/header.js';
const FRAME_LIMIT = 2 * HEADER_SIZE;
+const TLS_CERTIFICATE = readFileSync(
+ new URL('../../../../core/certs/iggy_cert.pem', import.meta.url)
+);
+const TLS_KEY = readFileSync(
+ new URL('../../../../core/certs/iggy_key.pem', import.meta.url)
+);
+const TLS_CA_CERTIFICATE = readFileSync(
+ new URL('../../../../core/certs/iggy_ca_cert.pem', import.meta.url)
+);
+
+const startTlsServer = async (): Promise<Server> => {
+ const server = createTlsServer({
+ cert: TLS_CERTIFICATE,
+ key: TLS_KEY
+ });
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ return server;
+};
+
const startServer = async (): Promise<Server> => {
const server = createServer();
server.listen(0, '127.0.0.1');
@@ -67,7 +92,16 @@ const closeConnection = async (
await new Promise<void>((resolve) => server.close(() => resolve()));
};
+let keepAlive: NodeJS.Timeout;
+
describe('IggyConnection', () => {
+
+ // Note:
+ // before node v24 Timeout.unref() would let eventloop exit before test end
+ // (tested against 22.x 23.x -> fail vs 24.x 26.x -> pass)
+ // this timeout prevent eventloop exit before this test end
+ before(() => { keepAlive = setInterval(() => {}, 10000) });
+
it('recognizes a connection established before connect is called',
async () => {
const server = await startServer();
@@ -342,8 +376,15 @@ describe('IggyConnection', () => {
(resolve) => server.close(() => resolve())
);
serverSocket?.destroy();
+ const retryStartedAt = Date.now();
await closed;
const error = await exhausted;
+ // Three retries at a 10 ms interval must spend at least 30 ms in
+ // backoff; a broken wait would redial back to back.
+ assert.ok(
+ Date.now() - retryStartedAt >= 30,
+ 'reconnect backoff did not elapse between retries'
+ );
assert.match(error.message, /reconnect maxRetries exceeded/);
await new Promise<void>((resolve) => setTimeout(resolve, 20));
assert.deepEqual(rejections, []);
@@ -813,4 +854,76 @@ describe('IggyConnection', () => {
}
}
);
+
+ it('rejects an unreadable tls_ca_file with a TypeError at socket creation',
+ () => {
+ assert.throws(
+ () =>
+ new IggyConnection({
+ transport: 'TLS',
+ options: {
+ host: '127.0.0.1',
+ port: 8090,
+ caFile: '/does/not/exist.pem'
+ },
+ credentials: { username: 'iggy', password: 'iggy' },
+ reconnect: { enabled: false, interval: 0, maxRetries: 0 }
+ }),
+ /cannot read tls_ca_file/
+ );
+ }
+ );
+
+ it('sends a DNS host as the SNI server name when none is set',
+ async () => {
+ const server = await startTlsServer();
+ const secureConnection =
+ once(server, 'secureConnection') as Promise<[TLSSocket]>;
+ const connection = new IggyConnection({
+ transport: 'TLS',
+ options: {
+ host: 'localhost',
+ port: (server.address() as AddressInfo).port,
+ ca: TLS_CA_CERTIFICATE
+ },
+ credentials: { username: 'iggy', password: 'iggy' },
+ reconnect: { enabled: false, interval: 0, maxRetries: 0 }
+ });
+ try {
+ await connection.connect();
+ assert.equal(connection.connected, true);
+ const [tlsSocket] = await secureConnection;
+ assert.equal(tlsSocket.servername, 'localhost');
+ } finally {
+ await closeConnection(connection, server);
+ }
+ }
+ );
+
+ it('omits SNI for IP literal hosts', async () => {
+ const server = await startTlsServer();
+ const secureConnection =
+ once(server, 'secureConnection') as Promise<[TLSSocket]>;
+ const connection = new IggyConnection({
+ transport: 'TLS',
+ options: {
+ host: '127.0.0.1',
+ port: (server.address() as AddressInfo).port,
+ rejectUnauthorized: false
+ },
+ credentials: { username: 'iggy', password: 'iggy' },
+ reconnect: { enabled: false, interval: 0, maxRetries: 0 }
+ });
+ try {
+ await connection.connect();
+ assert.equal(connection.connected, true);
+ const [tlsSocket] = await secureConnection;
+ // Node reports a missing SNI name as false on the server side.
+ assert.ok(!tlsSocket.servername);
+ } finally {
+ await closeConnection(connection, server);
+ }
+ });
+
+ after(() => clearInterval(keepAlive));
});
diff --git a/foreign/node/src/client/client.connection.ts
b/foreign/node/src/client/client.connection.ts
index 49f7752f8..936c6f158 100644
--- a/foreign/node/src/client/client.connection.ts
+++ b/foreign/node/src/client/client.connection.ts
@@ -17,8 +17,9 @@
import { EventEmitter } from 'node:events';
import type { Socket } from 'node:net';
-import { createConnection } from 'node:net';
+import { createConnection, isIP } from 'node:net';
import { connect as TLSConnect } from 'node:tls';
+import { readFileSync } from 'node:fs';
import type { ClientConfig, TlsOption, TcpOption, ReconnectOption } from
"./client.type.js"
import { debug } from './client.debug.js';
import { DEFAULT_MAX_RESPONSE_FRAME_SIZE } from './client.config.js';
@@ -47,8 +48,26 @@ const createTcpSocket = (options: TcpOption): Socket => {
* @returns TLS socket
*/
const createTlsSocket = ({ port, ...options }: TlsOption): Socket => {
- const socket = TLSConnect(port, options);
- return socket;
+ const { caFile, ...tlsOptions } = options;
+ if (caFile !== undefined)
+ tlsOptions.ca = readCaCertificate(caFile);
+ // An SNI-routing terminator needs a server name in the ClientHello;
+ // IP literals are never sent as SNI, matching the Rust SDK's fallback.
+ if (typeof tlsOptions.host === 'string' &&
+ tlsOptions.servername === undefined &&
+ isIP(tlsOptions.host) === 0)
+ tlsOptions.servername = tlsOptions.host;
+ return TLSConnect(port, tlsOptions);
+};
+
+// A missing or unreadable CA file is a configuration error rather than a
+// transient I/O failure, hence the TypeError instead of the raw ENOENT.
+const readCaCertificate = (caFile: string): Buffer => {
+ try {
+ return readFileSync(caFile);
+ } catch {
+ throw new TypeError(`cannot read tls_ca_file "${caFile}"`);
+ }
};
/**
@@ -96,14 +115,16 @@ const DefaultReconnectOption: ReconnectOption = {
/**
* Waits before a reconnection attempt.
*
- * @param timer - Delay in milliseconds before recreating
- * @returns Promise resolving after the delay
+ * The timer is unref'd: a queued command fails fast on 'disconnected', so
+ * the retry loop is background work that must not keep the process alive.
+ *
+ * @param interval - Delay in milliseconds before dialing again
*/
-function waitForReconnect(timer = 1000): Promise<void> {
- return new Promise((resolve) => {
- setTimeout(resolve, timer);
+const waitForReconnect = (interval: number): Promise<void> =>
+ new Promise<void>((resolve) => {
+ const timeout = setTimeout(resolve, interval);
+ (timeout as NodeJS.Timeout).unref();
});
-}
/** Socket error with optional error code */
type SocketError = Error & { code?: string };
diff --git a/foreign/node/src/client/client.socket.ts
b/foreign/node/src/client/client.socket.ts
index 3924ba14a..06670e5c1 100644
--- a/foreign/node/src/client/client.socket.ts
+++ b/foreign/node/src/client/client.socket.ts
@@ -18,6 +18,7 @@
import { EventEmitter } from 'node:events';
import type {
ClientConfig,
+ ClientConfigOrString,
ClientCredentials, CommandResponse,
PasswordCredentials, RawClient, SendCommandOptions,
TokenCredentials
@@ -165,7 +166,7 @@ export class CommandResponseStream extends EventEmitter {
*
* @param options - Client configuration
*/
- constructor(options: ClientConfig) {
+ constructor(options: ClientConfigOrString) {
super();
const normalizedConfig = normalizeClientConfig(options);
this.options = normalizedConfig;
@@ -950,7 +951,7 @@ export class CommandResponseStream extends EventEmitter {
* @param options - Client configuration
* @returns RawClient instance
*/
-export function getRawClient(options: ClientConfig): RawClient {
+export function getRawClient(options: ClientConfigOrString): RawClient {
return new CommandResponseStream(options);
}
diff --git a/foreign/node/src/client/client.ts
b/foreign/node/src/client/client.ts
index fc642f5ef..25f877b13 100644
--- a/foreign/node/src/client/client.ts
+++ b/foreign/node/src/client/client.ts
@@ -16,28 +16,13 @@
// under the License.
import { createPool, type Pool } from 'generic-pool';
-import type { RawClient, ClientConfig } from "./client.type.js"
+import type { RawClient, ClientConfig, ClientConfigOrString } from
"./client.type.js"
import { getRawClient } from '../client/client.socket.js';
import { CommandAPI } from '../wire/command-set.js';
import { debug } from './client.debug.js';
import { normalizeClientConfig } from './client.config.js';
-/**
- * Creates a pool factory for managing RawClient instances.
- *
- * @param config - Client configuration
- * @returns Pool factory with create and destroy methods
- */
-const createPoolFactory = (config: ClientConfig) => ({
- create: async function () {
- return getRawClient(config);
- },
- destroy: async function (client: RawClient) {
- return client.destroy();
- }
-});
-
/**
* Creates a client provider that uses connection pooling.
* Automatically acquires and releases clients from the pool.
@@ -46,20 +31,33 @@ const createPoolFactory = (config: ClientConfig) => ({
* @returns Client provider and its connection pool
*/
const createPooledClientProvider = (config: ClientConfig) => {
+ // Constructed eagerly rather than inside the factory: generic-pool never
+ // rejects waiting acquires when a create fails, and keeps re-dispatching
+ // the doomed factory, which hangs every command. Normalization already
+ // pins this pool to exactly one pooled connection, so there is nothing
+ // lazy to preserve.
+ const client = getRawClient(config);
const minPoolSize = config.poolSize?.min || 1;
const maxPoolSize = config.poolSize?.max || 4;
- const pool = createPool(createPoolFactory(config), {
+ const pool = createPool({
+ create: async function () {
+ return client;
+ },
+ destroy: async function () {
+ return client.destroy();
+ }
+ }, {
min: minPoolSize,
max: maxPoolSize
});
const clientProvider = async () => {
- const client = await pool.acquire();
+ const pooled = await pool.acquire();
debug('client acquired from pool. pool size is', pool.size);
- client.once('finishQueue', () => {
- pool.release(client)
+ pooled.once('finishQueue', () => {
+ pool.release(pooled)
debug('client released to pool. pool size is', pool.size);
});
- return client;
+ return pooled;
}
return { clientProvider, pool };
};
@@ -78,9 +76,9 @@ export class Client extends CommandAPI {
/**
* Creates a new pooled client.
*
- * @param config - Client configuration
+ * @param config - Client configuration or connection string
*/
- constructor(config: ClientConfig) {
+ constructor(config: ClientConfigOrString) {
const normalizedConfig = normalizeClientConfig(config);
const { clientProvider, pool } =
createPooledClientProvider(normalizedConfig);
@@ -124,11 +122,12 @@ export class SingleClient extends CommandAPI {
/**
* Creates a new single-connection client.
*
- * @param config - Client configuration
+ * @param config - Client configuration or connection string
*/
- constructor(config: ClientConfig) {
- super(createSingleClientProvider(config));
- this._config = config;
+ constructor(config: ClientConfigOrString) {
+ const normalizedConfig = normalizeClientConfig(config);
+ super(createSingleClientProvider(normalizedConfig));
+ this._config = normalizedConfig;
}
/**
@@ -169,10 +168,10 @@ export class SimpleClient extends CommandAPI {
* Creates a SimpleClient with the given configuration.
* Convenience function for quickly creating a client.
*
- * @param config - Client configuration
+ * @param config - Client configuration or connection string
* @returns SimpleClient instance
*/
-export const getClient = async (config: ClientConfig) => {
+export const getClient = async (config: ClientConfigOrString) => {
const client = getRawClient(config);
return new SimpleClient(client);
};
diff --git a/foreign/node/src/client/client.type.ts
b/foreign/node/src/client/client.type.ts
index 42ae39239..ad3932f42 100644
--- a/foreign/node/src/client/client.type.ts
+++ b/foreign/node/src/client/client.type.ts
@@ -16,20 +16,22 @@
// under the License.
import type { Readable } from 'stream';
-import { type TcpSocketConnectOpts } from 'node:net';
+import { type TcpNetConnectOpts } from 'node:net';
import { type ConnectionOptions } from 'node:tls';
/**
* TCP socket connection options.
- * Alias for Node.js TcpSocketConnectOpts.
+ * Alias for Node.js TcpNetConnectOpts, what net.createConnection accepts.
*/
-export type TcpOption = TcpSocketConnectOpts;
+export type TcpOption = TcpNetConnectOpts;
/**
* TLS socket connection options.
- * Combines port number with Node.js TLS ConnectionOptions.
+ * Combines port number with Node.js TLS ConnectionOptions and the
+ * net.connect options tls.connect forwards at runtime. `caFile` is an SDK
+ * extension: a CA certificate path read when the TLS socket is created.
*/
-export type TlsOption = { port: number } & ConnectionOptions;
+export type TlsOption = { port: number } & ConnectionOptions &
Partial<TcpNetConnectOpts> & { caFile?: string };
/**
* Response from a command sent to the Iggy server.
@@ -165,6 +167,12 @@ export type PoolSizeOption = {
max?: number
}
+/**
+ * Client configuration or a connection string such as
+ * `iggy://username:password@host:port`.
+ */
+export type ClientConfigOrString = ClientConfig | string;
+
/**
* Complete client configuration for connecting to the Iggy server.
*/
@@ -179,12 +187,7 @@ export type ClientConfig = {
poolSize?: PoolSizeOption,
/** Automatic reconnection configuration */
reconnect?: ReconnectOption,
- /**
- * Interval for sending heartbeat pings in milliseconds, as an integer
- * between 0 and Node's timer ceiling. Defaults to 5000. Set to 0 to disable
- * client heartbeats; any other unusable value is rejected rather than
- * silently disabling them.
- */
+ /** Interval for sending heartbeat pings, in milliseconds */
heartbeatInterval?: number,
/** Maximum accepted response frame size in bytes */
maxResponseFrameSize?: number
diff --git a/foreign/node/src/client/index.ts b/foreign/node/src/client/index.ts
index d968e5ca0..e3430c4a9 100644
--- a/foreign/node/src/client/index.ts
+++ b/foreign/node/src/client/index.ts
@@ -17,6 +17,7 @@
export { Client, SimpleClient, SingleClient } from './client.js'
export * from './client.config.js';
+export { parseConnectionString } from './client.connection-string.js';
export * from './client.utils.js';
export * from './client.socket.js';
export * from './client.type.js';
diff --git a/foreign/node/src/client/index.ts b/foreign/node/src/constant.ts
similarity index 79%
copy from foreign/node/src/client/index.ts
copy to foreign/node/src/constant.ts
index d968e5ca0..bfc196335 100644
--- a/foreign/node/src/client/index.ts
+++ b/foreign/node/src/constant.ts
@@ -15,8 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-export { Client, SimpleClient, SingleClient } from './client.js'
-export * from './client.config.js';
-export * from './client.utils.js';
-export * from './client.socket.js';
-export * from './client.type.js';
+/** Largest value representable by the Rust u32 type. */
+export const MAX_U32 = 4294967295;
diff --git a/foreign/node/src/duration.utils.test.ts
b/foreign/node/src/duration.utils.test.ts
new file mode 100644
index 000000000..992ff86a4
--- /dev/null
+++ b/foreign/node/src/duration.utils.test.ts
@@ -0,0 +1,180 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ DurationParseError,
+ nanosecondsToMilliseconds,
+ parseHumantimeDuration,
+ parseIggyDurationNanoseconds
+} from './duration.utils.js';
+
+const ns = (
+ seconds: number | bigint,
+ nanos = 0n
+): bigint => BigInt(seconds) * 1_000_000_000n + nanos;
+
+const parseNs = (input: string): bigint => {
+ const { seconds, nanoseconds } = parseHumantimeDuration(input);
+ return seconds * 1_000_000_000n + nanoseconds;
+};
+
+describe('parseHumantimeDuration', () => {
+ it('parses every unit spelling', () => {
+ const cases: [string, bigint][] = [
+ ['17nsec', ns(0n, 17n)],
+ ['17nanos', ns(0n, 17n)],
+ ['33ns', ns(0n, 33n)],
+ ['3usec', ns(0n, 3000n)],
+ ['78us', ns(0n, 78_000n)],
+ ['163µs', ns(0n, 163_000n)],
+ ['31msec', ns(0n, 31_000_000n)],
+ ['31millis', ns(0n, 31_000_000n)],
+ ['6ms', ns(0n, 6_000_000n)],
+ ['3000s', ns(3000n)],
+ ['300secs', ns(300n)],
+ ['50seconds', ns(50n)],
+ ['100m', ns(6000n)],
+ ['12mins', ns(720n)],
+ ['7minutes', ns(420n)],
+ ['2h', ns(7200n)],
+ ['7hrs', ns(25_200n)],
+ ['24hours', ns(86_400n)],
+ ['2days', ns(172_800n)],
+ ['365d', ns(31_536_000n)],
+ ['7weeks', ns(4_233_600n)],
+ ['104wks', ns(62_899_200n)],
+ ['52w', ns(31_449_600n)],
+ ['3months', ns(3n * 2_630_016n)],
+ ['15yrs', ns(15n * 31_557_600n)],
+ ['10yr', ns(10n * 31_557_600n)],
+ ['17y', ns(536_479_200n)]
+ ];
+ for (const [input, expected] of cases)
+ assert.equal(parseNs(input), expected, input);
+ });
+
+ it('combines tokens with and without whitespace', () => {
+ assert.equal(parseNs('2h 37min'), ns(9420n));
+ assert.equal(parseNs('2h 15m'), ns(8100n));
+ assert.equal(parseNs('20 min 17 nsec '), ns(1200n, 17n));
+ assert.equal(parseNs('1.234s0.345ms0.678us0ns'), ns(1n, 234_345_678n));
+ assert.equal(
+ parseNs('1.234s 1.345ms 1.678us 1ns'),
+ ns(1n, 235_346_679n)
+ );
+ });
+
+ it('supports fractional values with exact division only', () => {
+ assert.equal(parseNs('4.2s'), 4_200_000_000n);
+ assert.equal(parseNs('1.5minute'), ns(90n));
+ assert.equal(parseNs('0.5h'), ns(1800n));
+ assert.equal(parseNs('1.123456789s'), ns(1n, 123_456_789n));
+ assert.equal(parseNs('31.000001ms'), ns(0n, 31_000_001n));
+ // Precision losses are rejected rather than truncated.
+ for (const input of [
+ '0.000123456789s',
+ '31.0000001ms',
+ '1.0000000002s',
+ '0.0000000002s'
+ ])
+ assert.throws(() => parseHumantimeDuration(input), (error: unknown) =>
+ error instanceof DurationParseError &&
+ error.kind === 'number-overflow'
+ );
+ });
+
+ it('rejects malformed fractional input', () => {
+ for (const input of ['1.s', '1..s'])
+ assert.throws(() => parseHumantimeDuration(input), (error: unknown) =>
+ error instanceof DurationParseError &&
+ error.kind === 'invalid-character'
+ );
+ for (const input of ['.1s', '.'])
+ assert.throws(() => parseHumantimeDuration(input), (error: unknown) =>
+ error instanceof DurationParseError &&
+ error.kind === 'number-expected'
+ );
+ });
+
+ it('reports overflow like u64 arithmetic', () => {
+ for (const input of [
+ '100000000000000000000ns',
+ '100000000000000ms',
+ '10000000000000000000m',
+ '100000000000000000d',
+ '10000000000000y'
+ ])
+ assert.throws(() => parseHumantimeDuration(input), (error: unknown) =>
+ error instanceof DurationParseError &&
+ error.kind === 'number-overflow'
+ );
+ });
+
+ it('produces the Rust error messages verbatim', () => {
+ const messageOf = (input: string): string => {
+ try {
+ parseHumantimeDuration(input);
+ } catch (error) {
+ return (error as Error).message;
+ }
+ return '';
+ };
+ assert.equal(
+ messageOf('123'),
+ 'time unit needed, for example 123sec or 123ms'
+ );
+ assert.equal(
+ messageOf('10 months 1'),
+ 'time unit needed, for example 1sec or 1ms'
+ );
+ assert.equal(
+ messageOf('10nights'),
+ 'unknown time unit "nights", supported units: ns, us/µs, ms, sec, ' +
+ 'min, hours, days, weeks, months, years (and few variations)'
+ );
+ assert.equal(messageOf('\0'), 'expected number at 0');
+ assert.equal(messageOf('\r'), 'value was empty');
+ assert.equal(messageOf('1~'), 'invalid character at 1');
+ assert.equal(messageOf('1Nå'), 'invalid character at 2');
+ assert.equal(
+ messageOf('222nsec221nanosmsec7s5msec572s'),
+ 'unknown time unit "nanosmsec", supported units: ns, us/µs, ms, ' +
+ 'sec, min, hours, days, weeks, months, years (and few variations)'
+ );
+ });
+});
+
+describe('parseIggyDurationNanoseconds', () => {
+ it('maps the zero spellings to zero case-insensitively', () => {
+ for (const value of [
+ '0', 'unlimited', 'disabled', 'none', 'UNLIMITED', 'None', 'Disabled'
+ ]) {
+ assert.equal(parseIggyDurationNanoseconds(value), 0n);
+ // The literal "0" is also short-circuited by humantime itself.
+ assert.equal(nanosecondsToMilliseconds(0n), 0);
+ }
+ });
+
+ it('rounds sub-millisecond results half up', () => {
+
assert.equal(nanosecondsToMilliseconds(parseIggyDurationNanoseconds('500ms')),
500);
+
assert.equal(nanosecondsToMilliseconds(parseIggyDurationNanoseconds('17ns')),
0);
+
assert.equal(nanosecondsToMilliseconds(parseIggyDurationNanoseconds('1.5ms')),
2);
+
assert.equal(nanosecondsToMilliseconds(parseIggyDurationNanoseconds('1.005s')),
1005);
+ });
+});
diff --git a/foreign/node/src/duration.utils.ts
b/foreign/node/src/duration.utils.ts
new file mode 100644
index 000000000..35d473919
--- /dev/null
+++ b/foreign/node/src/duration.utils.ts
@@ -0,0 +1,393 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/**
+ * TypeScript port of the duration grammar accepted by the Rust SDK.
+ *
+ * `parseHumantimeDuration` mirrors humantime 2.4.0's `parse_duration`,
+ * which `IggyDuration::from_str` delegates to after lowercasing and
+ * mapping its zero spellings. Control flow, the unit table, u64 overflow
+ * checks, exact fraction division, and error messages are kept identical
+ * so both SDKs accept and reject the same strings.
+ */
+
+const MAX_U64 = 18_446_744_073_709_551_615n;
+
+export type DurationErrorKind =
+ | 'invalid-character'
+ | 'number-expected'
+ | 'unknown-unit'
+ | 'number-overflow'
+ | 'empty';
+
+/** Carries the message the corresponding Rust error's Display produces. */
+export class DurationParseError extends TypeError {
+ kind: DurationErrorKind;
+
+ constructor(kind: DurationErrorKind, message: string) {
+ super(message);
+ this.kind = kind;
+ }
+}
+
+const overflowError = () =>
+ new DurationParseError(
+ 'number-overflow',
+ 'number is too large or cannot be represented without a lack of ' +
+ 'precision (values below 1ns are not supported)'
+ );
+
+const checkedMul = (a: bigint, b: bigint): bigint => {
+ const product = a * b;
+ if (product > MAX_U64)
+ throw overflowError();
+ return product;
+};
+
+const checkedAdd = (a: bigint, b: bigint): bigint => {
+ const sum = a + b;
+ if (sum > MAX_U64)
+ throw overflowError();
+ return sum;
+};
+
+// humantime's OverflowOp::div: a non-exact division loses precision,
+// reported as overflow rather than truncated.
+const checkedExactDiv = (a: bigint, b: bigint): bigint => {
+ if (a % b !== 0n)
+ throw overflowError();
+ return a / b;
+};
+
+type UnitKind =
+ | 'ns' | 'us' | 'ms' | 's' | 'm' | 'h' | 'd' | 'w' | 'M' | 'y';
+
+const UNITS: Record<string, UnitKind> = {
+ nanos: 'ns', nsec: 'ns', ns: 'ns',
+ usec: 'us', us: 'us', 'µs': 'us',
+ millis: 'ms', msec: 'ms', ms: 'ms',
+ seconds: 's', second: 's', secs: 's', sec: 's', s: 's',
+ minutes: 'm', minute: 'm', min: 'm', mins: 'm', m: 'm',
+ hours: 'h', hour: 'h', hr: 'h', hrs: 'h', h: 'h',
+ days: 'd', day: 'd', d: 'd',
+ weeks: 'w', week: 'w', wk: 'w', wks: 'w', w: 'w',
+ months: 'M', month: 'M',
+ years: 'y', year: 'y', yr: 'y', yrs: 'y', y: 'y'
+};
+
+// Month is 30.44 days and year is 365.25 days, matching the Rust table.
+const SECONDS_PER_UNIT: Record<UnitKind, bigint> = {
+ ns: 0n, us: 0n, ms: 0n, s: 1n, m: 60n,
+ h: 3600n, d: 86_400n, w: 604_800n, M: 2_630_016n, y: 31_557_600n
+};
+
+const NANOS_PER_UNIT: Record<UnitKind, bigint> = {
+ ns: 1n, us: 1000n, ms: 1_000_000n, s: 0n, m: 0n,
+ h: 0n, d: 0n, w: 0n, M: 0n, y: 0n
+};
+
+type Fraction = {
+ numerator: bigint,
+ denominator: bigint
+};
+
+type ParsedDuration = {
+ seconds: bigint,
+ nanoseconds: bigint
+};
+
+const utf8ByteLength = (char: string): number => {
+ const code = char.codePointAt(0)!;
+ return code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
+};
+
+const isDigit = (c: string): boolean => c >= '0' && c <= '9';
+const isUnitChar = (c: string): boolean => /^[a-zA-Zµ]$/.test(c);
+
+class DurationParser {
+ private readonly chars: string[];
+ private readonly byteLengths: number[];
+ /** Index of the next unconsumed char. */
+ private index = 0;
+ /** Byte offset of the next unconsumed char; error offsets are byte-based. */
+ private consumedBytes = 0;
+
+ constructor(src: string) {
+ this.chars = Array.from(src);
+ this.byteLengths = this.chars.map(utf8ByteLength);
+ }
+
+ private off(): number {
+ return this.consumedBytes;
+ }
+
+ private next(): string | undefined {
+ const c = this.chars[this.index];
+ if (c === undefined)
+ return undefined;
+ this.index += 1;
+ this.consumedBytes += this.byteLengths[this.index - 1];
+ return c;
+ }
+
+ /** Byte-exact source slice; unit spellings are ASCII-only. */
+ private sliceBytes(startByte: number, endByte: number): string {
+ let collected = '';
+ let position = 0;
+ for (let i = 0; i < this.chars.length && position < endByte; i += 1) {
+ if (position >= startByte)
+ collected += this.chars[i];
+ position += this.byteLengths[i];
+ }
+ return collected;
+ }
+
+ parse(): ParsedDuration {
+ // The error offset is fixed where scanning began, matching the Rust
+ // implementation's single capture outside the loop.
+ let n = this.parseFirstChar(this.off());
+ // Rust: `.ok_or(Error::Empty)` on the first-char scan.
+ if (n === undefined)
+ throw new DurationParseError('empty', 'value was empty');
+ let seconds = 0n;
+ let nanoseconds = 0n;
+
+ outer:
+ while (true) {
+ let fraction: Fraction | undefined;
+ // Offsets refresh at iteration end only, so a break leaves the
+ // offset pointing at the char that caused it.
+ let off = this.off();
+ while (true) {
+ const c = this.next();
+ if (c === undefined)
+ break;
+ if (isDigit(c)) {
+ n = checkedAdd(checkedMul(n, 10n), BigInt(c));
+ } else if (isUnitChar(c)) {
+ break;
+ } else if (c === '.') {
+ // The scanner's final offset becomes the unit start.
+ const scanned = this.parseFractionalPart(off);
+ fraction = scanned.fraction;
+ off = scanned.offset;
+ break;
+ } else if (!/\s/.test(c)) {
+ throw new DurationParseError(
+ 'invalid-character',
+ `invalid character at ${off}`
+ );
+ }
+ off = this.off();
+ }
+ const start = off;
+ let unitEnd = this.off();
+ while (true) {
+ const c = this.next();
+ if (c === undefined)
+ break;
+ if (isDigit(c)) {
+ ({ seconds, nanoseconds } = this.addUnit(
+ n, fraction, start, unitEnd, seconds, nanoseconds
+ ));
+ n = BigInt(c);
+ continue outer;
+ }
+ if (/\s/.test(c))
+ break;
+ if (!isUnitChar(c))
+ throw new DurationParseError(
+ 'invalid-character',
+ `invalid character at ${unitEnd}`
+ );
+ unitEnd = this.off();
+ }
+ ({ seconds, nanoseconds } = this.addUnit(
+ n, fraction, start, unitEnd, seconds, nanoseconds
+ ));
+ const next = this.parseFirstChar(this.off());
+ if (next === undefined)
+ return { seconds, nanoseconds };
+ n = next;
+ }
+ }
+
+ private parseFirstChar(scanStart: number): bigint | undefined {
+ for (;;) {
+ const c = this.next();
+ if (c === undefined)
+ return undefined;
+ if (isDigit(c))
+ return BigInt(c);
+ if (/\s/.test(c))
+ continue;
+ throw new DurationParseError(
+ 'number-expected',
+ `expected number at ${scanStart}`
+ );
+ }
+ }
+
+ /**
+ * Consumes fraction digits after the decimal separator. Whitespace
+ * between digits is tolerated; the returned offset is the position the
+ * scanner stopped at, which becomes the unit start.
+ */
+ private parseFractionalPart(
+ startOffset: number
+ ): { fraction: Fraction, offset: number } {
+ let numerator = 0n;
+ let denominator = 1n;
+ // Leading zeros grow the denominator only.
+ let zeros = true;
+ let off = startOffset;
+ for (;;) {
+ const c = this.next();
+ if (c === undefined) {
+ off = this.off();
+ break;
+ }
+ if (c === '0') {
+ denominator = checkedMul(denominator, 10n);
+ if (!zeros)
+ numerator = checkedMul(numerator, 10n);
+ } else if (isDigit(c)) {
+ zeros = false;
+ denominator = checkedMul(denominator, 10n);
+ numerator = checkedAdd(checkedMul(numerator, 10n), BigInt(c));
+ } else if (isUnitChar(c)) {
+ break;
+ } else if (!/\s/.test(c)) {
+ throw new DurationParseError(
+ 'invalid-character',
+ `invalid character at ${off}`
+ );
+ }
+ off = this.off();
+ }
+ if (denominator === 1n)
+ throw new DurationParseError(
+ 'invalid-character',
+ `invalid character at ${off}`
+ );
+ return { fraction: { numerator, denominator }, offset: off };
+ }
+
+ private addUnit(
+ n: bigint,
+ fraction: Fraction | undefined,
+ start: number,
+ end: number,
+ seconds: bigint,
+ nanoseconds: bigint
+ ): ParsedDuration {
+ const unitSlice = this.sliceBytes(start, end);
+ const kind = UNITS[unitSlice];
+ if (kind === undefined) {
+ const detail = unitSlice.length === 0
+ ? `time unit needed, for example ${n}sec or ${n}ms`
+ : 'unknown time unit "' + unitSlice + '", supported units: ns, ' +
+ 'us/µs, ms, sec, min, hours, days, weeks, months, years ' +
+ '(and few variations)';
+ throw new DurationParseError('unknown-unit', detail);
+ }
+
+ const intSeconds = checkedMul(n, SECONDS_PER_UNIT[kind]);
+ const intNanos = checkedMul(n, NANOS_PER_UNIT[kind]);
+ ({ seconds, nanoseconds } = addCurrent(
+ seconds, nanoseconds, intSeconds, intNanos
+ ));
+
+ if (fraction === undefined)
+ return { seconds, nanoseconds };
+
+ // Fractional part: sub-nanosecond results are a precision loss.
+ const { numerator, denominator } = fraction;
+ let fracSeconds = 0n;
+ let fracNanos = 0n;
+ switch (kind) {
+ case 'ns':
+ throw overflowError();
+ case 'us':
+ fracNanos =
+ checkedExactDiv(checkedMul(numerator, 1000n), denominator);
+ break;
+ case 'ms':
+ fracNanos = checkedExactDiv(
+ checkedMul(numerator, 1_000_000n), denominator
+ );
+ break;
+ case 's':
+ fracNanos = checkedExactDiv(
+ checkedMul(numerator, 1_000_000_000n), denominator
+ );
+ break;
+ default:
+ fracSeconds = checkedExactDiv(
+ checkedMul(numerator, SECONDS_PER_UNIT[kind]), denominator
+ );
+ }
+ return addCurrent(seconds, nanoseconds, fracSeconds, fracNanos);
+ }
+}
+
+const addCurrent = (
+ seconds: bigint,
+ nanoseconds: bigint,
+ addSeconds: bigint,
+ addNanos: bigint
+): ParsedDuration => {
+ // Strictly-greater carry preserved from the Rust implementation.
+ let totalNanos = checkedAdd(nanoseconds, addNanos);
+ let totalSeconds = addSeconds;
+ if (totalNanos > 1_000_000_000n) {
+ totalSeconds = checkedAdd(totalSeconds, totalNanos / 1_000_000_000n);
+ totalNanos %= 1_000_000_000n;
+ }
+ return {
+ seconds: checkedAdd(seconds, totalSeconds),
+ nanoseconds: totalNanos
+ };
+};
+
+/** Parses a humantime expression into seconds plus subsecond nanos. */
+export const parseHumantimeDuration = (input: string): ParsedDuration => {
+ // Rust short-circuits the literal "0" before the parser runs.
+ if (input === '0')
+ return { seconds: 0n, nanoseconds: 0n };
+ return new DurationParser(input).parse();
+};
+
+const ZERO_SPELLINGS = new Set(['0', 'unlimited', 'disabled', 'none']);
+
+/**
+ * Mirrors `IggyDuration::from_str`: case-insensitive, with the zero
+ * spellings mapped to a zero duration before humantime parsing.
+ *
+ * @returns Total nanoseconds as BigInt.
+ */
+export const parseIggyDurationNanoseconds = (value: string): bigint => {
+ const lowered = value.toLowerCase();
+ if (ZERO_SPELLINGS.has(lowered))
+ return 0n;
+ const { seconds, nanoseconds } = parseHumantimeDuration(lowered);
+ return seconds * 1_000_000_000n + nanoseconds;
+};
+
+/** Rounds nanoseconds to whole milliseconds, half up. */
+export const nanosecondsToMilliseconds = (totalNanoseconds: bigint): number =>
+ Number((totalNanoseconds + 500_000n) / 1_000_000n);
diff --git a/foreign/node/src/e2e/tcp.connection-string.e2e.ts
b/foreign/node/src/e2e/tcp.connection-string.e2e.ts
new file mode 100644
index 000000000..c42eac44a
--- /dev/null
+++ b/foreign/node/src/e2e/tcp.connection-string.e2e.ts
@@ -0,0 +1,146 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { after, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { Client } from '../client/client.js';
+import type { TransportType } from '../client/client.type.js';
+import { MAX_U32 } from '../constant.js';
+import { getIggyAddress } from '../tcp.sm.utils.js';
+
+const dummyOpt = 'nodelay=true' +
+ '&reconnection_retries=1' +
+ '&reconnection_interval=1s' +
+ '&heartbeat_interval=10s' +
+ '&tls=false';
+
+/** Option-value variations exercised against a live server. */
+const optionCases: {
+ name: string,
+ query: string,
+ expect: {
+ transport?: TransportType,
+ reconnect?: Record<string, unknown>,
+ heartbeatInterval?: number,
+ options?: Record<string, unknown>
+ }
+}[] = [
+ {
+ name: 'unlimited retries at the default interval',
+ query: 'reconnection_retries=unlimited',
+ expect: {
+ reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 }
+ }
+ },
+ {
+ name: 'bounded retries with a sub-second interval',
+ query: 'reconnection_retries=10&reconnection_interval=250ms',
+ expect: {
+ reconnect: { enabled: true, interval: 250, maxRetries: 10 }
+ }
+ },
+ {
+ name: 'compound duration interval',
+ query: 'reconnection_interval=1m30s',
+ expect: {
+ reconnect: { enabled: true, interval: 90000, maxRetries: MAX_U32 }
+ }
+ },
+ {
+ name: 'disabled heartbeats and nodelay off',
+ query: 'heartbeat_interval=0ms&nodelay=false',
+ expect: {
+ heartbeatInterval: 0,
+ options: { noDelay: false }
+ }
+ },
+ {
+ name: 'reestablish_after validated then ignored',
+ query: 'reestablish_after=7s',
+ expect: {
+ reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 }
+ }
+ },
+ {
+ name: 'timer-ceiling heartbeat interval',
+ query: 'heartbeat_interval=2147483647ms',
+ expect: {
+ heartbeatInterval: 2147483647
+ }
+ }
+];
+
+describe('e2e -> connection string', async () => {
+ const [host, port] = getIggyAddress();
+ const client = new Client(`iggy://iggy:iggy@${host}:${port}?${dummyOpt}`);
+
+ it('e2e -> connection string::parses every option exactly once',
+ () => {
+ // A repeated key would silently keep only the last value, so each
+ // option appears once above and must all land in the config.
+ assert.equal(client._config.transport, 'TCP');
+ assert.equal(client._config.options.noDelay, true);
+ assert.deepEqual(client._config.reconnect, {
+ enabled: true,
+ interval: 1000,
+ maxRetries: 1
+ });
+ assert.equal(client._config.heartbeatInterval, 10000);
+ });
+
+ it('e2e -> connection string::ping', async () => {
+ assert.ok(await client.system.ping());
+ });
+
+ describe('option values', async () => {
+ for (const { name, query, expect } of optionCases) {
+ it(name, async () => {
+ const caseClient =
+ new Client(`iggy://iggy:iggy@${host}:${port}?${query}`);
+ try {
+ if (expect.transport !== undefined)
+ assert.equal(caseClient._config.transport, expect.transport);
+ if (expect.reconnect !== undefined)
+ assert.deepEqual(
+ caseClient._config.reconnect,
+ expect.reconnect
+ );
+ if (expect.heartbeatInterval !== undefined)
+ assert.equal(
+ caseClient._config.heartbeatInterval,
+ expect.heartbeatInterval
+ );
+ for (const [key, value] of Object.entries(expect.options ?? {}))
+ assert.deepEqual(
+ (caseClient._config.options as unknown as
+ Record<string, unknown>)[key],
+ value
+ );
+
+ // Every accepted value set must still reach a live server.
+ assert.ok(await caseClient.system.ping());
+ } finally {
+ await caseClient.destroy();
+ }
+ });
+ }
+ });
+
+ after(async () => {
+ await client.destroy();
+ });
+});
diff --git a/foreign/node/src/e2e/tls.system.e2e.ts
b/foreign/node/src/e2e/tls.system.e2e.ts
index eeb8c3d64..95caa7232 100644
--- a/foreign/node/src/e2e/tls.system.e2e.ts
+++ b/foreign/node/src/e2e/tls.system.e2e.ts
@@ -37,6 +37,7 @@ import { resolve } from 'node:path';
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { Client } from '../client/client.js';
+import type { TlsOption } from '../client/client.type.js';
import { Partitioning, Consumer, PollingStrategy } from '../wire/index.js';
import { getIggyAddress } from '../tcp.sm.utils.js';
@@ -127,6 +128,69 @@ describe('e2e -> tls', { skip: !tlsEnabled &&
'IGGY_TCP_TLS_ENABLED is not set'
await c.stream.delete({ streamId: streamName });
});
+ it('e2e -> tls::connect via connection string', async () => {
+ const [, port] = getIggyAddress();
+
+ // The tls_ca_file path is resolved by the connection string parser;
+ // the certificate itself is read when the TLS socket connects.
+ const csClient = new Client(
+ 'iggy://iggy:iggy@localhost:' +
+ `${port}?tls=true&tls_ca_file=${caCertPath}`
+ );
+ try {
+ // tls=true alone: servername defaults to the DNS host.
+ assert.equal(csClient._config.transport, 'TLS');
+ const options = csClient._config.options as TlsOption;
+ assert.equal(options.servername, undefined);
+ assert.ok(await csClient.system.ping());
+ assert.deepEqual(
+ await csClient.session.login(credentials),
+ { userId: 0 }
+ );
+ } finally {
+ await csClient.destroy();
+ }
+ });
+
+ it('e2e -> tls::connect via connection string with explicit tls_domain',
+ async () => {
+ const [, port] = getIggyAddress();
+
+ const csClient = new Client(
+ 'iggy://iggy:iggy@localhost:' +
+ `${port}?tls=true&tls_domain=localhost&tls_ca_file=${caCertPath}` +
+ '&heartbeat_interval=15s'
+ );
+ try {
+ assert.equal(csClient._config.transport, 'TLS');
+ assert.equal(
+ (csClient._config.options as TlsOption).servername,
+ 'localhost'
+ );
+ assert.equal(csClient._config.heartbeatInterval, 15000);
+ assert.ok(await csClient.system.ping());
+ } finally {
+ await csClient.destroy();
+ }
+ }
+ );
+
+ it('e2e -> tls::rejects a wrong ca path with a TypeError', () => {
+ // The client is constructed eagerly, so an unreadable CA file surfaces
+ // as a TypeError from the constructor instead of a raw ENOENT Error
+ // leaking from the socket layer.
+ assert.throws(
+ () =>
+ new Client(
+ 'iggy://iggy:iggy@localhost:8090' +
+ '?tls=true&tls_ca_file=/does/not/exist.pem'
+ ),
+ (error: unknown) =>
+ error instanceof TypeError &&
+ /cannot read tls_ca_file/.test(error.message)
+ );
+ });
+
it('e2e -> tls::logout', async () => {
assert.ok(await c.session.logout());
});
diff --git a/foreign/node/src/stream/consumer-stream.ts
b/foreign/node/src/stream/consumer-stream.ts
index f69873421..80109e4f1 100644
--- a/foreign/node/src/stream/consumer-stream.ts
+++ b/foreign/node/src/stream/consumer-stream.ts
@@ -16,7 +16,7 @@
// under the License.
import { Readable } from "node:stream";
-import type { ClientConfig } from "../client/client.type.js";
+import type { ClientConfigOrString } from "../client/client.type.js";
import type { Id } from '../wire/identifier.utils.js';
import { getClient } from "../client/client.js";
import {
@@ -78,7 +78,7 @@ export type SingleConsumerStreamRequest =
ConsumerStreamRequest & {
export type GroupConsumerStreamRequest = ConsumerStreamRequest & { groupName:
string };
-export const singleConsumerStream = (config: ClientConfig) => async (
+export const singleConsumerStream = (config: ClientConfigOrString) => async (
{
streamId,
topicId,
@@ -110,7 +110,7 @@ export const singleConsumerStream = (config: ClientConfig)
=> async (
};
-export const groupConsumerStream = (config: ClientConfig) =>
+export const groupConsumerStream = (config: ClientConfigOrString) =>
async function groupConsumerStream({
groupName,
streamId,