6 Commits

Author SHA1 Message Date
44770bf820 2.2.1
Some checks failed
Default (tags) / security (push) Successful in 27s
Default (tags) / test (push) Failing after 3m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-29 08:49:04 +00:00
6c77ca1e4c fix(tests): Remove redundant manual topic handlers from tests and rely on server built-in pub/sub 2025-08-29 08:49:04 +00:00
350b3f1359 2.2.0
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 3m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-29 08:48:38 +00:00
fa53dcfc4f feat(ipcclient): Add clientOnly mode to prevent clients from auto-starting servers and improve registration/reconnect behavior 2025-08-29 08:48:38 +00:00
fd3fc7518b 2.1.3 2025-08-28 20:12:40 +00:00
1b462e3a35 fix(classes.ipcchannel): Normalize heartbeatThrowOnTimeout option parsing and allow registering heartbeatTimeout via IpcChannel.on 2025-08-28 20:12:40 +00:00
10 changed files with 213 additions and 54 deletions

View File

@@ -1,5 +1,28 @@
# Changelog # Changelog
## 2025-08-29 - 2.2.1 - fix(tests)
Remove redundant manual topic handlers from tests and rely on server built-in pub/sub
- Removed manual server.onMessage('__subscribe__') and server.onMessage('__publish__') handlers from test/test.ts
- Tests now rely on the server's built-in publish/subscribe behavior: clients publish directly and subscribers receive messages
- Test code simplified without changing public API or runtime behavior
## 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
- Normalize heartbeatThrowOnTimeout to boolean (accepts 'true'/'false' strings and other truthy/falsey values) to be defensive for JS consumers
- Expose 'heartbeatTimeout' as a special channel event so handlers registered via IpcChannel.on('heartbeatTimeout', ...) will be called
## 2025-08-26 - 2.1.2 - fix(core) ## 2025-08-26 - 2.1.2 - fix(core)
Improve heartbeat handling and transport routing; forward heartbeat timeout events; include clientId routing and probe improvements Improve heartbeat handling and transport routing; forward heartbeat timeout events; include clientId routing and probe improvements
@@ -75,4 +98,11 @@ Metadata and configuration updates; repository/org migration.
Initial release and a series of patch fixes to core components. Initial release and a series of patch fixes to core components.
- 1.0.1: initial release. - 1.0.1: initial release.
- 1.0.2 → 1.0.7: a sequence of small core fixes and maintenance updates (repeated "fix(core): update" commits). - 1.0.2 → 1.0.7: a sequence of small core fixes and maintenance updates (repeated "fix(core): update" commits).
## 2025-08-29 - 2.1.4 - feat(transports)
Add client-only mode to prevent unintended server auto-start in Unix/NamedPipe transports; safer probing
- Add `clientOnly?: boolean` to transport options; when true (or `SMARTIPC_CLIENT_ONLY=1`), a client will fail fast on `ECONNREFUSED`/`ENOENT` instead of auto-starting a server.
- Update `SmartIpc.waitForServer()` to probe with `clientOnly: true` to avoid races during readiness checks.
- Extend tests to cover option and env override; update core test to use unique socket path and auto-cleanup.
- Docs: add README section for client-only mode.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartipc", "name": "@push.rocks/smartipc",
"version": "2.1.2", "version": "2.2.1",
"private": false, "private": false,
"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.",
"exports": { "exports": {

View File

@@ -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:
@@ -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. 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. 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.

View File

@@ -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 {
@@ -201,4 +256,4 @@ tap.test('Cleanup test socket', async () => {
} }
}); });
export default tap.start(); export default tap.start();

View File

@@ -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();
@@ -154,17 +161,6 @@ tap.test('should handle pub/sub pattern', async () => {
messageReceived.resolve(); messageReceived.resolve();
}); });
// Server handles the subscription
server.onMessage('__subscribe__', async (payload, clientId) => {
expect(payload.topic).toEqual('news');
});
// Server handles publishing
server.onMessage('__publish__', async (payload, clientId) => {
// Broadcast to all subscribers of the topic
await server.broadcast(`topic:${payload.topic}`, payload.payload);
});
// Client 2 publishes to the topic // Client 2 publishes to the topic
await client2.publish('news', { headline: 'Breaking news!' }); await client2.publish('news', { headline: 'Breaking news!' });
@@ -296,4 +292,4 @@ tap.test('should cleanup and close all connections', async () => {
expect(client1.getIsConnected()).toBeFalse(); expect(client1.getIsConnected()).toBeFalse();
}); });
export default tap.start(); export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartipc', name: '@push.rocks/smartipc',
version: '2.1.2', version: '2.2.1',
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.'
} }

View File

@@ -82,6 +82,18 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
...options ...options
}; };
// Normalize heartbeatThrowOnTimeout to boolean (defensive for JS consumers)
const throwOnTimeout = (this.options as any).heartbeatThrowOnTimeout;
if (throwOnTimeout !== undefined) {
if (throwOnTimeout === 'false') {
this.options.heartbeatThrowOnTimeout = false;
} else if (throwOnTimeout === 'true') {
this.options.heartbeatThrowOnTimeout = true;
} else if (typeof throwOnTimeout !== 'boolean') {
this.options.heartbeatThrowOnTimeout = Boolean(throwOnTimeout);
}
}
this.transport = createTransport(this.options); this.transport = createTransport(this.options);
this.setupTransportHandlers(); this.setupTransportHandlers();
} }
@@ -449,7 +461,7 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
* Register a message handler * Register a message handler
*/ */
public on(event: string, handler: (payload: any) => any | Promise<any>): this { public on(event: string, handler: (payload: any) => any | Promise<any>): this {
if (event === 'message' || event === 'connect' || event === 'disconnect' || event === 'error' || event === 'reconnecting' || event === 'drain') { if (event === 'message' || event === 'connect' || event === 'disconnect' || event === 'error' || event === 'reconnecting' || event === 'drain' || event === 'heartbeatTimeout') {
// Special handling for channel events // Special handling for channel events
super.on(event, handler); super.on(event, handler);
} else { } else {

View File

@@ -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) => {
@@ -343,4 +361,4 @@ export class IpcClient extends plugins.EventEmitter {
public getStats(): any { public getStats(): any {
return this.channel.getStats(); return this.channel.getStats();
} }
} }

View File

@@ -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 {
@@ -718,4 +738,4 @@ export function createTransport(options: IIpcTransportOptions): IpcTransport {
} else { } else {
return new UnixSocketTransport(options); return new UnixSocketTransport(options);
} }
} }

View File

@@ -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
}, },
@@ -127,4 +128,4 @@ export class SmartIpc {
} }
// Export the main class as default // Export the main class as default
export default SmartIpc; export default SmartIpc;