feat(ipcclient): Add clientOnly mode to prevent clients from auto-starting servers and improve registration/reconnect behavior
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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)
|
## 2025-08-28 - 2.1.3 - fix(classes.ipcchannel)
|
||||||
Normalize heartbeatThrowOnTimeout option parsing and allow registering 'heartbeatTimeout' via IpcChannel.on
|
Normalize heartbeatThrowOnTimeout option parsing and allow registering 'heartbeatTimeout' via IpcChannel.on
|
||||||
|
|
||||||
|
27
readme.md
27
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
|
### 💓 Graceful Heartbeat Monitoring
|
||||||
|
|
||||||
Keep connections alive without crashing on timeouts:
|
Keep connections alive without crashing on timeouts:
|
||||||
|
@@ -192,6 +192,61 @@ tap.test('Client retry should work with delayed server', async () => {
|
|||||||
await server.stop();
|
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
|
// Cleanup
|
||||||
tap.test('Cleanup test socket', async () => {
|
tap.test('Cleanup test socket', async () => {
|
||||||
try {
|
try {
|
||||||
|
19
test/test.ts
19
test/test.ts
@@ -2,6 +2,10 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|||||||
import * as smartipc from '../ts/index.js';
|
import * as smartipc from '../ts/index.js';
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
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 server: smartipc.IpcServer;
|
||||||
let client1: smartipc.IpcClient;
|
let client1: smartipc.IpcClient;
|
||||||
@@ -11,12 +15,13 @@ let client2: smartipc.IpcClient;
|
|||||||
tap.test('should create and start an IPC server', async () => {
|
tap.test('should create and start an IPC server', async () => {
|
||||||
server = smartipc.SmartIpc.createServer({
|
server = smartipc.SmartIpc.createServer({
|
||||||
id: 'test-server',
|
id: 'test-server',
|
||||||
socketPath: '/tmp/test-smartipc.sock',
|
socketPath: testSocketPath,
|
||||||
|
autoCleanupSocketFile: true,
|
||||||
heartbeat: true,
|
heartbeat: true,
|
||||||
heartbeatInterval: 2000
|
heartbeatInterval: 2000
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start({ readyWhen: 'accepting' });
|
||||||
expect(server.getStats().isRunning).toBeTrue();
|
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 () => {
|
tap.test('should create and connect a client', async () => {
|
||||||
client1 = smartipc.SmartIpc.createClient({
|
client1 = smartipc.SmartIpc.createClient({
|
||||||
id: 'test-server',
|
id: 'test-server',
|
||||||
socketPath: '/tmp/test-smartipc.sock',
|
socketPath: testSocketPath,
|
||||||
clientId: 'client-1',
|
clientId: 'client-1',
|
||||||
metadata: { name: 'Test Client 1' },
|
metadata: { name: 'Test Client 1' },
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
heartbeat: true
|
heartbeat: true,
|
||||||
|
clientOnly: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await client1.connect();
|
await client1.connect();
|
||||||
@@ -76,9 +82,10 @@ tap.test('should handle request/response pattern', async () => {
|
|||||||
tap.test('should handle multiple clients', async () => {
|
tap.test('should handle multiple clients', async () => {
|
||||||
client2 = smartipc.SmartIpc.createClient({
|
client2 = smartipc.SmartIpc.createClient({
|
||||||
id: 'test-server',
|
id: 'test-server',
|
||||||
socketPath: '/tmp/test-smartipc.sock',
|
socketPath: testSocketPath,
|
||||||
clientId: 'client-2',
|
clientId: 'client-2',
|
||||||
metadata: { name: 'Test Client 2' }
|
metadata: { name: 'Test Client 2' },
|
||||||
|
clientOnly: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await client2.connect();
|
await client2.connect();
|
||||||
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartipc',
|
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.'
|
description: 'A library for node inter process communication, providing an easy-to-use API for IPC.'
|
||||||
}
|
}
|
||||||
|
@@ -45,6 +45,7 @@ export class IpcClient extends plugins.EventEmitter {
|
|||||||
private messageHandlers = new Map<string, (payload: any) => any | Promise<any>>();
|
private messageHandlers = new Map<string, (payload: any) => any | Promise<any>>();
|
||||||
private isConnected = false;
|
private isConnected = false;
|
||||||
private clientId: string;
|
private clientId: string;
|
||||||
|
private didRegisterOnce = false;
|
||||||
|
|
||||||
constructor(options: IIpcClientOptions) {
|
constructor(options: IIpcClientOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -66,30 +67,7 @@ export class IpcClient extends plugins.EventEmitter {
|
|||||||
|
|
||||||
// Helper function to attempt registration
|
// Helper function to attempt registration
|
||||||
const attemptRegistration = async (): Promise<void> => {
|
const attemptRegistration = async (): Promise<void> => {
|
||||||
const registerTimeoutMs = this.options.registerTimeoutMs || 5000;
|
await this.attemptRegistrationInternal();
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.channel.request<any, any>(
|
|
||||||
'__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}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to attempt connection with retry
|
// 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<void> {
|
||||||
|
const registerTimeoutMs = this.options.registerTimeoutMs || 5000;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.channel.request<any, any>(
|
||||||
|
'__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
|
* Disconnect from the server
|
||||||
*/
|
*/
|
||||||
@@ -188,8 +198,16 @@ export class IpcClient extends plugins.EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private setupChannelHandlers(): void {
|
private setupChannelHandlers(): void {
|
||||||
// Forward channel events
|
// Forward channel events
|
||||||
this.channel.on('connect', () => {
|
this.channel.on('connect', async () => {
|
||||||
// Don't emit connect here, wait for successful registration
|
// 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) => {
|
this.channel.on('disconnect', (reason) => {
|
||||||
|
@@ -18,6 +18,12 @@ export interface IIpcMessageEnvelope<T = any> {
|
|||||||
export interface IIpcTransportOptions {
|
export interface IIpcTransportOptions {
|
||||||
/** Unique identifier for this transport */
|
/** Unique identifier for this transport */
|
||||||
id: string;
|
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 */
|
/** Socket path for Unix domain sockets or pipe name for Windows */
|
||||||
socketPath?: string;
|
socketPath?: string;
|
||||||
/** TCP host for network transport */
|
/** TCP host for network transport */
|
||||||
@@ -195,7 +201,21 @@ export class UnixSocketTransport extends IpcTransport {
|
|||||||
|
|
||||||
this.socket.on('error', (error: any) => {
|
this.socket.on('error', (error: any) => {
|
||||||
if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') {
|
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.socket = null;
|
||||||
this.startServer(socketPath).then(resolve).catch(reject);
|
this.startServer(socketPath).then(resolve).catch(reject);
|
||||||
} else {
|
} else {
|
||||||
|
@@ -35,6 +35,7 @@ export class SmartIpc {
|
|||||||
socketPath: options.socketPath,
|
socketPath: options.socketPath,
|
||||||
clientId: `probe-${process.pid}-${Date.now()}`,
|
clientId: `probe-${process.pid}-${Date.now()}`,
|
||||||
heartbeat: false,
|
heartbeat: false,
|
||||||
|
clientOnly: true,
|
||||||
connectRetry: {
|
connectRetry: {
|
||||||
enabled: false // Don't retry, we're handling retries here
|
enabled: false // Don't retry, we're handling retries here
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user