@push.rocks/smartrust 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +22 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.linescanner.d.ts +18 -0
- package/dist_ts/classes.linescanner.js +43 -0
- package/dist_ts/classes.rustbridge.d.ts +19 -16
- package/dist_ts/classes.rustbridge.js +86 -152
- package/dist_ts/classes.sockettransport.d.ts +48 -0
- package/dist_ts/classes.sockettransport.js +138 -0
- package/dist_ts/classes.stdiotransport.d.ts +35 -0
- package/dist_ts/classes.stdiotransport.js +136 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/interfaces/config.d.ts +13 -0
- package/dist_ts/interfaces/index.d.ts +1 -0
- package/dist_ts/interfaces/index.js +2 -1
- package/dist_ts/interfaces/transport.d.ts +22 -0
- package/dist_ts/interfaces/transport.js +2 -0
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +1 -1
- package/readme.md +158 -16
- package/test/helpers/mock-socket-server.mjs +124 -0
- package/test/test.linescanner.node.ts +89 -0
- package/test/test.sockettransport.node.ts +312 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.linescanner.ts +51 -0
- package/ts/classes.rustbridge.ts +93 -147
- package/ts/classes.sockettransport.ts +187 -0
- package/ts/classes.stdiotransport.ts +149 -0
- package/ts/index.ts +3 -0
- package/ts/interfaces/config.ts +14 -0
- package/ts/interfaces/index.ts +1 -0
- package/ts/interfaces/transport.ts +26 -0
- package/ts/plugins.ts +2 -1
package/ts/classes.rustbridge.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { RustBinaryLocator } from './classes.rustbinarylocator.js';
|
|
3
3
|
import { StreamingResponse } from './classes.streamingresponse.js';
|
|
4
|
+
import { StdioTransport } from './classes.stdiotransport.js';
|
|
5
|
+
import { SocketTransport } from './classes.sockettransport.js';
|
|
4
6
|
import type {
|
|
5
7
|
IRustBridgeOptions,
|
|
6
8
|
IRustBridgeLogger,
|
|
9
|
+
ISocketConnectOptions,
|
|
7
10
|
TCommandMap,
|
|
8
11
|
IManagementRequest,
|
|
9
12
|
IManagementResponse,
|
|
10
13
|
IManagementEvent,
|
|
11
14
|
TStreamingCommandKeys,
|
|
12
15
|
TExtractChunk,
|
|
16
|
+
IRustTransport,
|
|
13
17
|
} from './interfaces/index.js';
|
|
14
18
|
|
|
15
19
|
const defaultLogger: IRustBridgeLogger = {
|
|
@@ -18,7 +22,8 @@ const defaultLogger: IRustBridgeLogger = {
|
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Generic bridge between TypeScript and a Rust binary.
|
|
21
|
-
* Communicates via JSON-over-stdin/stdout IPC protocol
|
|
25
|
+
* Communicates via JSON-over-stdin/stdout IPC protocol (stdio mode)
|
|
26
|
+
* or JSON-over-Unix-socket/named-pipe (socket mode).
|
|
22
27
|
*
|
|
23
28
|
* @typeParam TCommands - Map of command names to their param/result types
|
|
24
29
|
*/
|
|
@@ -26,9 +31,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
26
31
|
private locator: RustBinaryLocator;
|
|
27
32
|
private options: Required<Pick<IRustBridgeOptions, 'cliArgs' | 'requestTimeoutMs' | 'readyTimeoutMs' | 'readyEventName' | 'maxPayloadSize'>> & IRustBridgeOptions;
|
|
28
33
|
private logger: IRustBridgeLogger;
|
|
29
|
-
private
|
|
30
|
-
private stdoutBuffer: Buffer = Buffer.alloc(0);
|
|
31
|
-
private stderrRemainder: string = '';
|
|
34
|
+
private transport: IRustTransport | null = null;
|
|
32
35
|
private pendingRequests = new Map<string, {
|
|
33
36
|
resolve: (value: any) => void;
|
|
34
37
|
reject: (error: Error) => void;
|
|
@@ -63,71 +66,93 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
63
66
|
return false;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
const transport = new StdioTransport({
|
|
70
|
+
binaryPath: this.binaryPath,
|
|
71
|
+
cliArgs: this.options.cliArgs,
|
|
72
|
+
env: this.options.env,
|
|
73
|
+
maxPayloadSize: this.options.maxPayloadSize,
|
|
74
|
+
logger: this.logger,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return this.connectWithTransport(transport);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Connect to an already-running Rust daemon via Unix socket or named pipe.
|
|
82
|
+
* Returns true if the connection was established and the daemon signaled readiness.
|
|
83
|
+
*
|
|
84
|
+
* @param socketPath - Path to Unix socket or Windows named pipe
|
|
85
|
+
* @param socketOptions - Optional socket connection options (reconnect, etc.)
|
|
86
|
+
*/
|
|
87
|
+
public async connect(socketPath: string, socketOptions?: ISocketConnectOptions): Promise<boolean> {
|
|
88
|
+
const transport = new SocketTransport({
|
|
89
|
+
socketPath,
|
|
90
|
+
maxPayloadSize: this.options.maxPayloadSize,
|
|
91
|
+
logger: this.logger,
|
|
92
|
+
autoReconnect: socketOptions?.autoReconnect,
|
|
93
|
+
reconnectBaseDelayMs: socketOptions?.reconnectBaseDelayMs,
|
|
94
|
+
reconnectMaxDelayMs: socketOptions?.reconnectMaxDelayMs,
|
|
95
|
+
maxReconnectAttempts: socketOptions?.maxReconnectAttempts,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return this.connectWithTransport(transport);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Internal: wire up any transport and wait for the ready handshake.
|
|
103
|
+
*/
|
|
104
|
+
private connectWithTransport(transport: IRustTransport): Promise<boolean> {
|
|
66
105
|
return new Promise<boolean>((resolve) => {
|
|
67
106
|
try {
|
|
68
|
-
|
|
69
|
-
? { ...process.env, ...this.options.env }
|
|
70
|
-
: { ...process.env };
|
|
107
|
+
this.transport = transport;
|
|
71
108
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
env,
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// Handle stderr with cross-chunk buffering
|
|
78
|
-
this.childProcess.stderr?.on('data', (data: Buffer) => {
|
|
79
|
-
this.stderrRemainder += data.toString();
|
|
80
|
-
const lines = this.stderrRemainder.split('\n');
|
|
81
|
-
// Keep the last element (incomplete line) as remainder
|
|
82
|
-
this.stderrRemainder = lines.pop()!;
|
|
83
|
-
for (const line of lines) {
|
|
84
|
-
const trimmed = line.trim();
|
|
85
|
-
if (trimmed) {
|
|
86
|
-
this.logger.log('debug', `[${this.options.binaryName}] ${trimmed}`);
|
|
87
|
-
this.emit('stderr', trimmed);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
});
|
|
109
|
+
// Wire transport events
|
|
110
|
+
transport.on('line', (line: string) => this.handleLine(line));
|
|
91
111
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
this.
|
|
112
|
+
transport.on('stderr', (line: string) => {
|
|
113
|
+
this.logger.log('debug', `[${this.options.binaryName}] ${line}`);
|
|
114
|
+
this.emit('stderr', line);
|
|
95
115
|
});
|
|
96
116
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.logger.log('info', `Process exited (code=${code}, signal=${signal})`);
|
|
100
|
-
// Flush any remaining stderr
|
|
101
|
-
if (this.stderrRemainder.trim()) {
|
|
102
|
-
this.logger.log('debug', `[${this.options.binaryName}] ${this.stderrRemainder.trim()}`);
|
|
103
|
-
this.emit('stderr', this.stderrRemainder.trim());
|
|
104
|
-
}
|
|
117
|
+
transport.on('close', (...args: any[]) => {
|
|
118
|
+
this.logger.log('info', `Transport closed`);
|
|
105
119
|
this.cleanup();
|
|
106
|
-
this.emit('exit',
|
|
120
|
+
this.emit('exit', ...args);
|
|
107
121
|
});
|
|
108
122
|
|
|
109
|
-
|
|
110
|
-
this.logger.log('error', `
|
|
123
|
+
transport.on('error', (err: Error) => {
|
|
124
|
+
this.logger.log('error', `Transport error: ${err.message}`);
|
|
111
125
|
this.cleanup();
|
|
112
126
|
resolve(false);
|
|
113
127
|
});
|
|
114
128
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
|
|
129
|
+
transport.on('reconnected', () => {
|
|
130
|
+
this.logger.log('info', 'Transport reconnected, waiting for ready event');
|
|
131
|
+
this.emit('reconnected');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Connect the transport
|
|
135
|
+
transport.connect().then(() => {
|
|
136
|
+
// Wait for the ready event from the protocol layer
|
|
137
|
+
const readyTimeout = setTimeout(() => {
|
|
138
|
+
this.logger.log('error', `Process did not send ready event within ${this.options.readyTimeoutMs}ms`);
|
|
139
|
+
this.kill();
|
|
140
|
+
resolve(false);
|
|
141
|
+
}, this.options.readyTimeoutMs);
|
|
142
|
+
|
|
143
|
+
this.once(`management:${this.options.readyEventName}`, () => {
|
|
144
|
+
clearTimeout(readyTimeout);
|
|
145
|
+
this.isRunning = true;
|
|
146
|
+
this.logger.log('info', `Bridge connected to ${this.options.binaryName}`);
|
|
147
|
+
this.emit('ready');
|
|
148
|
+
resolve(true);
|
|
149
|
+
});
|
|
150
|
+
}).catch((err: Error) => {
|
|
151
|
+
this.logger.log('error', `Transport connect failed: ${err.message}`);
|
|
119
152
|
resolve(false);
|
|
120
|
-
}, this.options.readyTimeoutMs);
|
|
121
|
-
|
|
122
|
-
this.once(`management:${this.options.readyEventName}`, () => {
|
|
123
|
-
clearTimeout(readyTimeout);
|
|
124
|
-
this.isRunning = true;
|
|
125
|
-
this.logger.log('info', `Bridge connected to ${this.options.binaryName}`);
|
|
126
|
-
this.emit('ready');
|
|
127
|
-
resolve(true);
|
|
128
153
|
});
|
|
129
154
|
} catch (err: any) {
|
|
130
|
-
this.logger.log('error', `Failed to
|
|
155
|
+
this.logger.log('error', `Failed to connect: ${err.message}`);
|
|
131
156
|
resolve(false);
|
|
132
157
|
}
|
|
133
158
|
});
|
|
@@ -140,7 +165,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
140
165
|
method: K,
|
|
141
166
|
params: TCommands[K]['params'],
|
|
142
167
|
): Promise<TCommands[K]['result']> {
|
|
143
|
-
if (!this.
|
|
168
|
+
if (!this.transport?.connected || !this.isRunning) {
|
|
144
169
|
throw new Error(`${this.options.binaryName} bridge is not running`);
|
|
145
170
|
}
|
|
146
171
|
|
|
@@ -164,10 +189,10 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
164
189
|
|
|
165
190
|
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
166
191
|
|
|
167
|
-
this.
|
|
192
|
+
this.transport!.write(json + '\n').catch((err) => {
|
|
168
193
|
clearTimeout(timer);
|
|
169
194
|
this.pendingRequests.delete(id);
|
|
170
|
-
reject(new Error(`Failed to write to
|
|
195
|
+
reject(new Error(`Failed to write to transport: ${err.message}`));
|
|
171
196
|
});
|
|
172
197
|
});
|
|
173
198
|
}
|
|
@@ -183,7 +208,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
183
208
|
): StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']> {
|
|
184
209
|
const streaming = new StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']>();
|
|
185
210
|
|
|
186
|
-
if (!this.
|
|
211
|
+
if (!this.transport?.connected || !this.isRunning) {
|
|
187
212
|
streaming.fail(new Error(`${this.options.binaryName} bridge is not running`));
|
|
188
213
|
return streaming;
|
|
189
214
|
}
|
|
@@ -213,28 +238,26 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
213
238
|
streaming,
|
|
214
239
|
});
|
|
215
240
|
|
|
216
|
-
this.
|
|
241
|
+
this.transport!.write(json + '\n').catch((err) => {
|
|
217
242
|
clearTimeout(timer);
|
|
218
243
|
this.pendingRequests.delete(id);
|
|
219
|
-
streaming.fail(new Error(`Failed to write to
|
|
244
|
+
streaming.fail(new Error(`Failed to write to transport: ${err.message}`));
|
|
220
245
|
});
|
|
221
246
|
|
|
222
247
|
return streaming;
|
|
223
248
|
}
|
|
224
249
|
|
|
225
250
|
/**
|
|
226
|
-
* Kill the
|
|
251
|
+
* Kill the connection and clean up all resources.
|
|
252
|
+
* For stdio: kills the child process (SIGTERM, then SIGKILL).
|
|
253
|
+
* For socket: closes the socket connection (does NOT kill the daemon).
|
|
227
254
|
*/
|
|
228
255
|
public kill(): void {
|
|
229
|
-
if (this.
|
|
230
|
-
const
|
|
231
|
-
this.
|
|
256
|
+
if (this.transport) {
|
|
257
|
+
const transport = this.transport;
|
|
258
|
+
this.transport = null;
|
|
232
259
|
this.isRunning = false;
|
|
233
260
|
|
|
234
|
-
// Clear buffers
|
|
235
|
-
this.stdoutBuffer = Buffer.alloc(0);
|
|
236
|
-
this.stderrRemainder = '';
|
|
237
|
-
|
|
238
261
|
// Reject pending requests
|
|
239
262
|
for (const [, pending] of this.pendingRequests) {
|
|
240
263
|
clearTimeout(pending.timer);
|
|
@@ -242,27 +265,8 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
242
265
|
}
|
|
243
266
|
this.pendingRequests.clear();
|
|
244
267
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
proc.stdout?.removeAllListeners();
|
|
248
|
-
proc.stderr?.removeAllListeners();
|
|
249
|
-
proc.stdin?.removeAllListeners();
|
|
250
|
-
|
|
251
|
-
// Kill the process
|
|
252
|
-
try { proc.kill('SIGTERM'); } catch { /* already dead */ }
|
|
253
|
-
|
|
254
|
-
// Destroy stdio pipes
|
|
255
|
-
try { proc.stdin?.destroy(); } catch { /* ignore */ }
|
|
256
|
-
try { proc.stdout?.destroy(); } catch { /* ignore */ }
|
|
257
|
-
try { proc.stderr?.destroy(); } catch { /* ignore */ }
|
|
258
|
-
|
|
259
|
-
// Unref so Node doesn't wait
|
|
260
|
-
try { proc.unref(); } catch { /* ignore */ }
|
|
261
|
-
|
|
262
|
-
// Force kill after 5 seconds
|
|
263
|
-
setTimeout(() => {
|
|
264
|
-
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
265
|
-
}, 5000).unref();
|
|
268
|
+
transport.removeAllListeners();
|
|
269
|
+
transport.disconnect();
|
|
266
270
|
}
|
|
267
271
|
}
|
|
268
272
|
|
|
@@ -273,62 +277,6 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
273
277
|
return this.isRunning;
|
|
274
278
|
}
|
|
275
279
|
|
|
276
|
-
/**
|
|
277
|
-
* Buffer-based newline scanner for stdout chunks.
|
|
278
|
-
* Replaces readline to handle large payloads without buffering entire lines in a separate abstraction.
|
|
279
|
-
*/
|
|
280
|
-
private handleStdoutChunk(chunk: Buffer): void {
|
|
281
|
-
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
|
|
282
|
-
|
|
283
|
-
let newlineIndex: number;
|
|
284
|
-
while ((newlineIndex = this.stdoutBuffer.indexOf(0x0A)) !== -1) {
|
|
285
|
-
const lineBuffer = this.stdoutBuffer.subarray(0, newlineIndex);
|
|
286
|
-
this.stdoutBuffer = this.stdoutBuffer.subarray(newlineIndex + 1);
|
|
287
|
-
|
|
288
|
-
if (lineBuffer.length > this.options.maxPayloadSize) {
|
|
289
|
-
this.logger.log('error', `Inbound message exceeds maxPayloadSize (${lineBuffer.length} bytes), dropping`);
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const line = lineBuffer.toString('utf8').trim();
|
|
294
|
-
this.handleLine(line);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// If accumulated buffer exceeds maxPayloadSize (sender never sends newline), clear to prevent OOM
|
|
298
|
-
if (this.stdoutBuffer.length > this.options.maxPayloadSize) {
|
|
299
|
-
this.logger.log('error', `Stdout buffer exceeded maxPayloadSize (${this.stdoutBuffer.length} bytes) without newline, clearing`);
|
|
300
|
-
this.stdoutBuffer = Buffer.alloc(0);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Write data to stdin with backpressure support.
|
|
306
|
-
* Waits for drain if the internal buffer is full.
|
|
307
|
-
*/
|
|
308
|
-
private writeToStdin(data: string): Promise<void> {
|
|
309
|
-
return new Promise<void>((resolve, reject) => {
|
|
310
|
-
if (!this.childProcess?.stdin) {
|
|
311
|
-
reject(new Error('stdin not available'));
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const canContinue = this.childProcess.stdin.write(data, 'utf8', (err) => {
|
|
316
|
-
if (err) {
|
|
317
|
-
reject(err);
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
if (canContinue) {
|
|
322
|
-
resolve();
|
|
323
|
-
} else {
|
|
324
|
-
// Wait for drain before resolving
|
|
325
|
-
this.childProcess.stdin.once('drain', () => {
|
|
326
|
-
resolve();
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
280
|
private handleLine(line: string): void {
|
|
333
281
|
if (!line) return;
|
|
334
282
|
|
|
@@ -381,9 +329,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
381
329
|
|
|
382
330
|
private cleanup(): void {
|
|
383
331
|
this.isRunning = false;
|
|
384
|
-
this.
|
|
385
|
-
this.stdoutBuffer = Buffer.alloc(0);
|
|
386
|
-
this.stderrRemainder = '';
|
|
332
|
+
this.transport = null;
|
|
387
333
|
|
|
388
334
|
// Reject all pending requests
|
|
389
335
|
for (const [, pending] of this.pendingRequests) {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { LineScanner } from './classes.linescanner.js';
|
|
3
|
+
import type { IRustBridgeLogger } from './interfaces/index.js';
|
|
4
|
+
import type { IRustTransport } from './interfaces/transport.js';
|
|
5
|
+
|
|
6
|
+
export interface ISocketTransportOptions {
|
|
7
|
+
/** Path to Unix socket (Linux/macOS) or named pipe path (Windows) */
|
|
8
|
+
socketPath: string;
|
|
9
|
+
/** Maximum inbound message size in bytes */
|
|
10
|
+
maxPayloadSize: number;
|
|
11
|
+
/** Logger instance */
|
|
12
|
+
logger: IRustBridgeLogger;
|
|
13
|
+
/** Enable auto-reconnect on unexpected disconnect (default: false) */
|
|
14
|
+
autoReconnect?: boolean;
|
|
15
|
+
/** Initial delay between reconnect attempts in ms (default: 100) */
|
|
16
|
+
reconnectBaseDelayMs?: number;
|
|
17
|
+
/** Maximum delay between reconnect attempts in ms (default: 30000) */
|
|
18
|
+
reconnectMaxDelayMs?: number;
|
|
19
|
+
/** Maximum number of reconnect attempts before giving up (default: 10) */
|
|
20
|
+
maxReconnectAttempts?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface IResolvedSocketTransportOptions {
|
|
24
|
+
socketPath: string;
|
|
25
|
+
maxPayloadSize: number;
|
|
26
|
+
logger: IRustBridgeLogger;
|
|
27
|
+
autoReconnect: boolean;
|
|
28
|
+
reconnectBaseDelayMs: number;
|
|
29
|
+
reconnectMaxDelayMs: number;
|
|
30
|
+
maxReconnectAttempts: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Transport that connects to an already-running process via Unix socket or Windows named pipe.
|
|
35
|
+
* The JSON-over-newline protocol is identical to stdio; only the transport changes.
|
|
36
|
+
*/
|
|
37
|
+
export class SocketTransport extends plugins.events.EventEmitter implements IRustTransport {
|
|
38
|
+
private options: IResolvedSocketTransportOptions;
|
|
39
|
+
private socket: plugins.net.Socket | null = null;
|
|
40
|
+
private lineScanner: LineScanner;
|
|
41
|
+
private _connected: boolean = false;
|
|
42
|
+
private intentionalDisconnect: boolean = false;
|
|
43
|
+
private reconnectAttempts: number = 0;
|
|
44
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
+
|
|
46
|
+
constructor(options: ISocketTransportOptions) {
|
|
47
|
+
super();
|
|
48
|
+
this.options = {
|
|
49
|
+
autoReconnect: false,
|
|
50
|
+
reconnectBaseDelayMs: 100,
|
|
51
|
+
reconnectMaxDelayMs: 30000,
|
|
52
|
+
maxReconnectAttempts: 10,
|
|
53
|
+
...options,
|
|
54
|
+
};
|
|
55
|
+
this.lineScanner = new LineScanner(options.maxPayloadSize, options.logger);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public get connected(): boolean {
|
|
59
|
+
return this._connected;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Connect to the socket. Resolves when the TCP/Unix connection is established.
|
|
64
|
+
*/
|
|
65
|
+
public async connect(): Promise<void> {
|
|
66
|
+
this.intentionalDisconnect = false;
|
|
67
|
+
this.reconnectAttempts = 0;
|
|
68
|
+
return this.doConnect();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private doConnect(): Promise<void> {
|
|
72
|
+
return new Promise<void>((resolve, reject) => {
|
|
73
|
+
let settled = false;
|
|
74
|
+
|
|
75
|
+
this.socket = plugins.net.connect({ path: this.options.socketPath });
|
|
76
|
+
|
|
77
|
+
this.socket.on('connect', () => {
|
|
78
|
+
if (!settled) {
|
|
79
|
+
settled = true;
|
|
80
|
+
this._connected = true;
|
|
81
|
+
this.reconnectAttempts = 0;
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.socket.on('data', (chunk: Buffer) => {
|
|
87
|
+
this.lineScanner.push(chunk, (line) => {
|
|
88
|
+
this.emit('line', line);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.socket.on('close', () => {
|
|
93
|
+
const wasConnected = this._connected;
|
|
94
|
+
this._connected = false;
|
|
95
|
+
this.lineScanner.clear();
|
|
96
|
+
|
|
97
|
+
if (!this.intentionalDisconnect && wasConnected && this.options.autoReconnect) {
|
|
98
|
+
this.attemptReconnect();
|
|
99
|
+
}
|
|
100
|
+
this.emit('close');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.socket.on('error', (err: Error) => {
|
|
104
|
+
this._connected = false;
|
|
105
|
+
if (!settled) {
|
|
106
|
+
settled = true;
|
|
107
|
+
reject(err);
|
|
108
|
+
} else if (!this.intentionalDisconnect) {
|
|
109
|
+
this.emit('error', err);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private attemptReconnect(): void {
|
|
116
|
+
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
117
|
+
this.options.logger.log('error', `Max reconnect attempts (${this.options.maxReconnectAttempts}) reached`);
|
|
118
|
+
this.emit('reconnect_failed');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const delay = Math.min(
|
|
123
|
+
this.options.reconnectBaseDelayMs * Math.pow(2, this.reconnectAttempts),
|
|
124
|
+
this.options.reconnectMaxDelayMs,
|
|
125
|
+
);
|
|
126
|
+
this.reconnectAttempts++;
|
|
127
|
+
|
|
128
|
+
this.options.logger.log('info', `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.maxReconnectAttempts})`);
|
|
129
|
+
|
|
130
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
131
|
+
this.reconnectTimer = null;
|
|
132
|
+
try {
|
|
133
|
+
await this.doConnect();
|
|
134
|
+
this.emit('reconnected');
|
|
135
|
+
} catch {
|
|
136
|
+
// doConnect rejected — the 'close' handler on the new socket will trigger another attempt
|
|
137
|
+
}
|
|
138
|
+
}, delay);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Write data to the socket with backpressure support.
|
|
143
|
+
*/
|
|
144
|
+
public async write(data: string): Promise<void> {
|
|
145
|
+
return new Promise<void>((resolve, reject) => {
|
|
146
|
+
if (!this.socket || !this._connected) {
|
|
147
|
+
reject(new Error('Socket not connected'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const canContinue = this.socket.write(data, 'utf8', (err) => {
|
|
152
|
+
if (err) {
|
|
153
|
+
reject(err);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (canContinue) {
|
|
158
|
+
resolve();
|
|
159
|
+
} else {
|
|
160
|
+
this.socket.once('drain', () => {
|
|
161
|
+
resolve();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Close the socket connection. Does NOT kill the remote daemon.
|
|
169
|
+
*/
|
|
170
|
+
public disconnect(): void {
|
|
171
|
+
this.intentionalDisconnect = true;
|
|
172
|
+
|
|
173
|
+
if (this.reconnectTimer) {
|
|
174
|
+
clearTimeout(this.reconnectTimer);
|
|
175
|
+
this.reconnectTimer = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (this.socket) {
|
|
179
|
+
const sock = this.socket;
|
|
180
|
+
this.socket = null;
|
|
181
|
+
this._connected = false;
|
|
182
|
+
this.lineScanner.clear();
|
|
183
|
+
sock.removeAllListeners();
|
|
184
|
+
sock.destroy();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { LineScanner } from './classes.linescanner.js';
|
|
3
|
+
import type { IRustBridgeLogger } from './interfaces/index.js';
|
|
4
|
+
import type { IRustTransport } from './interfaces/transport.js';
|
|
5
|
+
|
|
6
|
+
export interface IStdioTransportOptions {
|
|
7
|
+
binaryPath: string;
|
|
8
|
+
cliArgs: string[];
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
maxPayloadSize: number;
|
|
11
|
+
logger: IRustBridgeLogger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Transport that spawns a child process and communicates via stdin/stdout.
|
|
16
|
+
* Extracted from the original RustBridge process management logic.
|
|
17
|
+
*/
|
|
18
|
+
export class StdioTransport extends plugins.events.EventEmitter implements IRustTransport {
|
|
19
|
+
private options: IStdioTransportOptions;
|
|
20
|
+
private childProcess: plugins.childProcess.ChildProcess | null = null;
|
|
21
|
+
private lineScanner: LineScanner;
|
|
22
|
+
private stderrRemainder: string = '';
|
|
23
|
+
private _connected: boolean = false;
|
|
24
|
+
|
|
25
|
+
constructor(options: IStdioTransportOptions) {
|
|
26
|
+
super();
|
|
27
|
+
this.options = options;
|
|
28
|
+
this.lineScanner = new LineScanner(options.maxPayloadSize, options.logger);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public get connected(): boolean {
|
|
32
|
+
return this._connected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spawn the child process. Resolves when the process is running (not necessarily ready).
|
|
37
|
+
*/
|
|
38
|
+
public async connect(): Promise<void> {
|
|
39
|
+
const env = this.options.env
|
|
40
|
+
? { ...process.env, ...this.options.env }
|
|
41
|
+
: { ...process.env };
|
|
42
|
+
|
|
43
|
+
this.childProcess = plugins.childProcess.spawn(
|
|
44
|
+
this.options.binaryPath,
|
|
45
|
+
this.options.cliArgs,
|
|
46
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], env },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Handle stderr with cross-chunk buffering
|
|
50
|
+
this.childProcess.stderr?.on('data', (data: Buffer) => {
|
|
51
|
+
this.stderrRemainder += data.toString();
|
|
52
|
+
const lines = this.stderrRemainder.split('\n');
|
|
53
|
+
this.stderrRemainder = lines.pop()!;
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (trimmed) {
|
|
57
|
+
this.emit('stderr', trimmed);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Handle stdout via LineScanner
|
|
63
|
+
this.childProcess.stdout!.on('data', (chunk: Buffer) => {
|
|
64
|
+
this.lineScanner.push(chunk, (line) => {
|
|
65
|
+
this.emit('line', line);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Handle process exit
|
|
70
|
+
this.childProcess.on('exit', (code: number | null, signal: string | null) => {
|
|
71
|
+
// Flush remaining stderr
|
|
72
|
+
if (this.stderrRemainder.trim()) {
|
|
73
|
+
this.emit('stderr', this.stderrRemainder.trim());
|
|
74
|
+
}
|
|
75
|
+
this._connected = false;
|
|
76
|
+
this.lineScanner.clear();
|
|
77
|
+
this.stderrRemainder = '';
|
|
78
|
+
this.emit('close', code, signal);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.childProcess.on('error', (err: Error) => {
|
|
82
|
+
this._connected = false;
|
|
83
|
+
this.emit('error', err);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this._connected = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write data to stdin with backpressure support.
|
|
91
|
+
*/
|
|
92
|
+
public async write(data: string): Promise<void> {
|
|
93
|
+
return new Promise<void>((resolve, reject) => {
|
|
94
|
+
if (!this.childProcess?.stdin) {
|
|
95
|
+
reject(new Error('stdin not available'));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const canContinue = this.childProcess.stdin.write(data, 'utf8', (err) => {
|
|
100
|
+
if (err) {
|
|
101
|
+
reject(err);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (canContinue) {
|
|
106
|
+
resolve();
|
|
107
|
+
} else {
|
|
108
|
+
this.childProcess.stdin.once('drain', () => {
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Kill the child process. Sends SIGTERM, then SIGKILL after 5s.
|
|
117
|
+
*/
|
|
118
|
+
public disconnect(): void {
|
|
119
|
+
if (!this.childProcess) return;
|
|
120
|
+
|
|
121
|
+
const proc = this.childProcess;
|
|
122
|
+
this.childProcess = null;
|
|
123
|
+
this._connected = false;
|
|
124
|
+
this.lineScanner.clear();
|
|
125
|
+
this.stderrRemainder = '';
|
|
126
|
+
|
|
127
|
+
// Remove all listeners
|
|
128
|
+
proc.removeAllListeners();
|
|
129
|
+
proc.stdout?.removeAllListeners();
|
|
130
|
+
proc.stderr?.removeAllListeners();
|
|
131
|
+
proc.stdin?.removeAllListeners();
|
|
132
|
+
|
|
133
|
+
// Kill the process
|
|
134
|
+
try { proc.kill('SIGTERM'); } catch { /* already dead */ }
|
|
135
|
+
|
|
136
|
+
// Destroy stdio pipes
|
|
137
|
+
try { proc.stdin?.destroy(); } catch { /* ignore */ }
|
|
138
|
+
try { proc.stdout?.destroy(); } catch { /* ignore */ }
|
|
139
|
+
try { proc.stderr?.destroy(); } catch { /* ignore */ }
|
|
140
|
+
|
|
141
|
+
// Unref so Node doesn't wait
|
|
142
|
+
try { proc.unref(); } catch { /* ignore */ }
|
|
143
|
+
|
|
144
|
+
// Force kill after 5 seconds
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
147
|
+
}, 5000).unref();
|
|
148
|
+
}
|
|
149
|
+
}
|
package/ts/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { RustBridge } from './classes.rustbridge.js';
|
|
2
2
|
export { RustBinaryLocator } from './classes.rustbinarylocator.js';
|
|
3
3
|
export { StreamingResponse } from './classes.streamingresponse.js';
|
|
4
|
+
export { StdioTransport } from './classes.stdiotransport.js';
|
|
5
|
+
export { SocketTransport } from './classes.sockettransport.js';
|
|
6
|
+
export { LineScanner } from './classes.linescanner.js';
|
|
4
7
|
export * from './interfaces/index.js';
|