BREAKING CHANGE(core): Refactor core IPC: replace node-ipc with native transports and add IpcChannel / IpcServer / IpcClient with heartbeat, reconnection, request/response and pub/sub. Update tests and documentation.
This commit is contained in:
508
ts/classes.ipcserver.ts
Normal file
508
ts/classes.ipcserver.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import * as plugins from './smartipc.plugins.js';
|
||||
import { IpcChannel } from './classes.ipcchannel.js';
|
||||
import type { IIpcChannelOptions } from './classes.ipcchannel.js';
|
||||
|
||||
/**
|
||||
* Options for IPC Server
|
||||
*/
|
||||
export interface IIpcServerOptions extends Omit<IIpcChannelOptions, 'autoReconnect' | 'reconnectDelay' | 'maxReconnectDelay' | 'reconnectMultiplier' | 'maxReconnectAttempts'> {
|
||||
/** Maximum number of client connections */
|
||||
maxClients?: number;
|
||||
/** Client idle timeout in ms */
|
||||
clientIdleTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client connection information
|
||||
*/
|
||||
interface IClientConnection {
|
||||
id: string;
|
||||
channel: IpcChannel;
|
||||
connectedAt: number;
|
||||
lastActivity: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC Server for handling multiple client connections
|
||||
*/
|
||||
export class IpcServer extends plugins.EventEmitter {
|
||||
private options: IIpcServerOptions;
|
||||
private clients = new Map<string, IClientConnection>();
|
||||
private messageHandlers = new Map<string, (payload: any, clientId: string) => any | Promise<any>>();
|
||||
private primaryChannel?: IpcChannel;
|
||||
private isRunning = false;
|
||||
private clientIdleCheckTimer?: NodeJS.Timeout;
|
||||
|
||||
// Pub/sub tracking
|
||||
private topicIndex = new Map<string, Set<string>>(); // topic -> clientIds
|
||||
private clientTopics = new Map<string, Set<string>>(); // clientId -> topics
|
||||
|
||||
constructor(options: IIpcServerOptions) {
|
||||
super();
|
||||
this.options = {
|
||||
maxClients: Infinity,
|
||||
clientIdleTimeout: 0, // 0 means no timeout
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create primary channel for initial connections
|
||||
this.primaryChannel = new IpcChannel({
|
||||
...this.options,
|
||||
autoReconnect: false // Server doesn't auto-reconnect
|
||||
});
|
||||
|
||||
// Register the __register__ handler on the channel
|
||||
this.primaryChannel.on('__register__', async (payload: { clientId: string; metadata?: Record<string, any> }) => {
|
||||
const clientId = payload.clientId;
|
||||
const metadata = payload.metadata;
|
||||
|
||||
// Check max clients
|
||||
if (this.clients.size >= this.options.maxClients!) {
|
||||
return { success: false, error: 'Maximum number of clients reached' };
|
||||
}
|
||||
|
||||
// Create new client connection
|
||||
const clientConnection: IClientConnection = {
|
||||
id: clientId,
|
||||
channel: this.primaryChannel!,
|
||||
connectedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
metadata: metadata
|
||||
};
|
||||
|
||||
this.clients.set(clientId, clientConnection);
|
||||
this.emit('clientConnect', clientId, metadata);
|
||||
|
||||
return { success: true, clientId: clientId };
|
||||
});
|
||||
|
||||
// Handle other messages
|
||||
this.primaryChannel.on('message', (message) => {
|
||||
// Extract client ID from message headers
|
||||
const clientId = message.headers?.clientId || 'unknown';
|
||||
|
||||
// Update last activity
|
||||
if (this.clients.has(clientId)) {
|
||||
this.clients.get(clientId)!.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
// Handle pub/sub messages
|
||||
if (message.type === '__subscribe__') {
|
||||
const topic = message.payload?.topic;
|
||||
if (typeof topic === 'string' && topic.length) {
|
||||
let set = this.topicIndex.get(topic);
|
||||
if (!set) this.topicIndex.set(topic, (set = new Set()));
|
||||
set.add(clientId);
|
||||
let cset = this.clientTopics.get(clientId);
|
||||
if (!cset) this.clientTopics.set(clientId, (cset = new Set()));
|
||||
cset.add(topic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === '__unsubscribe__') {
|
||||
const topic = message.payload?.topic;
|
||||
const set = this.topicIndex.get(topic);
|
||||
if (set) {
|
||||
set.delete(clientId);
|
||||
if (set.size === 0) this.topicIndex.delete(topic);
|
||||
}
|
||||
const cset = this.clientTopics.get(clientId);
|
||||
if (cset) {
|
||||
cset.delete(topic);
|
||||
if (cset.size === 0) this.clientTopics.delete(clientId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === '__publish__') {
|
||||
const topic = message.payload?.topic;
|
||||
const payload = message.payload?.payload;
|
||||
const targets = this.topicIndex.get(topic);
|
||||
if (targets && targets.size) {
|
||||
// Send to subscribers
|
||||
const sends: Promise<void>[] = [];
|
||||
for (const subClientId of targets) {
|
||||
sends.push(
|
||||
this.sendToClient(subClientId, `topic:${topic}`, payload)
|
||||
.catch(err => {
|
||||
this.emit('error', err, subClientId);
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.allSettled(sends).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward to registered handlers
|
||||
if (this.messageHandlers.has(message.type)) {
|
||||
const handler = this.messageHandlers.get(message.type)!;
|
||||
|
||||
// If message expects a response
|
||||
if (message.headers?.requiresResponse && message.id) {
|
||||
Promise.resolve()
|
||||
.then(() => handler(message.payload, clientId))
|
||||
.then((result) => {
|
||||
return this.primaryChannel!.sendMessage(
|
||||
`${message.type}_response`,
|
||||
result,
|
||||
{ correlationId: message.id, clientId }
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
return this.primaryChannel!.sendMessage(
|
||||
`${message.type}_response`,
|
||||
null,
|
||||
{ correlationId: message.id, error: error.message, clientId }
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Fire and forget
|
||||
handler(message.payload, clientId);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit raw message event
|
||||
this.emit('message', message, clientId);
|
||||
});
|
||||
|
||||
// Setup primary channel handlers
|
||||
this.primaryChannel.on('disconnect', () => {
|
||||
// Server disconnected, clear all clients and subscriptions
|
||||
for (const [clientId] of this.clients) {
|
||||
this.cleanupClientSubscriptions(clientId);
|
||||
}
|
||||
this.clients.clear();
|
||||
});
|
||||
|
||||
this.primaryChannel.on('error', (error) => {
|
||||
this.emit('error', error, 'server');
|
||||
});
|
||||
|
||||
// Connect the primary channel (will start as server)
|
||||
await this.primaryChannel.connect();
|
||||
|
||||
this.isRunning = true;
|
||||
this.startClientIdleCheck();
|
||||
this.emit('start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
this.stopClientIdleCheck();
|
||||
|
||||
// Disconnect all clients
|
||||
const disconnectPromises: Promise<void>[] = [];
|
||||
for (const [clientId, client] of this.clients) {
|
||||
disconnectPromises.push(
|
||||
client.channel.disconnect()
|
||||
.then(() => {
|
||||
this.emit('clientDisconnect', clientId);
|
||||
})
|
||||
.catch(() => {}) // Ignore disconnect errors
|
||||
);
|
||||
}
|
||||
await Promise.all(disconnectPromises);
|
||||
this.clients.clear();
|
||||
|
||||
// Disconnect primary channel
|
||||
if (this.primaryChannel) {
|
||||
await this.primaryChannel.disconnect();
|
||||
this.primaryChannel = undefined;
|
||||
}
|
||||
|
||||
this.emit('stop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup channel event handlers
|
||||
*/
|
||||
private setupChannelHandlers(channel: IpcChannel, clientId: string): void {
|
||||
// Handle client registration
|
||||
channel.on('__register__', async (payload: { clientId: string; metadata?: Record<string, any> }) => {
|
||||
if (payload.clientId && payload.clientId !== clientId) {
|
||||
// New client registration
|
||||
const newClientId = payload.clientId;
|
||||
|
||||
// Check max clients
|
||||
if (this.clients.size >= this.options.maxClients!) {
|
||||
throw new Error('Maximum number of clients reached');
|
||||
}
|
||||
|
||||
// Create new client connection
|
||||
const clientConnection: IClientConnection = {
|
||||
id: newClientId,
|
||||
channel: channel,
|
||||
connectedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
metadata: payload.metadata
|
||||
};
|
||||
|
||||
this.clients.set(newClientId, clientConnection);
|
||||
this.emit('clientConnect', newClientId, payload.metadata);
|
||||
|
||||
// Now messages from this channel should be associated with the new client ID
|
||||
clientId = newClientId;
|
||||
|
||||
return { success: true, clientId: newClientId };
|
||||
}
|
||||
return { success: false, error: 'Invalid registration' };
|
||||
});
|
||||
|
||||
// Handle messages - pass the correct clientId
|
||||
channel.on('message', (message) => {
|
||||
// Try to find the actual client ID for this channel
|
||||
let actualClientId = clientId;
|
||||
for (const [id, client] of this.clients) {
|
||||
if (client.channel === channel) {
|
||||
actualClientId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last activity
|
||||
if (actualClientId !== 'primary' && this.clients.has(actualClientId)) {
|
||||
this.clients.get(actualClientId)!.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
// Forward to registered handlers
|
||||
if (this.messageHandlers.has(message.type)) {
|
||||
const handler = this.messageHandlers.get(message.type)!;
|
||||
handler(message.payload, actualClientId);
|
||||
}
|
||||
|
||||
// Emit raw message event
|
||||
this.emit('message', message, actualClientId);
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
channel.on('disconnect', () => {
|
||||
// Find and remove the actual client
|
||||
for (const [id, client] of this.clients) {
|
||||
if (client.channel === channel) {
|
||||
this.clients.delete(id);
|
||||
this.emit('clientDisconnect', id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
channel.on('error', (error) => {
|
||||
// Find the actual client ID for this channel
|
||||
let actualClientId = clientId;
|
||||
for (const [id, client] of this.clients) {
|
||||
if (client.channel === channel) {
|
||||
actualClientId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.emit('error', error, actualClientId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message handler
|
||||
*/
|
||||
public onMessage(type: string, handler: (payload: any, clientId: string) => any | Promise<any>): void {
|
||||
this.messageHandlers.set(type, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to specific client
|
||||
*/
|
||||
public async sendToClient(clientId: string, type: string, payload: any, headers?: Record<string, any>): Promise<void> {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
throw new Error(`Client ${clientId} not found`);
|
||||
}
|
||||
|
||||
await client.channel.sendMessage(type, payload, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request to specific client and wait for response
|
||||
*/
|
||||
public async requestFromClient<TReq = any, TRes = any>(
|
||||
clientId: string,
|
||||
type: string,
|
||||
payload: TReq,
|
||||
options?: { timeout?: number; headers?: Record<string, any> }
|
||||
): Promise<TRes> {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
throw new Error(`Client ${clientId} not found`);
|
||||
}
|
||||
|
||||
return client.channel.request<TReq, TRes>(type, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to all clients
|
||||
*/
|
||||
public async broadcast(type: string, payload: any, headers?: Record<string, any>): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [clientId, client] of this.clients) {
|
||||
promises.push(
|
||||
client.channel.sendMessage(type, payload, headers)
|
||||
.catch((error) => {
|
||||
this.emit('error', error, clientId);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to clients matching a filter
|
||||
*/
|
||||
public async broadcastTo(
|
||||
filter: (clientId: string, metadata?: Record<string, any>) => boolean,
|
||||
type: string,
|
||||
payload: any,
|
||||
headers?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [clientId, client] of this.clients) {
|
||||
if (filter(clientId, client.metadata)) {
|
||||
promises.push(
|
||||
client.channel.sendMessage(type, payload, headers)
|
||||
.catch((error) => {
|
||||
this.emit('error', error, clientId);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected client IDs
|
||||
*/
|
||||
public getClientIds(): string[] {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client information
|
||||
*/
|
||||
public getClientInfo(clientId: string): {
|
||||
id: string;
|
||||
connectedAt: number;
|
||||
lastActivity: number;
|
||||
metadata?: Record<string, any>;
|
||||
} | undefined {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: client.id,
|
||||
connectedAt: client.connectedAt,
|
||||
lastActivity: client.lastActivity,
|
||||
metadata: client.metadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific client
|
||||
*/
|
||||
public async disconnectClient(clientId: string): Promise<void> {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.channel.disconnect();
|
||||
this.clients.delete(clientId);
|
||||
this.cleanupClientSubscriptions(clientId);
|
||||
this.emit('clientDisconnect', clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up topic subscriptions for a disconnected client
|
||||
*/
|
||||
private cleanupClientSubscriptions(clientId: string): void {
|
||||
const topics = this.clientTopics.get(clientId);
|
||||
if (topics) {
|
||||
for (const topic of topics) {
|
||||
const set = this.topicIndex.get(topic);
|
||||
if (set) {
|
||||
set.delete(clientId);
|
||||
if (set.size === 0) this.topicIndex.delete(topic);
|
||||
}
|
||||
}
|
||||
this.clientTopics.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start checking for idle clients
|
||||
*/
|
||||
private startClientIdleCheck(): void {
|
||||
if (!this.options.clientIdleTimeout || this.options.clientIdleTimeout <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientIdleCheckTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeout = this.options.clientIdleTimeout!;
|
||||
|
||||
for (const [clientId, client] of this.clients) {
|
||||
if (now - client.lastActivity > timeout) {
|
||||
this.disconnectClient(clientId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, this.options.clientIdleTimeout / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop checking for idle clients
|
||||
*/
|
||||
private stopClientIdleCheck(): void {
|
||||
if (this.clientIdleCheckTimer) {
|
||||
clearInterval(this.clientIdleCheckTimer);
|
||||
this.clientIdleCheckTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
*/
|
||||
public getStats(): {
|
||||
isRunning: boolean;
|
||||
connectedClients: number;
|
||||
maxClients: number;
|
||||
uptime?: number;
|
||||
} {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
connectedClients: this.clients.size,
|
||||
maxClients: this.options.maxClients!,
|
||||
uptime: this.primaryChannel ? Date.now() - (this.primaryChannel as any).connectedAt : undefined
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user