From 40dec9194097d3dbfdffa550901d6400620bc3b1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 10 Feb 2026 09:10:18 +0000 Subject: [PATCH] feat(rustbridge): add RustBridge and RustBinaryLocator with typed IPC interfaces, plugins, tests and mock runner; export from index; add npm registries --- changelog.md | 10 ++ npmextra.json | 8 +- test/helpers/mock-rust-binary.mjs | 62 +++++++ test/test.rustbinarylocator.node.ts | 98 +++++++++++ test/test.rustbridge.node.ts | 191 +++++++++++++++++++++ test/test.ts | 14 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.rustbinarylocator.ts | 140 +++++++++++++++ ts/classes.rustbridge.ts | 256 ++++++++++++++++++++++++++++ ts/index.ts | 6 +- ts/interfaces/config.ts | 42 +++++ ts/interfaces/index.ts | 2 + ts/interfaces/ipc.ts | 40 +++++ ts/plugins.ts | 6 +- 14 files changed, 865 insertions(+), 12 deletions(-) create mode 100755 test/helpers/mock-rust-binary.mjs create mode 100644 test/test.rustbinarylocator.node.ts create mode 100644 test/test.rustbridge.node.ts create mode 100644 ts/classes.rustbinarylocator.ts create mode 100644 ts/classes.rustbridge.ts create mode 100644 ts/interfaces/config.ts create mode 100644 ts/interfaces/index.ts create mode 100644 ts/interfaces/ipc.ts diff --git a/changelog.md b/changelog.md index c16397e..71149b2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-10 - 1.1.0 - feat(rustbridge) +add RustBridge and RustBinaryLocator with typed IPC interfaces, plugins, tests and mock runner; export from index; add npm registries + +- Introduce RustBridge: spawn and manage a child binary, JSON-over-stdin/stdout request/response handling, events, timeouts, pending request tracking, kill/cleanup logic. +- Introduce RustBinaryLocator: multi-strategy binary discovery (explicit path, env var, platform-specific package, local build paths, system PATH) with caching and logger hooks. +- Add IPC and config TypeScript interfaces (IManagementRequest/Response/Event, ICommandDefinition, IBinaryLocatorOptions, IRustBridgeOptions) and re-export via interfaces/index.ts. +- Update ts/plugins.ts to export fs, child_process, readline and events for easier native integration. +- Add tests for RustBridge and RustBinaryLocator plus a test helper mock-rust-binary.mjs to simulate the IPC protocol and exercise commands, events, timeouts and locator behaviors. +- Update ts/index.ts to export RustBridge and RustBinaryLocator and export interfaces; update npmextra.json to include internal Verdaccio registry alongside npmjs.org. + ## 2026-02-08 - 1.0.2 - fix() no changes diff --git a/npmextra.json b/npmextra.json index 93c38e5..d6f99e8 100644 --- a/npmextra.json +++ b/npmextra.json @@ -11,10 +11,14 @@ "projectDomain": "push.rocks" }, "release": { - "accessLevel": "public" + "accessLevel": "public", + "registries": [ + "https://verdaccio.lossless.digital", + "https://registry.npmjs.org" + ] } }, "@ship.zone/szci": { "npmGlobalTools": [] } -} +} \ No newline at end of file diff --git a/test/helpers/mock-rust-binary.mjs b/test/helpers/mock-rust-binary.mjs new file mode 100755 index 0000000..caa67bf --- /dev/null +++ b/test/helpers/mock-rust-binary.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +/** + * Mock "Rust binary" for testing the RustBridge IPC protocol. + * Reads JSON lines from stdin, writes JSON lines to stdout. + * Emits a ready event on startup. + */ + +import { createInterface } from 'readline'; + +// Emit ready event +const readyEvent = JSON.stringify({ event: 'ready', data: { version: '1.0.0' } }); +process.stdout.write(readyEvent + '\n'); + +const rl = createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + let request; + try { + request = JSON.parse(line.trim()); + } catch { + return; + } + + const { id, method, params } = request; + + if (method === 'echo') { + // Echo back the params as result + const response = JSON.stringify({ id, success: true, result: params }); + process.stdout.write(response + '\n'); + } else if (method === 'error') { + // Return an error + const response = JSON.stringify({ id, success: false, error: 'Test error message' }); + process.stdout.write(response + '\n'); + } else if (method === 'emitEvent') { + // Emit a custom event, then respond with success + const event = JSON.stringify({ event: params.eventName, data: params.eventData }); + process.stdout.write(event + '\n'); + const response = JSON.stringify({ id, success: true, result: null }); + process.stdout.write(response + '\n'); + } else if (method === 'slow') { + // Respond after a delay + setTimeout(() => { + const response = JSON.stringify({ id, success: true, result: { delayed: true } }); + process.stdout.write(response + '\n'); + }, 100); + } else if (method === 'exit') { + // Graceful exit + const response = JSON.stringify({ id, success: true, result: null }); + process.stdout.write(response + '\n'); + process.exit(0); + } else { + // Unknown command + const response = JSON.stringify({ id, success: false, error: `Unknown method: ${method}` }); + process.stdout.write(response + '\n'); + } +}); + +// Handle SIGTERM gracefully +process.on('SIGTERM', () => { + process.exit(0); +}); diff --git a/test/test.rustbinarylocator.node.ts b/test/test.rustbinarylocator.node.ts new file mode 100644 index 0000000..c1ffb41 --- /dev/null +++ b/test/test.rustbinarylocator.node.ts @@ -0,0 +1,98 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as path from 'path'; +import * as fs from 'fs'; +import { RustBinaryLocator } from '../ts/classes.rustbinarylocator.js'; + +const testDir = path.resolve(path.dirname(new URL(import.meta.url).pathname)); + +tap.test('should return null when no binary is found', async () => { + const locator = new RustBinaryLocator({ + binaryName: 'nonexistent-binary-xyz', + searchSystemPath: false, + }); + const result = await locator.findBinary(); + expect(result).toBeNull(); +}); + +tap.test('should use explicit binaryPath when provided', async () => { + const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs'); + const locator = new RustBinaryLocator({ + binaryName: 'mock-rust-binary', + binaryPath: mockBinaryPath, + searchSystemPath: false, + }); + const result = await locator.findBinary(); + expect(result).toEqual(mockBinaryPath); +}); + +tap.test('should cache the result', async () => { + const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs'); + const locator = new RustBinaryLocator({ + binaryName: 'mock-rust-binary', + binaryPath: mockBinaryPath, + searchSystemPath: false, + }); + + const first = await locator.findBinary(); + const second = await locator.findBinary(); + expect(first).toEqual(second); + expect(first).toEqual(mockBinaryPath); +}); + +tap.test('should clear cache', async () => { + const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs'); + const locator = new RustBinaryLocator({ + binaryName: 'mock-rust-binary', + binaryPath: mockBinaryPath, + searchSystemPath: false, + }); + + const first = await locator.findBinary(); + expect(first).toEqual(mockBinaryPath); + + locator.clearCache(); + // After clearing, next call should re-search and still find it + const second = await locator.findBinary(); + expect(second).toEqual(mockBinaryPath); +}); + +tap.test('should fall back to env var when binaryPath not set', async () => { + const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs'); + const envVar = 'TEST_SMARTRUST_BINARY_' + Date.now(); + process.env[envVar] = mockBinaryPath; + + const locator = new RustBinaryLocator({ + binaryName: 'mock-rust-binary', + envVarName: envVar, + searchSystemPath: false, + }); + + const result = await locator.findBinary(); + expect(result).toEqual(mockBinaryPath); + + delete process.env[envVar]; +}); + +tap.test('should find binary in local paths', async () => { + const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs'); + const locator = new RustBinaryLocator({ + binaryName: 'mock-rust-binary', + localPaths: ['/nonexistent/path/binary', mockBinaryPath], + searchSystemPath: false, + }); + + const result = await locator.findBinary(); + expect(result).toEqual(mockBinaryPath); +}); + +tap.test('should find node in system PATH', async () => { + const locator = new RustBinaryLocator({ + binaryName: 'node', + searchSystemPath: true, + }); + + const result = await locator.findBinary(); + expect(result).not.toBeNull(); +}); + +export default tap.start(); diff --git a/test/test.rustbridge.node.ts b/test/test.rustbridge.node.ts new file mode 100644 index 0000000..c527a68 --- /dev/null +++ b/test/test.rustbridge.node.ts @@ -0,0 +1,191 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as path from 'path'; +import { RustBridge } from '../ts/classes.rustbridge.js'; +import type { ICommandDefinition } from '../ts/interfaces/index.js'; + +const testDir = path.resolve(path.dirname(new URL(import.meta.url).pathname)); +const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs'); + +// Define the command types for our mock binary +type TMockCommands = { + echo: { params: Record; result: Record }; + error: { params: {}; result: never }; + emitEvent: { params: { eventName: string; eventData: any }; result: null }; + slow: { params: {}; result: { delayed: boolean } }; + exit: { params: {}; result: null }; +}; + +tap.test('should spawn and receive ready event', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + }); + + const result = await bridge.spawn(); + expect(result).toBeTrue(); + expect(bridge.running).toBeTrue(); + + bridge.kill(); + expect(bridge.running).toBeFalse(); +}); + +tap.test('should send command and receive response', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + }); + + await bridge.spawn(); + + const result = await bridge.sendCommand('echo', { hello: 'world', num: 42 }); + expect(result).toEqual({ hello: 'world', num: 42 }); + + bridge.kill(); +}); + +tap.test('should handle error responses', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + }); + + await bridge.spawn(); + + let threw = false; + try { + await bridge.sendCommand('error', {}); + } catch (err: any) { + threw = true; + expect(err.message).toInclude('Test error message'); + } + expect(threw).toBeTrue(); + + bridge.kill(); +}); + +tap.test('should receive custom events from the binary', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + }); + + await bridge.spawn(); + + const eventPromise = new Promise((resolve) => { + bridge.once('management:testEvent', (data) => { + resolve(data); + }); + }); + + await bridge.sendCommand('emitEvent', { + eventName: 'testEvent', + eventData: { key: 'value' }, + }); + + const eventData = await eventPromise; + expect(eventData).toEqual({ key: 'value' }); + + bridge.kill(); +}); + +tap.test('should handle delayed responses', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + requestTimeoutMs: 5000, + }); + + await bridge.spawn(); + + const result = await bridge.sendCommand('slow', {}); + expect(result).toEqual({ delayed: true }); + + bridge.kill(); +}); + +tap.test('should handle multiple concurrent commands', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + }); + + await bridge.spawn(); + + const results = await Promise.all([ + bridge.sendCommand('echo', { id: 1 }), + bridge.sendCommand('echo', { id: 2 }), + bridge.sendCommand('echo', { id: 3 }), + ]); + + expect(results[0]).toEqual({ id: 1 }); + expect(results[1]).toEqual({ id: 2 }); + expect(results[2]).toEqual({ id: 3 }); + + bridge.kill(); +}); + +tap.test('should throw when sending command while not running', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + }); + + let threw = false; + try { + await bridge.sendCommand('echo', {}); + } catch (err: any) { + threw = true; + expect(err.message).toInclude('not running'); + } + expect(threw).toBeTrue(); +}); + +tap.test('should return false when binary not found', async () => { + const bridge = new RustBridge({ + binaryName: 'nonexistent-binary-xyz', + searchSystemPath: false, + }); + + const result = await bridge.spawn(); + expect(result).toBeFalse(); + expect(bridge.running).toBeFalse(); +}); + +tap.test('should emit exit event when process exits', async () => { + const bridge = new RustBridge({ + binaryName: 'node', + binaryPath: 'node', + cliArgs: [mockBinaryPath], + readyTimeoutMs: 5000, + }); + + await bridge.spawn(); + + const exitPromise = new Promise((resolve) => { + bridge.once('exit', (code) => { + resolve(code); + }); + }); + + // Tell mock binary to exit + await bridge.sendCommand('exit', {}); + + const exitCode = await exitPromise; + expect(exitCode).toEqual(0); + expect(bridge.running).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/test.ts b/test/test.ts index 9f9db82..95eb440 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,8 +1,12 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartrust from '../ts/index.js' +import * as smartrust from '../ts/index.js'; -tap.test('first test', async () => { - console.log(smartrust) -}) +tap.test('should export RustBridge', async () => { + expect(smartrust.RustBridge).toBeTypeOf('function'); +}); -export default tap.start() +tap.test('should export RustBinaryLocator', async () => { + expect(smartrust.RustBinaryLocator).toBeTypeOf('function'); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 81098e0..1023841 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts/classes.rustbinarylocator.ts b/ts/classes.rustbinarylocator.ts new file mode 100644 index 0000000..8229998 --- /dev/null +++ b/ts/classes.rustbinarylocator.ts @@ -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 { + 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 { + 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 { + 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 { + try { + await plugins.fs.promises.access(filePath, plugins.fs.constants.X_OK); + return true; + } catch { + return false; + } + } + + private async findInPath(binaryName: string): Promise { + 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; + } +} diff --git a/ts/classes.rustbridge.ts b/ts/classes.rustbridge.ts new file mode 100644 index 0000000..5b79c65 --- /dev/null +++ b/ts/classes.rustbridge.ts @@ -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 extends plugins.events.EventEmitter { + private locator: RustBinaryLocator; + private options: Required> & IRustBridgeOptions; + private logger: IRustBridgeLogger; + private childProcess: plugins.childProcess.ChildProcess | null = null; + private readlineInterface: plugins.readline.Interface | null = null; + private pendingRequests = new Map void; + reject: (error: Error) => void; + timer: ReturnType; + }>(); + 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 { + this.binaryPath = await this.locator.findBinary(); + if (!this.binaryPath) { + return false; + } + + return new Promise((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( + method: K, + params: TCommands[K]['params'], + ): Promise { + 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((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(); + } +} diff --git a/ts/index.ts b/ts/index.ts index 8f1f224..d661bad 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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'; diff --git a/ts/interfaces/config.ts b/ts/interfaces/config.ts new file mode 100644 index 0000000..4d13714 --- /dev/null +++ b/ts/interfaces/config.ts @@ -0,0 +1,42 @@ +/** + * Minimal logger interface for the bridge. + */ +export interface IRustBridgeLogger { + log(level: string, message: string, data?: Record): 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/ and ./rust/target/debug/) */ + 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; + /** Name of the ready event emitted by the Rust binary (default: 'ready') */ + readyEventName?: string; + /** Optional logger instance */ + logger?: IRustBridgeLogger; +} diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts new file mode 100644 index 0000000..992e6c8 --- /dev/null +++ b/ts/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './ipc.js'; +export * from './config.js'; diff --git a/ts/interfaces/ipc.ts b/ts/interfaces/ipc.ts new file mode 100644 index 0000000..a36ad11 --- /dev/null +++ b/ts/interfaces/ipc.ts @@ -0,0 +1,40 @@ +/** + * Management request sent to the Rust binary via stdin. + */ +export interface IManagementRequest { + id: string; + method: string; + params: Record; +} + +/** + * 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 { + params: TParams; + result: TResult; +} + +/** + * Map of command names to their definitions. + * Used to type-safe the bridge's sendCommand method. + */ +export type TCommandMap = Record; diff --git a/ts/plugins.ts b/ts/plugins.ts index a7c8cf2..b0c7538 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -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';