feat(rustbridge): add RustBridge and RustBinaryLocator with typed IPC interfaces, plugins, tests and mock runner; export from index; add npm registries

This commit is contained in:
2026-02-10 09:10:18 +00:00
parent fad0b9e602
commit 40dec91940
14 changed files with 865 additions and 12 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartrust',
version: '1.0.2',
version: '1.1.0',
description: 'a bridge between JS engines and rust'
}

View File

@@ -0,0 +1,140 @@
import * as plugins from './plugins.js';
import type { IBinaryLocatorOptions, IRustBridgeLogger } from './interfaces/index.js';
const defaultLogger: IRustBridgeLogger = {
log() {},
};
/**
* Locates a Rust binary using a priority-ordered search strategy:
* 1. Explicit binaryPath override
* 2. Environment variable
* 3. Platform-specific npm package
* 4. Local development build paths
* 5. System PATH
*/
export class RustBinaryLocator {
private options: IBinaryLocatorOptions;
private logger: IRustBridgeLogger;
private cachedPath: string | null = null;
constructor(options: IBinaryLocatorOptions, logger?: IRustBridgeLogger) {
this.options = options;
this.logger = logger || defaultLogger;
}
/**
* Find the binary path.
* Returns null if no binary is available.
*/
public async findBinary(): Promise<string | null> {
if (this.cachedPath !== null) {
return this.cachedPath;
}
const path = await this.searchBinary();
this.cachedPath = path;
return path;
}
/**
* Clear the cached binary path.
*/
public clearCache(): void {
this.cachedPath = null;
}
private async searchBinary(): Promise<string | null> {
const { binaryName } = this.options;
// 1. Explicit binary path override
if (this.options.binaryPath) {
if (await this.isExecutable(this.options.binaryPath)) {
this.logger.log('info', `Binary found via explicit path: ${this.options.binaryPath}`);
return this.options.binaryPath;
}
this.logger.log('warn', `Explicit binary path not executable: ${this.options.binaryPath}`);
}
// 2. Environment variable override
if (this.options.envVarName) {
const envPath = process.env[this.options.envVarName];
if (envPath) {
if (await this.isExecutable(envPath)) {
this.logger.log('info', `Binary found via ${this.options.envVarName}: ${envPath}`);
return envPath;
}
this.logger.log('warn', `${this.options.envVarName} set but not executable: ${envPath}`);
}
}
// 3. Platform-specific npm package
if (this.options.platformPackagePrefix) {
const platformBinary = await this.findPlatformPackageBinary();
if (platformBinary) {
this.logger.log('info', `Binary found in platform package: ${platformBinary}`);
return platformBinary;
}
}
// 4. Local development build paths
const localPaths = this.options.localPaths || [
plugins.path.resolve(process.cwd(), `rust/target/release/${binaryName}`),
plugins.path.resolve(process.cwd(), `rust/target/debug/${binaryName}`),
];
for (const localPath of localPaths) {
if (await this.isExecutable(localPath)) {
this.logger.log('info', `Binary found at local path: ${localPath}`);
return localPath;
}
}
// 5. System PATH
if (this.options.searchSystemPath !== false) {
const systemPath = await this.findInPath(binaryName);
if (systemPath) {
this.logger.log('info', `Binary found in system PATH: ${systemPath}`);
return systemPath;
}
}
this.logger.log('error', `No binary '${binaryName}' found. Provide an explicit path, set an env var, install the platform package, or build from source.`);
return null;
}
private async findPlatformPackageBinary(): Promise<string | null> {
const { binaryName, platformPackagePrefix } = this.options;
const platform = process.platform;
const arch = process.arch;
const packageName = `${platformPackagePrefix}-${platform}-${arch}`;
try {
const packagePath = require.resolve(`${packageName}/${binaryName}`);
if (await this.isExecutable(packagePath)) {
return packagePath;
}
} catch {
// Package not installed - expected for development
}
return null;
}
private async isExecutable(filePath: string): Promise<boolean> {
try {
await plugins.fs.promises.access(filePath, plugins.fs.constants.X_OK);
return true;
} catch {
return false;
}
}
private async findInPath(binaryName: string): Promise<string | null> {
const pathDirs = (process.env.PATH || '').split(plugins.path.delimiter);
for (const dir of pathDirs) {
const fullPath = plugins.path.join(dir, binaryName);
if (await this.isExecutable(fullPath)) {
return fullPath;
}
}
return null;
}
}

256
ts/classes.rustbridge.ts Normal file
View File

@@ -0,0 +1,256 @@
import * as plugins from './plugins.js';
import { RustBinaryLocator } from './classes.rustbinarylocator.js';
import type {
IRustBridgeOptions,
IRustBridgeLogger,
TCommandMap,
IManagementRequest,
IManagementResponse,
IManagementEvent,
} from './interfaces/index.js';
const defaultLogger: IRustBridgeLogger = {
log() {},
};
/**
* Generic bridge between TypeScript and a Rust binary.
* Communicates via JSON-over-stdin/stdout IPC protocol.
*
* @typeParam TCommands - Map of command names to their param/result types
*/
export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plugins.events.EventEmitter {
private locator: RustBinaryLocator;
private options: Required<Pick<IRustBridgeOptions, 'cliArgs' | 'requestTimeoutMs' | 'readyTimeoutMs' | 'readyEventName'>> & IRustBridgeOptions;
private logger: IRustBridgeLogger;
private childProcess: plugins.childProcess.ChildProcess | null = null;
private readlineInterface: plugins.readline.Interface | null = null;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
private requestCounter = 0;
private isRunning = false;
private binaryPath: string | null = null;
constructor(options: IRustBridgeOptions) {
super();
this.logger = options.logger || defaultLogger;
this.options = {
cliArgs: ['--management'],
requestTimeoutMs: 30000,
readyTimeoutMs: 10000,
readyEventName: 'ready',
...options,
};
this.locator = new RustBinaryLocator(options, this.logger);
}
/**
* Spawn the Rust binary and wait for it to signal readiness.
* Returns true if the binary was found and spawned successfully.
*/
public async spawn(): Promise<boolean> {
this.binaryPath = await this.locator.findBinary();
if (!this.binaryPath) {
return false;
}
return new Promise<boolean>((resolve) => {
try {
const env = this.options.env
? { ...process.env, ...this.options.env }
: { ...process.env };
this.childProcess = plugins.childProcess.spawn(this.binaryPath!, this.options.cliArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
// Handle stderr
this.childProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l: string) => l.trim());
for (const line of lines) {
this.logger.log('debug', `[${this.options.binaryName}] ${line}`);
this.emit('stderr', line);
}
});
// Handle stdout via readline for line-delimited JSON
this.readlineInterface = plugins.readline.createInterface({ input: this.childProcess.stdout! });
this.readlineInterface.on('line', (line: string) => {
this.handleLine(line.trim());
});
// Handle process exit
this.childProcess.on('exit', (code, signal) => {
this.logger.log('info', `Process exited (code=${code}, signal=${signal})`);
this.cleanup();
this.emit('exit', code, signal);
});
this.childProcess.on('error', (err) => {
this.logger.log('error', `Process error: ${err.message}`);
this.cleanup();
resolve(false);
});
// Wait for the ready event
const readyTimeout = setTimeout(() => {
this.logger.log('error', `Process did not send ready event within ${this.options.readyTimeoutMs}ms`);
this.kill();
resolve(false);
}, this.options.readyTimeoutMs);
this.once(`management:${this.options.readyEventName}`, () => {
clearTimeout(readyTimeout);
this.isRunning = true;
this.logger.log('info', `Bridge connected to ${this.options.binaryName}`);
this.emit('ready');
resolve(true);
});
} catch (err: any) {
this.logger.log('error', `Failed to spawn: ${err.message}`);
resolve(false);
}
});
}
/**
* Send a typed command to the Rust process and wait for the response.
*/
public async sendCommand<K extends string & keyof TCommands>(
method: K,
params: TCommands[K]['params'],
): Promise<TCommands[K]['result']> {
if (!this.childProcess || !this.isRunning) {
throw new Error(`${this.options.binaryName} bridge is not running`);
}
const id = `req_${++this.requestCounter}`;
const request: IManagementRequest = { id, method, params };
return new Promise<TCommands[K]['result']>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Command '${method}' timed out after ${this.options.requestTimeoutMs}ms`));
}, this.options.requestTimeoutMs);
this.pendingRequests.set(id, { resolve, reject, timer });
const json = JSON.stringify(request) + '\n';
this.childProcess!.stdin!.write(json, (err) => {
if (err) {
clearTimeout(timer);
this.pendingRequests.delete(id);
reject(new Error(`Failed to write to stdin: ${err.message}`));
}
});
});
}
/**
* Kill the Rust process and clean up all resources.
*/
public kill(): void {
if (this.childProcess) {
const proc = this.childProcess;
this.childProcess = null;
this.isRunning = false;
// Close readline
if (this.readlineInterface) {
this.readlineInterface.close();
this.readlineInterface = null;
}
// Reject pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error(`${this.options.binaryName} process killed`));
}
this.pendingRequests.clear();
// Remove all listeners
proc.removeAllListeners();
proc.stdout?.removeAllListeners();
proc.stderr?.removeAllListeners();
proc.stdin?.removeAllListeners();
// Kill the process
try { proc.kill('SIGTERM'); } catch { /* already dead */ }
// Destroy stdio pipes
try { proc.stdin?.destroy(); } catch { /* ignore */ }
try { proc.stdout?.destroy(); } catch { /* ignore */ }
try { proc.stderr?.destroy(); } catch { /* ignore */ }
// Unref so Node doesn't wait
try { proc.unref(); } catch { /* ignore */ }
// Force kill after 5 seconds
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}, 5000).unref();
}
}
/**
* Whether the bridge is currently running.
*/
public get running(): boolean {
return this.isRunning;
}
private handleLine(line: string): void {
if (!line) return;
let parsed: any;
try {
parsed = JSON.parse(line);
} catch {
this.logger.log('warn', `Non-JSON output: ${line}`);
return;
}
// Check if it's an event (has 'event' field, no 'id')
if ('event' in parsed && !('id' in parsed)) {
const event = parsed as IManagementEvent;
this.emit(`management:${event.event}`, event.data);
return;
}
// Otherwise it's a response (has 'id' field)
if ('id' in parsed) {
const response = parsed as IManagementResponse;
const pending = this.pendingRequests.get(response.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response.result);
} else {
pending.reject(new Error(response.error || 'Unknown error from Rust process'));
}
}
}
}
private cleanup(): void {
this.isRunning = false;
this.childProcess = null;
if (this.readlineInterface) {
this.readlineInterface.close();
this.readlineInterface = null;
}
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error(`${this.options.binaryName} process exited`));
}
this.pendingRequests.clear();
}
}

View File

@@ -1,3 +1,3 @@
import * as plugins from './plugins.js';
export let demoExport = 'Hi there! :) This is an exported string';
export { RustBridge } from './classes.rustbridge.js';
export { RustBinaryLocator } from './classes.rustbinarylocator.js';
export * from './interfaces/index.js';

42
ts/interfaces/config.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Minimal logger interface for the bridge.
*/
export interface IRustBridgeLogger {
log(level: string, message: string, data?: Record<string, any>): void;
}
/**
* Options for locating a Rust binary.
*/
export interface IBinaryLocatorOptions {
/** Name of the binary (e.g., 'rustproxy') */
binaryName: string;
/** Environment variable to check for explicit binary path (e.g., 'SMARTPROXY_RUST_BINARY') */
envVarName?: string;
/** Prefix for platform-specific npm packages (e.g., '@push.rocks/smartproxy') */
platformPackagePrefix?: string;
/** Additional local paths to search (defaults to ./rust/target/release/<binaryName> and ./rust/target/debug/<binaryName>) */
localPaths?: string[];
/** Whether to search the system PATH (default: true) */
searchSystemPath?: boolean;
/** Explicit binary path override - skips all other search */
binaryPath?: string;
}
/**
* Options for the RustBridge.
*/
export interface IRustBridgeOptions extends IBinaryLocatorOptions {
/** CLI arguments passed to the binary (default: ['--management']) */
cliArgs?: string[];
/** Timeout for individual requests in ms (default: 30000) */
requestTimeoutMs?: number;
/** Timeout for the ready event during spawn in ms (default: 10000) */
readyTimeoutMs?: number;
/** Additional environment variables for the child process */
env?: Record<string, string>;
/** Name of the ready event emitted by the Rust binary (default: 'ready') */
readyEventName?: string;
/** Optional logger instance */
logger?: IRustBridgeLogger;
}

2
ts/interfaces/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './ipc.js';
export * from './config.js';

40
ts/interfaces/ipc.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Management request sent to the Rust binary via stdin.
*/
export interface IManagementRequest {
id: string;
method: string;
params: Record<string, any>;
}
/**
* Management response received from the Rust binary via stdout.
*/
export interface IManagementResponse {
id: string;
success: boolean;
result?: any;
error?: string;
}
/**
* Management event received from the Rust binary (unsolicited, no id field).
*/
export interface IManagementEvent {
event: string;
data: any;
}
/**
* Definition of a single command supported by a Rust binary.
*/
export interface ICommandDefinition<TParams = any, TResult = any> {
params: TParams;
result: TResult;
}
/**
* Map of command names to their definitions.
* Used to type-safe the bridge's sendCommand method.
*/
export type TCommandMap = Record<string, ICommandDefinition>;

View File

@@ -1,7 +1,11 @@
// native scope
import * as path from 'path';
import * as fs from 'fs';
import * as childProcess from 'child_process';
import * as readline from 'readline';
import * as events from 'events';
export { path };
export { path, fs, childProcess, readline, events };
// @push.rocks scope
import * as smartpath from '@push.rocks/smartpath';