Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65f8e98eb3 | |||
| 40dec91940 |
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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()
|
## 2026-02-08 - 1.0.2 - fix()
|
||||||
no changes
|
no changes
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
"projectDomain": "push.rocks"
|
"projectDomain": "push.rocks"
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"accessLevel": "public"
|
"accessLevel": "public",
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@ship.zone/szci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": []
|
"npmGlobalTools": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartrust",
|
"name": "@push.rocks/smartrust",
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a bridge between JS engines and rust",
|
"description": "a bridge between JS engines and rust",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
62
test/helpers/mock-rust-binary.mjs
Executable file
62
test/helpers/mock-rust-binary.mjs
Executable file
@@ -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);
|
||||||
|
});
|
||||||
98
test/test.rustbinarylocator.node.ts
Normal file
98
test/test.rustbinarylocator.node.ts
Normal file
@@ -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();
|
||||||
191
test/test.rustbridge.node.ts
Normal file
191
test/test.rustbridge.node.ts
Normal file
@@ -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<string, any>; result: Record<string, any> };
|
||||||
|
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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
binaryName: 'node',
|
||||||
|
binaryPath: 'node',
|
||||||
|
cliArgs: [mockBinaryPath],
|
||||||
|
readyTimeoutMs: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.spawn();
|
||||||
|
|
||||||
|
const eventPromise = new Promise<any>((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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
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<TMockCommands>({
|
||||||
|
binaryName: 'node',
|
||||||
|
binaryPath: 'node',
|
||||||
|
cliArgs: [mockBinaryPath],
|
||||||
|
readyTimeoutMs: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.spawn();
|
||||||
|
|
||||||
|
const exitPromise = new Promise<number | null>((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();
|
||||||
14
test/test.ts
14
test/test.ts
@@ -1,8 +1,12 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
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 () => {
|
tap.test('should export RustBridge', async () => {
|
||||||
console.log(smartrust)
|
expect(smartrust.RustBridge).toBeTypeOf('function');
|
||||||
})
|
});
|
||||||
|
|
||||||
export default tap.start()
|
tap.test('should export RustBinaryLocator', async () => {
|
||||||
|
expect(smartrust.RustBinaryLocator).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartrust',
|
name: '@push.rocks/smartrust',
|
||||||
version: '1.0.2',
|
version: '1.1.0',
|
||||||
description: 'a bridge between JS engines and rust'
|
description: 'a bridge between JS engines and rust'
|
||||||
}
|
}
|
||||||
|
|||||||
140
ts/classes.rustbinarylocator.ts
Normal file
140
ts/classes.rustbinarylocator.ts
Normal 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
256
ts/classes.rustbridge.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import * as plugins from './plugins.js';
|
export { RustBridge } from './classes.rustbridge.js';
|
||||||
|
export { RustBinaryLocator } from './classes.rustbinarylocator.js';
|
||||||
export let demoExport = 'Hi there! :) This is an exported string';
|
export * from './interfaces/index.js';
|
||||||
|
|||||||
42
ts/interfaces/config.ts
Normal file
42
ts/interfaces/config.ts
Normal 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
2
ts/interfaces/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ipc.js';
|
||||||
|
export * from './config.js';
|
||||||
40
ts/interfaces/ipc.ts
Normal file
40
ts/interfaces/ipc.ts
Normal 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>;
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
// native scope
|
// native scope
|
||||||
import * as path from 'path';
|
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
|
// @push.rocks scope
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
|||||||
Reference in New Issue
Block a user