@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.
@@ -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 childProcess: plugins.childProcess.ChildProcess | null = null;
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
- const env = this.options.env
69
- ? { ...process.env, ...this.options.env }
70
- : { ...process.env };
107
+ this.transport = transport;
71
108
 
72
- this.childProcess = plugins.childProcess.spawn(this.binaryPath!, this.options.cliArgs, {
73
- stdio: ['pipe', 'pipe', 'pipe'],
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
- // Handle stdout via Buffer-based newline scanner
93
- this.childProcess.stdout!.on('data', (chunk: Buffer) => {
94
- this.handleStdoutChunk(chunk);
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
- // Handle process exit
98
- this.childProcess.on('exit', (code, signal) => {
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', code, signal);
120
+ this.emit('exit', ...args);
107
121
  });
108
122
 
109
- this.childProcess.on('error', (err) => {
110
- this.logger.log('error', `Process error: ${err.message}`);
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
- // Wait for the ready event
116
- const readyTimeout = setTimeout(() => {
117
- this.logger.log('error', `Process did not send ready event within ${this.options.readyTimeoutMs}ms`);
118
- this.kill();
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 spawn: ${err.message}`);
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.childProcess || !this.isRunning) {
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.writeToStdin(json + '\n').catch((err) => {
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 stdin: ${err.message}`));
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.childProcess || !this.isRunning) {
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.writeToStdin(json + '\n').catch((err) => {
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 stdin: ${err.message}`));
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 Rust process and clean up all resources.
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.childProcess) {
230
- const proc = this.childProcess;
231
- this.childProcess = null;
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
- // Remove all listeners
246
- proc.removeAllListeners();
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.childProcess = null;
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';