diff --git a/foreign/node/README.md b/foreign/node/README.md index 169500437f..5c0f9052fc 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:iggy@127.0.0.1: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 9022ab0804..f2404c703b 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 7c94bdbe40..8fe48e6584 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 0000000000..09198e7813 --- /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:secret@127.0.0.1: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 0000000000..75d0b67e29 --- /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 b1269d19d0..5e320b0ac2 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 => { + 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 => { const server = createServer(); server.listen(0, '127.0.0.1'); @@ -67,7 +92,16 @@ const closeConnection = async ( await new Promise((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((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 49f7752f89..936c6f1580 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 { - return new Promise((resolve) => { - setTimeout(resolve, timer); +const waitForReconnect = (interval: number): Promise => + new Promise((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 3924ba14ad..06670e5c13 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 fc642f5ef8..25f877b131 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 42ae39239d..ad3932f420 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 & { 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 d968e5ca0b..e3430c4a9b 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/constant.ts b/foreign/node/src/constant.ts new file mode 100644 index 0000000000..bfc196335b --- /dev/null +++ b/foreign/node/src/constant.ts @@ -0,0 +1,19 @@ +// 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. + +/** 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 0000000000..992ff86a4d --- /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 0000000000..35d4739192 --- /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 = { + 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 = { + 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 = { + 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 0000000000..c42eac44a6 --- /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, + heartbeatInterval?: number, + options?: Record + } +}[] = [ + { + 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)[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 eeb8c3d640..95caa7232d 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 f69873421b..80109e4f1a 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,