From 44edc7c4d4a306bd47dd260df91b24bce5d515f9 Mon Sep 17 00:00:00 2001 From: T1B0 Date: Tue, 18 Aug 2026 18:07:11 +0200 Subject: [PATCH] feat(node): support connection string syntax for nodejs client creation --- foreign/node/README.md | 18 ++ foreign/node/src/client/client.config.ts | 8 +- .../client/client.connection-string.test.ts | 185 +++++++++++++++ .../src/client/client.connection-string.ts | 212 ++++++++++++++++++ foreign/node/src/client/client.socket.ts | 5 +- foreign/node/src/client/client.ts | 19 +- foreign/node/src/client/client.type.ts | 17 +- foreign/node/src/client/index.ts | 1 + .../node/src/e2e/tcp.connection-string.e2e.ts | 42 ++++ 9 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 foreign/node/src/client/client.connection-string.test.ts create mode 100644 foreign/node/src/client/client.connection-string.ts create mode 100644 foreign/node/src/e2e/tcp.connection-string.e2e.ts diff --git a/foreign/node/README.md b/foreign/node/README.md index 169500437f..0a71bafe2e 100644 --- a/foreign/node/README.md +++ b/foreign/node/README.md @@ -105,6 +105,24 @@ const client = new Client({ const stats = await client.system.getStats(); ``` +### Connection strings + +Every client constructor 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. + ## use sources ### Install diff --git a/foreign/node/src/client/client.config.ts b/foreign/node/src/client/client.config.ts index 7c94bdbe40..26f824ae76 100644 --- a/foreign/node/src/client/client.config.ts +++ b/foreign/node/src/client/client.config.ts @@ -15,7 +15,8 @@ // specific language governing permissions and limitations // under the License. -import type { ClientConfig } from './client.type.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 +30,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) || 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..c3ff07c4a6 --- /dev/null +++ b/foreign/node/src/client/client.connection-string.test.ts @@ -0,0 +1,185 @@ +// 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 { + 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' } + } + ); + }); + + 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' } + } + ); + }); + + 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' } + } + ); + }); + + 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('maps nodelay to the socket option', () => { + assert.equal( + parseConnectionString('iggy://iggy:secret@localhost:8090?nodelay=true') + .options.noDelay, + true + ); + }); + + it('maps unlimited retries to a safe integer ceiling', () => { + assert.equal( + parseConnectionString( + 'iggy://iggy:secret@localhost:8090?reconnection_retries=unlimited' + ).reconnect?.maxRetries, + Number.MAX_SAFE_INTEGER + ); + }); + + 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' } + } + ); + }); + + 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' + ]) + assert.throws(() => parseConnectionString(value), TypeError); + }); + + it('parses IPv6 host addresses', () => { + assert.deepEqual( + parseConnectionString('iggy://iggy:secret@[::1]:8090').options, + { host: '[::1]', port: 8090 } + ); + }); +}); + +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); + }); + + it('rejects unsupported durations', () => { + for (const value of ['5', '5d', 's', '-1s', 'ms']) + 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 }); + }); +}); 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..fadf3bbb1a --- /dev/null +++ b/foreign/node/src/client/client.connection-string.ts @@ -0,0 +1,212 @@ +// 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 { readFileSync } from 'node:fs'; +import type { ClientConfig, ReconnectOption } from './client.type.js'; + +const DEFAULT_PROTOCOL = 'iggy'; +const SCHEME_PREFIX = 'iggy+'; +const SUPPORTED_PROTOCOLS = ['tcp'] as const; + +/** Duration units in milliseconds. */ +const DURATION_UNITS = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000 +} as const; + +/** Parses a duration such as "500ms" or "5s" into milliseconds. */ +export const parseDuration = (value: string): number => { + const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value); + if (!match) + throw new TypeError(`invalid duration in connection string: "${value}"`); + return Number(match[1]) * DURATION_UNITS[match[2] as keyof typeof DURATION_UNITS]; +}; + +/** + * 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: "${connectionString}"`); + + 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: "${connectionString}"`); + if (!SUPPORTED_PROTOCOLS.includes(protocol as (typeof SUPPORTED_PROTOCOLS)[number])) + throw new TypeError( + `unsupported transport "${protocol}" in connection string, ` + + 'Node SDK supports tcp only' + ); + + const parts = protocolParts[1].split('@'); + if (parts.length !== 2) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const credentials = parts[0].split(':'); + const tokenCredentials = credentials.length === 1; + if (!tokenCredentials && credentials.length !== 2) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const username = credentials[0]; + const password = credentials[1] ?? ''; + if (!tokenCredentials && (username.length === 0 || password.length === 0)) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const serverAndOptions = parts[1].split('?'); + if (serverAndOptions.length > 2) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const serverAddress = serverAndOptions[0]; + if (serverAddress.length === 0 || + !serverAddress.includes(':') || + serverAddress.startsWith(':')) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const port = serverAddress.slice(serverAddress.lastIndexOf(':') + 1); + if (port.length === 0 || !/^\d+$/.test(port) || Number(port) > 65535) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const host = serverAddress.slice(0, serverAddress.lastIndexOf(':')); + if (host.length === 0) + throw new TypeError(`invalid connection string: "${connectionString}"`); + + const options: ParsedConnectionOptions = serverAndOptions[1] + ? parseConnectionOptions(serverAndOptions[1], connectionString) + : { tls: false }; + 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 } + }; + if (reconnect) + config.reconnect = reconnect; + if (heartbeatInterval !== undefined) + config.heartbeatInterval = heartbeatInterval; + + return config; +}; + +type ParsedConnectionOptions = { + tls: boolean, + noDelay?: boolean, + servername?: string, + ca?: Buffer, + reconnect?: ReconnectOption, + heartbeatInterval?: number +}; + +const parseConnectionOptions = ( + optionsString: string, + connectionString: string +): ParsedConnectionOptions => { + const parsed: ParsedConnectionOptions = { tls: false }; + for (const option of optionsString.split('&')) { + const optionParts = option.split('='); + if (optionParts.length !== 2) + throw new TypeError(`invalid connection string: "${connectionString}"`); + const [name, value] = optionParts; + switch (name) { + case 'tls': + parsed.tls = parseBoolean(name, value, connectionString); + break; + case 'nodelay': + parsed.noDelay = parseBoolean(name, value, connectionString); + break; + case 'tls_domain': + parsed.servername = value; + break; + case 'tls_ca_file': + parsed.ca = readFileSync(value); + break; + case 'reconnection_retries': + parsed.reconnect = { + enabled: true, + interval: parsed.reconnect?.interval ?? 5000, + maxRetries: value === 'unlimited' + ? Number.MAX_SAFE_INTEGER + : parseNumber(name, value, connectionString) + }; + break; + case 'reconnection_interval': + parsed.reconnect = { + enabled: true, + maxRetries: parsed.reconnect?.maxRetries ?? 12, + interval: parseDuration(value) + }; + break; + case 'reestablish_after': + // No Node equivalent: accepted for format compatibility. + break; + case 'heartbeat_interval': + parsed.heartbeatInterval = parseDuration(value); + break; + default: + throw new TypeError( + `unknown option "${name}" in connection string: "${connectionString}"` + ); + } + } + return parsed; +}; + +const parseBoolean = ( + name: string, + value: string, + connectionString: string +): boolean => { + if (value !== 'true' && value !== 'false') + throw new TypeError( + `option "${name}" must be true or false in connection string: "${connectionString}"` + ); + return value === 'true'; +}; + +const parseNumber = ( + name: string, + value: string, + connectionString: string +): number => { + if (!/^\d+$/.test(value)) + throw new TypeError( + `option "${name}" must be a non-negative integer in connection string: "${connectionString}"` + ); + return Number(value); +}; diff --git a/foreign/node/src/client/client.socket.ts b/foreign/node/src/client/client.socket.ts index d1e5e4fe2a..5e577cc28f 100644 --- a/foreign/node/src/client/client.socket.ts +++ b/foreign/node/src/client/client.socket.ts @@ -19,6 +19,7 @@ import { EventEmitter } from 'node:events'; import type { ClientConfig, + ClientConfigOrString, ClientCredentials, CommandResponse, PasswordCredentials, RawClient, SendCommandOptions, TokenCredentials @@ -122,7 +123,7 @@ export class CommandResponseStream extends EventEmitter { * * @param options - Client configuration */ - constructor(options: ClientConfig) { + constructor(options: ClientConfigOrString) { super(); const normalizedConfig = normalizeClientConfig(options); this.options = normalizedConfig; @@ -657,7 +658,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 cfb7d2626f..25ac8156bf 100644 --- a/foreign/node/src/client/client.ts +++ b/foreign/node/src/client/client.ts @@ -17,7 +17,7 @@ // 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'; @@ -79,9 +79,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); @@ -125,11 +125,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; } /** @@ -170,10 +171,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 4df9813e79..6362db6a23 100644 --- a/foreign/node/src/client/client.type.ts +++ b/foreign/node/src/client/client.type.ts @@ -17,20 +17,21 @@ // 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. */ -export type TlsOption = { port: number } & ConnectionOptions; +export type TlsOption = { port: number } & ConnectionOptions & Partial; /** * Response from a command sent to the Iggy server. @@ -146,6 +147,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. */ diff --git a/foreign/node/src/client/index.ts b/foreign/node/src/client/index.ts index d17a9e603e..85d4285256 100644 --- a/foreign/node/src/client/index.ts +++ b/foreign/node/src/client/index.ts @@ -18,6 +18,7 @@ export { Client, SimpleClient, SingleClient } from './client.js' export * from './client.config.js'; +export * 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/e2e/tcp.connection-string.e2e.ts b/foreign/node/src/e2e/tcp.connection-string.e2e.ts new file mode 100644 index 0000000000..d6c0dbe65c --- /dev/null +++ b/foreign/node/src/e2e/tcp.connection-string.e2e.ts @@ -0,0 +1,42 @@ +// 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 { getIggyAddress } from '../tcp.sm.utils.js'; + +const dummyOpt = 'nodelay=true' + + '&reconnection_retries=1' + + '&reconnection_interval=1s' + + '&heartbeat_interval=10s' + + '&reconnection_retries=unlimited' + + '&tls=false'; + +describe('e2e -> connection string', async () => { + const [host, port] = getIggyAddress(); + const client = new Client(`iggy://iggy:iggy@${host}:${port}?${dummyOpt}`); + + it('e2e -> connection string::ping', async () => { + assert.ok(await client.system.ping()); + }); + + after(() => { + client.destroy(); + }); +});