diff --git a/changelog.md b/changelog.md index 49e7c77..e6b6270 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-08-29 - 2.2.0 - feat(ipcclient) +Add clientOnly mode to prevent clients from auto-starting servers and improve registration/reconnect behavior + +- Introduce a clientOnly option on transports and clients, and support SMARTIPC_CLIENT_ONLY=1 env override to prevent a client from auto-starting a server when connect() encounters ECONNREFUSED/ENOENT. +- Update UnixSocketTransport/TcpTransport connect behavior: if clientOnly (or env override) is enabled, reject connect with a descriptive error instead of starting a server (preserves backward compatibility when disabled). +- Make SmartIpc.waitForServer use clientOnly probing to avoid accidental server creation during readiness checks. +- Refactor IpcClient registration flow: extract attemptRegistrationInternal, set didRegisterOnce flag, and automatically re-register on reconnects when previously registered. +- Add and update tests to cover clientOnly behavior, SMARTIPC_CLIENT_ONLY env enforcement, temporary socket paths and automatic cleanup, and other reliability improvements. +- Update README with a new 'Client-Only Mode' section documenting the option, env override, and examples. + ## 2025-08-28 - 2.1.3 - fix(classes.ipcchannel) Normalize heartbeatThrowOnTimeout option parsing and allow registering 'heartbeatTimeout' via IpcChannel.on diff --git a/readme.md b/readme.md index 8cc249c..67c6506 100644 --- a/readme.md +++ b/readme.md @@ -238,6 +238,33 @@ await client.connect({ }); ``` +### 🛑 Client-Only Mode (No Auto-Start) + +In some setups (CLI + long-running daemon), you want clients to fail fast when no server is available, rather than implicitly becoming the server. Enable client-only mode to prevent the “client becomes server” fallback for Unix domain sockets and Windows named pipes. + +```typescript +// Strict client that never auto-starts a server on connect failure +const client = SmartIpc.createClient({ + id: 'my-service', + socketPath: '/tmp/my-service.sock', + clientId: 'my-cli', + clientOnly: true, // NEW: disable auto-start fallback + connectRetry: { enabled: false } // optional: fail fast +}); + +try { + await client.connect(); +} catch (err) { + // With clientOnly: true, errors become descriptive + // e.g. "Server not available (ENOENT); clientOnly prevents auto-start" + console.error(err.message); +} +``` + +- Default: `clientOnly` is `false` to preserve backward compatibility. +- Env override: set `SMARTIPC_CLIENT_ONLY=1` to enforce client-only behavior without code changes. +- Note: `SmartIpc.waitForServer()` internally uses `clientOnly: true` for safe probing. + ### 💓 Graceful Heartbeat Monitoring Keep connections alive without crashing on timeouts: @@ -594,4 +621,4 @@ Registered at District court Bremen HRB 35230 HB, Germany For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. -By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. \ No newline at end of file +By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. diff --git a/test/test.improvements.ts b/test/test.improvements.ts index 8e65954..dc2019c 100644 --- a/test/test.improvements.ts +++ b/test/test.improvements.ts @@ -192,6 +192,61 @@ tap.test('Client retry should work with delayed server', async () => { await server.stop(); }); +// Test 7: clientOnly prevents client from auto-starting a server +tap.test('clientOnly should prevent auto-start and fail fast', async () => { + const uniqueSocketPath = path.join(os.tmpdir(), `smartipc-clientonly-${Date.now()}.sock`); + + const client = smartipc.SmartIpc.createClient({ + id: 'clientonly-test', + socketPath: uniqueSocketPath, + clientId: 'co-client-1', + clientOnly: true, + connectRetry: { enabled: false } + }); + + let failed = false; + try { + await client.connect(); + } catch (err: any) { + failed = true; + expect(err.message).toContain('clientOnly prevents auto-start'); + } + expect(failed).toBeTrue(); + // Ensure no server-side socket was created + expect(fs.existsSync(uniqueSocketPath)).toBeFalse(); +}); + +// Test 8: env SMARTIPC_CLIENT_ONLY enforces clientOnly behavior +tap.test('SMARTIPC_CLIENT_ONLY=1 should enforce clientOnly', async () => { + const uniqueSocketPath = path.join(os.tmpdir(), `smartipc-clientonly-env-${Date.now()}.sock`); + const prev = process.env.SMARTIPC_CLIENT_ONLY; + process.env.SMARTIPC_CLIENT_ONLY = '1'; + + const client = smartipc.SmartIpc.createClient({ + id: 'clientonly-test-env', + socketPath: uniqueSocketPath, + clientId: 'co-client-2', + connectRetry: { enabled: false } + }); + + let failed = false; + try { + await client.connect(); + } catch (err: any) { + failed = true; + expect(err.message).toContain('clientOnly prevents auto-start'); + } + expect(failed).toBeTrue(); + expect(fs.existsSync(uniqueSocketPath)).toBeFalse(); + + // restore env + if (prev === undefined) { + delete process.env.SMARTIPC_CLIENT_ONLY; + } else { + process.env.SMARTIPC_CLIENT_ONLY = prev; + } +}); + // Cleanup tap.test('Cleanup test socket', async () => { try { @@ -201,4 +256,4 @@ tap.test('Cleanup test socket', async () => { } }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/test/test.ts b/test/test.ts index 5b2b74a..18b8fa4 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,6 +2,10 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as smartipc from '../ts/index.js'; import * as smartdelay from '@push.rocks/smartdelay'; import * as smartpromise from '@push.rocks/smartpromise'; +import * as path from 'path'; +import * as os from 'os'; + +const testSocketPath = path.join(os.tmpdir(), `test-smartipc-${Date.now()}.sock`); let server: smartipc.IpcServer; let client1: smartipc.IpcClient; @@ -11,12 +15,13 @@ let client2: smartipc.IpcClient; tap.test('should create and start an IPC server', async () => { server = smartipc.SmartIpc.createServer({ id: 'test-server', - socketPath: '/tmp/test-smartipc.sock', + socketPath: testSocketPath, + autoCleanupSocketFile: true, heartbeat: true, heartbeatInterval: 2000 }); - await server.start(); + await server.start({ readyWhen: 'accepting' }); expect(server.getStats().isRunning).toBeTrue(); }); @@ -24,11 +29,12 @@ tap.test('should create and start an IPC server', async () => { tap.test('should create and connect a client', async () => { client1 = smartipc.SmartIpc.createClient({ id: 'test-server', - socketPath: '/tmp/test-smartipc.sock', + socketPath: testSocketPath, clientId: 'client-1', metadata: { name: 'Test Client 1' }, autoReconnect: true, - heartbeat: true + heartbeat: true, + clientOnly: true }); await client1.connect(); @@ -76,9 +82,10 @@ tap.test('should handle request/response pattern', async () => { tap.test('should handle multiple clients', async () => { client2 = smartipc.SmartIpc.createClient({ id: 'test-server', - socketPath: '/tmp/test-smartipc.sock', + socketPath: testSocketPath, clientId: 'client-2', - metadata: { name: 'Test Client 2' } + metadata: { name: 'Test Client 2' }, + clientOnly: true }); await client2.connect(); @@ -296,4 +303,4 @@ tap.test('should cleanup and close all connections', async () => { expect(client1.getIsConnected()).toBeFalse(); }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a03eb16..04b32e9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartipc', - version: '2.1.3', + version: '2.2.0', description: 'A library for node inter process communication, providing an easy-to-use API for IPC.' } diff --git a/ts/classes.ipcclient.ts b/ts/classes.ipcclient.ts index 4d535d4..24ddd4f 100644 --- a/ts/classes.ipcclient.ts +++ b/ts/classes.ipcclient.ts @@ -45,6 +45,7 @@ export class IpcClient extends plugins.EventEmitter { private messageHandlers = new Map any | Promise>(); private isConnected = false; private clientId: string; + private didRegisterOnce = false; constructor(options: IIpcClientOptions) { super(); @@ -66,30 +67,7 @@ export class IpcClient extends plugins.EventEmitter { // Helper function to attempt registration const attemptRegistration = async (): Promise => { - const registerTimeoutMs = this.options.registerTimeoutMs || 5000; - - try { - const response = await this.channel.request( - '__register__', - { - clientId: this.clientId, - metadata: this.options.metadata - }, - { - timeout: registerTimeoutMs, - headers: { clientId: this.clientId } // Include clientId in headers for proper routing - } - ); - - if (!response.success) { - throw new Error(response.error || 'Registration failed'); - } - - this.isConnected = true; - this.emit('connect'); - } catch (error) { - throw new Error(`Failed to register with server: ${error.message}`); - } + await this.attemptRegistrationInternal(); }; // Helper function to attempt connection with retry @@ -170,6 +148,38 @@ export class IpcClient extends plugins.EventEmitter { } } + /** + * Attempt to register this client over the current channel connection. + * Sets connection flags and emits 'connect' on success. + */ + private async attemptRegistrationInternal(): Promise { + const registerTimeoutMs = this.options.registerTimeoutMs || 5000; + + try { + const response = await this.channel.request( + '__register__', + { + clientId: this.clientId, + metadata: this.options.metadata + }, + { + timeout: registerTimeoutMs, + headers: { clientId: this.clientId } + } + ); + + if (!response.success) { + throw new Error(response.error || 'Registration failed'); + } + + this.isConnected = true; + this.didRegisterOnce = true; + this.emit('connect'); + } catch (error: any) { + throw new Error(`Failed to register with server: ${error.message}`); + } + } + /** * Disconnect from the server */ @@ -188,8 +198,16 @@ export class IpcClient extends plugins.EventEmitter { */ private setupChannelHandlers(): void { // Forward channel events - this.channel.on('connect', () => { - // Don't emit connect here, wait for successful registration + this.channel.on('connect', async () => { + // On reconnects, re-register automatically when we had connected before + if (this.didRegisterOnce && !this.isConnected) { + try { + await this.attemptRegistrationInternal(); + } catch (error) { + this.emit('error', error); + } + } + // For initial connect(), registration is handled explicitly there }); this.channel.on('disconnect', (reason) => { @@ -343,4 +361,4 @@ export class IpcClient extends plugins.EventEmitter { public getStats(): any { return this.channel.getStats(); } -} \ No newline at end of file +} diff --git a/ts/classes.transports.ts b/ts/classes.transports.ts index 2c7eec5..e2fcdf9 100644 --- a/ts/classes.transports.ts +++ b/ts/classes.transports.ts @@ -18,6 +18,12 @@ export interface IIpcMessageEnvelope { export interface IIpcTransportOptions { /** Unique identifier for this transport */ id: string; + /** + * When true, a client transport will NOT auto-start a server when connect() + * encounters ECONNREFUSED/ENOENT. Useful for strict client/daemon setups. + * Default: false. Can also be overridden by env SMARTIPC_CLIENT_ONLY=1. + */ + clientOnly?: boolean; /** Socket path for Unix domain sockets or pipe name for Windows */ socketPath?: string; /** TCP host for network transport */ @@ -195,7 +201,21 @@ export class UnixSocketTransport extends IpcTransport { this.socket.on('error', (error: any) => { if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') { - // No server exists, we should become the server + // Determine if we must NOT auto-start server + const envVal = process.env.SMARTIPC_CLIENT_ONLY; + const envClientOnly = !!envVal && (envVal === '1' || envVal === 'true' || envVal === 'TRUE'); + const clientOnly = this.options.clientOnly === true || envClientOnly; + + if (clientOnly) { + // Reject instead of starting a server to avoid races + const reason = error.code || 'UNKNOWN'; + const err = new Error(`Server not available (${reason}); clientOnly prevents auto-start`); + (err as any).code = reason; + reject(err); + return; + } + + // No server exists and clientOnly is false: become the server (back-compat) this.socket = null; this.startServer(socketPath).then(resolve).catch(reject); } else { @@ -718,4 +738,4 @@ export function createTransport(options: IIpcTransportOptions): IpcTransport { } else { return new UnixSocketTransport(options); } -} \ No newline at end of file +} diff --git a/ts/index.ts b/ts/index.ts index 633e21c..d8ae02f 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -35,6 +35,7 @@ export class SmartIpc { socketPath: options.socketPath, clientId: `probe-${process.pid}-${Date.now()}`, heartbeat: false, + clientOnly: true, connectRetry: { enabled: false // Don't retry, we're handling retries here }, @@ -127,4 +128,4 @@ export class SmartIpc { } // Export the main class as default -export default SmartIpc; \ No newline at end of file +export default SmartIpc;