@push.rocks/smartrust 1.1.2 → 1.2.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 +20 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.rustbinarylocator.js +12 -2
- package/dist_ts/classes.rustbridge.d.ts +20 -2
- package/dist_ts/classes.rustbridge.js +139 -26
- package/dist_ts/classes.streamingresponse.d.ts +36 -0
- package/dist_ts/classes.streamingresponse.js +102 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces/config.d.ts +5 -0
- package/dist_ts/interfaces/ipc.d.ts +23 -0
- package/dist_ts/plugins.d.ts +1 -2
- package/dist_ts/plugins.js +2 -3
- package/package.json +1 -1
- package/readme.md +167 -33
- package/test/helpers/mock-rust-binary.mjs +63 -21
- package/test/test.rustbridge.node.ts +249 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.rustbinarylocator.ts +10 -1
- package/ts/classes.rustbridge.ts +166 -27
- package/ts/classes.streamingresponse.ts +110 -0
- package/ts/index.ts +1 -0
- package/ts/interfaces/config.ts +5 -0
- package/ts/interfaces/ipc.ts +22 -0
- package/ts/plugins.ts +1 -2
|
@@ -9,10 +9,14 @@ const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs');
|
|
|
9
9
|
// Define the command types for our mock binary
|
|
10
10
|
type TMockCommands = {
|
|
11
11
|
echo: { params: Record<string, any>; result: Record<string, any> };
|
|
12
|
+
largeEcho: { params: Record<string, any>; result: Record<string, any> };
|
|
12
13
|
error: { params: {}; result: never };
|
|
13
14
|
emitEvent: { params: { eventName: string; eventData: any }; result: null };
|
|
14
15
|
slow: { params: {}; result: { delayed: boolean } };
|
|
15
16
|
exit: { params: {}; result: null };
|
|
17
|
+
streamEcho: { params: { count: number }; chunk: { index: number; value: string }; result: { totalChunks: number } };
|
|
18
|
+
streamError: { params: {}; chunk: { index: number; value: string }; result: never };
|
|
19
|
+
streamEmpty: { params: {}; chunk: never; result: { totalChunks: number } };
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
tap.test('should spawn and receive ready event', async () => {
|
|
@@ -188,4 +192,249 @@ tap.test('should emit exit event when process exits', async () => {
|
|
|
188
192
|
expect(bridge.running).toBeFalse();
|
|
189
193
|
});
|
|
190
194
|
|
|
195
|
+
tap.test('should handle 1MB payload round-trip', async () => {
|
|
196
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
197
|
+
binaryName: 'node',
|
|
198
|
+
binaryPath: 'node',
|
|
199
|
+
cliArgs: [mockBinaryPath],
|
|
200
|
+
readyTimeoutMs: 5000,
|
|
201
|
+
requestTimeoutMs: 30000,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await bridge.spawn();
|
|
205
|
+
|
|
206
|
+
// Create a ~1MB payload
|
|
207
|
+
const largeString = 'x'.repeat(1024 * 1024);
|
|
208
|
+
const result = await bridge.sendCommand('largeEcho', { data: largeString });
|
|
209
|
+
expect(result.data).toEqual(largeString);
|
|
210
|
+
expect(result.data.length).toEqual(1024 * 1024);
|
|
211
|
+
|
|
212
|
+
bridge.kill();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
tap.test('should handle 10MB payload round-trip', async () => {
|
|
216
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
217
|
+
binaryName: 'node',
|
|
218
|
+
binaryPath: 'node',
|
|
219
|
+
cliArgs: [mockBinaryPath],
|
|
220
|
+
readyTimeoutMs: 5000,
|
|
221
|
+
requestTimeoutMs: 60000,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await bridge.spawn();
|
|
225
|
+
|
|
226
|
+
// Create a ~10MB payload
|
|
227
|
+
const largeString = 'y'.repeat(10 * 1024 * 1024);
|
|
228
|
+
const result = await bridge.sendCommand('largeEcho', { data: largeString });
|
|
229
|
+
expect(result.data).toEqual(largeString);
|
|
230
|
+
expect(result.data.length).toEqual(10 * 1024 * 1024);
|
|
231
|
+
|
|
232
|
+
bridge.kill();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
tap.test('should reject outbound messages exceeding maxPayloadSize', async () => {
|
|
236
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
237
|
+
binaryName: 'node',
|
|
238
|
+
binaryPath: 'node',
|
|
239
|
+
cliArgs: [mockBinaryPath],
|
|
240
|
+
readyTimeoutMs: 5000,
|
|
241
|
+
maxPayloadSize: 1000,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await bridge.spawn();
|
|
245
|
+
|
|
246
|
+
let threw = false;
|
|
247
|
+
try {
|
|
248
|
+
await bridge.sendCommand('largeEcho', { data: 'z'.repeat(2000) });
|
|
249
|
+
} catch (err: any) {
|
|
250
|
+
threw = true;
|
|
251
|
+
expect(err.message).toInclude('maxPayloadSize');
|
|
252
|
+
}
|
|
253
|
+
expect(threw).toBeTrue();
|
|
254
|
+
|
|
255
|
+
bridge.kill();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
tap.test('should handle multiple large concurrent commands', async () => {
|
|
259
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
260
|
+
binaryName: 'node',
|
|
261
|
+
binaryPath: 'node',
|
|
262
|
+
cliArgs: [mockBinaryPath],
|
|
263
|
+
readyTimeoutMs: 5000,
|
|
264
|
+
requestTimeoutMs: 30000,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await bridge.spawn();
|
|
268
|
+
|
|
269
|
+
const size = 500 * 1024; // 500KB each
|
|
270
|
+
const results = await Promise.all([
|
|
271
|
+
bridge.sendCommand('largeEcho', { data: 'a'.repeat(size), id: 1 }),
|
|
272
|
+
bridge.sendCommand('largeEcho', { data: 'b'.repeat(size), id: 2 }),
|
|
273
|
+
bridge.sendCommand('largeEcho', { data: 'c'.repeat(size), id: 3 }),
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
expect(results[0].data.length).toEqual(size);
|
|
277
|
+
expect(results[0].data[0]).toEqual('a');
|
|
278
|
+
expect(results[1].data.length).toEqual(size);
|
|
279
|
+
expect(results[1].data[0]).toEqual('b');
|
|
280
|
+
expect(results[2].data.length).toEqual(size);
|
|
281
|
+
expect(results[2].data[0]).toEqual('c');
|
|
282
|
+
|
|
283
|
+
bridge.kill();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// === Streaming tests ===
|
|
287
|
+
|
|
288
|
+
tap.test('streaming: should receive chunks via for-await-of and final result', async () => {
|
|
289
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
290
|
+
binaryName: 'node',
|
|
291
|
+
binaryPath: 'node',
|
|
292
|
+
cliArgs: [mockBinaryPath],
|
|
293
|
+
readyTimeoutMs: 5000,
|
|
294
|
+
requestTimeoutMs: 10000,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await bridge.spawn();
|
|
298
|
+
|
|
299
|
+
const stream = bridge.sendCommandStreaming('streamEcho', { count: 5 });
|
|
300
|
+
const chunks: Array<{ index: number; value: string }> = [];
|
|
301
|
+
|
|
302
|
+
for await (const chunk of stream) {
|
|
303
|
+
chunks.push(chunk);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
expect(chunks.length).toEqual(5);
|
|
307
|
+
for (let i = 0; i < 5; i++) {
|
|
308
|
+
expect(chunks[i].index).toEqual(i);
|
|
309
|
+
expect(chunks[i].value).toEqual(`chunk_${i}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const result = await stream.result;
|
|
313
|
+
expect(result.totalChunks).toEqual(5);
|
|
314
|
+
|
|
315
|
+
bridge.kill();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
tap.test('streaming: should handle zero chunks (immediate result)', async () => {
|
|
319
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
320
|
+
binaryName: 'node',
|
|
321
|
+
binaryPath: 'node',
|
|
322
|
+
cliArgs: [mockBinaryPath],
|
|
323
|
+
readyTimeoutMs: 5000,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await bridge.spawn();
|
|
327
|
+
|
|
328
|
+
const stream = bridge.sendCommandStreaming('streamEmpty', {});
|
|
329
|
+
const chunks: any[] = [];
|
|
330
|
+
|
|
331
|
+
for await (const chunk of stream) {
|
|
332
|
+
chunks.push(chunk);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
expect(chunks.length).toEqual(0);
|
|
336
|
+
|
|
337
|
+
const result = await stream.result;
|
|
338
|
+
expect(result.totalChunks).toEqual(0);
|
|
339
|
+
|
|
340
|
+
bridge.kill();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
tap.test('streaming: should propagate error to iterator and .result', async () => {
|
|
344
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
345
|
+
binaryName: 'node',
|
|
346
|
+
binaryPath: 'node',
|
|
347
|
+
cliArgs: [mockBinaryPath],
|
|
348
|
+
readyTimeoutMs: 5000,
|
|
349
|
+
requestTimeoutMs: 10000,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await bridge.spawn();
|
|
353
|
+
|
|
354
|
+
const stream = bridge.sendCommandStreaming('streamError', {});
|
|
355
|
+
const chunks: any[] = [];
|
|
356
|
+
let iteratorError: Error | null = null;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
for await (const chunk of stream) {
|
|
360
|
+
chunks.push(chunk);
|
|
361
|
+
}
|
|
362
|
+
} catch (err: any) {
|
|
363
|
+
iteratorError = err;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Should have received at least one chunk before error
|
|
367
|
+
expect(chunks.length).toEqual(1);
|
|
368
|
+
expect(chunks[0].value).toEqual('before_error');
|
|
369
|
+
|
|
370
|
+
// Iterator should have thrown
|
|
371
|
+
expect(iteratorError).toBeTruthy();
|
|
372
|
+
expect(iteratorError!.message).toInclude('Stream error after chunk');
|
|
373
|
+
|
|
374
|
+
// .result should also reject
|
|
375
|
+
let resultError: Error | null = null;
|
|
376
|
+
try {
|
|
377
|
+
await stream.result;
|
|
378
|
+
} catch (err: any) {
|
|
379
|
+
resultError = err;
|
|
380
|
+
}
|
|
381
|
+
expect(resultError).toBeTruthy();
|
|
382
|
+
expect(resultError!.message).toInclude('Stream error after chunk');
|
|
383
|
+
|
|
384
|
+
bridge.kill();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
tap.test('streaming: should fail when bridge is not running', async () => {
|
|
388
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
389
|
+
binaryName: 'node',
|
|
390
|
+
binaryPath: 'node',
|
|
391
|
+
cliArgs: [mockBinaryPath],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const stream = bridge.sendCommandStreaming('streamEcho', { count: 3 });
|
|
395
|
+
|
|
396
|
+
let resultError: Error | null = null;
|
|
397
|
+
try {
|
|
398
|
+
await stream.result;
|
|
399
|
+
} catch (err: any) {
|
|
400
|
+
resultError = err;
|
|
401
|
+
}
|
|
402
|
+
expect(resultError).toBeTruthy();
|
|
403
|
+
expect(resultError!.message).toInclude('not running');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
tap.test('streaming: should fail when killed mid-stream', async () => {
|
|
407
|
+
const bridge = new RustBridge<TMockCommands>({
|
|
408
|
+
binaryName: 'node',
|
|
409
|
+
binaryPath: 'node',
|
|
410
|
+
cliArgs: [mockBinaryPath],
|
|
411
|
+
readyTimeoutMs: 5000,
|
|
412
|
+
requestTimeoutMs: 30000,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await bridge.spawn();
|
|
416
|
+
|
|
417
|
+
// Request many chunks so we can kill mid-stream
|
|
418
|
+
const stream = bridge.sendCommandStreaming('streamEcho', { count: 100 });
|
|
419
|
+
const chunks: any[] = [];
|
|
420
|
+
let iteratorError: Error | null = null;
|
|
421
|
+
|
|
422
|
+
// Kill after a short delay
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
bridge.kill();
|
|
425
|
+
}, 50);
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
for await (const chunk of stream) {
|
|
429
|
+
chunks.push(chunk);
|
|
430
|
+
}
|
|
431
|
+
} catch (err: any) {
|
|
432
|
+
iteratorError = err;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Should have gotten some chunks but not all
|
|
436
|
+
expect(iteratorError).toBeTruthy();
|
|
437
|
+
expect(iteratorError!.message).toInclude('killed');
|
|
438
|
+
});
|
|
439
|
+
|
|
191
440
|
export default tap.start();
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -124,7 +124,16 @@ export class RustBinaryLocator {
|
|
|
124
124
|
await plugins.fs.promises.access(filePath, plugins.fs.constants.X_OK);
|
|
125
125
|
return true;
|
|
126
126
|
} catch {
|
|
127
|
-
|
|
127
|
+
// File may exist but lack execute bit (common after npm/pnpm install).
|
|
128
|
+
// Try to make it executable.
|
|
129
|
+
try {
|
|
130
|
+
await plugins.fs.promises.access(filePath, plugins.fs.constants.F_OK);
|
|
131
|
+
await plugins.fs.promises.chmod(filePath, 0o755);
|
|
132
|
+
this.logger.log('info', `Auto-fixed missing execute permission on: ${filePath}`);
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
139
|
|
package/ts/classes.rustbridge.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { RustBinaryLocator } from './classes.rustbinarylocator.js';
|
|
3
|
+
import { StreamingResponse } from './classes.streamingresponse.js';
|
|
3
4
|
import type {
|
|
4
5
|
IRustBridgeOptions,
|
|
5
6
|
IRustBridgeLogger,
|
|
@@ -7,6 +8,8 @@ import type {
|
|
|
7
8
|
IManagementRequest,
|
|
8
9
|
IManagementResponse,
|
|
9
10
|
IManagementEvent,
|
|
11
|
+
TStreamingCommandKeys,
|
|
12
|
+
TExtractChunk,
|
|
10
13
|
} from './interfaces/index.js';
|
|
11
14
|
|
|
12
15
|
const defaultLogger: IRustBridgeLogger = {
|
|
@@ -21,14 +24,16 @@ const defaultLogger: IRustBridgeLogger = {
|
|
|
21
24
|
*/
|
|
22
25
|
export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plugins.events.EventEmitter {
|
|
23
26
|
private locator: RustBinaryLocator;
|
|
24
|
-
private options: Required<Pick<IRustBridgeOptions, 'cliArgs' | 'requestTimeoutMs' | 'readyTimeoutMs' | 'readyEventName'>> & IRustBridgeOptions;
|
|
27
|
+
private options: Required<Pick<IRustBridgeOptions, 'cliArgs' | 'requestTimeoutMs' | 'readyTimeoutMs' | 'readyEventName' | 'maxPayloadSize'>> & IRustBridgeOptions;
|
|
25
28
|
private logger: IRustBridgeLogger;
|
|
26
29
|
private childProcess: plugins.childProcess.ChildProcess | null = null;
|
|
27
|
-
private
|
|
30
|
+
private stdoutBuffer: Buffer = Buffer.alloc(0);
|
|
31
|
+
private stderrRemainder: string = '';
|
|
28
32
|
private pendingRequests = new Map<string, {
|
|
29
33
|
resolve: (value: any) => void;
|
|
30
34
|
reject: (error: Error) => void;
|
|
31
35
|
timer: ReturnType<typeof setTimeout>;
|
|
36
|
+
streaming?: StreamingResponse<any, any>;
|
|
32
37
|
}>();
|
|
33
38
|
private requestCounter = 0;
|
|
34
39
|
private isRunning = false;
|
|
@@ -42,6 +47,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
42
47
|
requestTimeoutMs: 30000,
|
|
43
48
|
readyTimeoutMs: 10000,
|
|
44
49
|
readyEventName: 'ready',
|
|
50
|
+
maxPayloadSize: 50 * 1024 * 1024,
|
|
45
51
|
...options,
|
|
46
52
|
};
|
|
47
53
|
this.locator = new RustBinaryLocator(options, this.logger);
|
|
@@ -68,24 +74,34 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
68
74
|
env,
|
|
69
75
|
});
|
|
70
76
|
|
|
71
|
-
// Handle stderr
|
|
77
|
+
// Handle stderr with cross-chunk buffering
|
|
72
78
|
this.childProcess.stderr?.on('data', (data: Buffer) => {
|
|
73
|
-
|
|
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()!;
|
|
74
83
|
for (const line of lines) {
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (trimmed) {
|
|
86
|
+
this.logger.log('debug', `[${this.options.binaryName}] ${trimmed}`);
|
|
87
|
+
this.emit('stderr', trimmed);
|
|
88
|
+
}
|
|
77
89
|
}
|
|
78
90
|
});
|
|
79
91
|
|
|
80
|
-
// Handle stdout via
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
this.handleLine(line.trim());
|
|
92
|
+
// Handle stdout via Buffer-based newline scanner
|
|
93
|
+
this.childProcess.stdout!.on('data', (chunk: Buffer) => {
|
|
94
|
+
this.handleStdoutChunk(chunk);
|
|
84
95
|
});
|
|
85
96
|
|
|
86
97
|
// Handle process exit
|
|
87
98
|
this.childProcess.on('exit', (code, signal) => {
|
|
88
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
|
+
}
|
|
89
105
|
this.cleanup();
|
|
90
106
|
this.emit('exit', code, signal);
|
|
91
107
|
});
|
|
@@ -130,6 +146,15 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
130
146
|
|
|
131
147
|
const id = `req_${++this.requestCounter}`;
|
|
132
148
|
const request: IManagementRequest = { id, method, params };
|
|
149
|
+
const json = JSON.stringify(request);
|
|
150
|
+
|
|
151
|
+
// Check outbound payload size
|
|
152
|
+
const byteLength = Buffer.byteLength(json, 'utf8');
|
|
153
|
+
if (byteLength > this.options.maxPayloadSize) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
133
158
|
|
|
134
159
|
return new Promise<TCommands[K]['result']>((resolve, reject) => {
|
|
135
160
|
const timer = setTimeout(() => {
|
|
@@ -139,17 +164,64 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
139
164
|
|
|
140
165
|
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
141
166
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
this.pendingRequests.delete(id);
|
|
147
|
-
reject(new Error(`Failed to write to stdin: ${err.message}`));
|
|
148
|
-
}
|
|
167
|
+
this.writeToStdin(json + '\n').catch((err) => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
this.pendingRequests.delete(id);
|
|
170
|
+
reject(new Error(`Failed to write to stdin: ${err.message}`));
|
|
149
171
|
});
|
|
150
172
|
});
|
|
151
173
|
}
|
|
152
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Send a streaming command to the Rust process.
|
|
177
|
+
* Returns a StreamingResponse that yields chunks via `for await...of`
|
|
178
|
+
* and exposes `.result` for the final response.
|
|
179
|
+
*/
|
|
180
|
+
public sendCommandStreaming<K extends string & TStreamingCommandKeys<TCommands>>(
|
|
181
|
+
method: K,
|
|
182
|
+
params: TCommands[K]['params'],
|
|
183
|
+
): StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']> {
|
|
184
|
+
const streaming = new StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']>();
|
|
185
|
+
|
|
186
|
+
if (!this.childProcess || !this.isRunning) {
|
|
187
|
+
streaming.fail(new Error(`${this.options.binaryName} bridge is not running`));
|
|
188
|
+
return streaming;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const id = `req_${++this.requestCounter}`;
|
|
192
|
+
const request: IManagementRequest = { id, method, params };
|
|
193
|
+
const json = JSON.stringify(request);
|
|
194
|
+
|
|
195
|
+
const byteLength = Buffer.byteLength(json, 'utf8');
|
|
196
|
+
if (byteLength > this.options.maxPayloadSize) {
|
|
197
|
+
streaming.fail(
|
|
198
|
+
new Error(`Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`)
|
|
199
|
+
);
|
|
200
|
+
return streaming;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const timeoutMs = this.options.streamTimeoutMs ?? this.options.requestTimeoutMs;
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
this.pendingRequests.delete(id);
|
|
206
|
+
streaming.fail(new Error(`Streaming command '${method}' timed out after ${timeoutMs}ms`));
|
|
207
|
+
}, timeoutMs);
|
|
208
|
+
|
|
209
|
+
this.pendingRequests.set(id, {
|
|
210
|
+
resolve: (result: any) => streaming.finish(result),
|
|
211
|
+
reject: (error: Error) => streaming.fail(error),
|
|
212
|
+
timer,
|
|
213
|
+
streaming,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this.writeToStdin(json + '\n').catch((err) => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
this.pendingRequests.delete(id);
|
|
219
|
+
streaming.fail(new Error(`Failed to write to stdin: ${err.message}`));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return streaming;
|
|
223
|
+
}
|
|
224
|
+
|
|
153
225
|
/**
|
|
154
226
|
* Kill the Rust process and clean up all resources.
|
|
155
227
|
*/
|
|
@@ -159,11 +231,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
159
231
|
this.childProcess = null;
|
|
160
232
|
this.isRunning = false;
|
|
161
233
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
this.readlineInterface = null;
|
|
166
|
-
}
|
|
234
|
+
// Clear buffers
|
|
235
|
+
this.stdoutBuffer = Buffer.alloc(0);
|
|
236
|
+
this.stderrRemainder = '';
|
|
167
237
|
|
|
168
238
|
// Reject pending requests
|
|
169
239
|
for (const [, pending] of this.pendingRequests) {
|
|
@@ -203,6 +273,62 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
203
273
|
return this.isRunning;
|
|
204
274
|
}
|
|
205
275
|
|
|
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
|
+
|
|
206
332
|
private handleLine(line: string): void {
|
|
207
333
|
if (!line) return;
|
|
208
334
|
|
|
@@ -221,6 +347,22 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
221
347
|
return;
|
|
222
348
|
}
|
|
223
349
|
|
|
350
|
+
// Stream chunk (has 'id' + stream === true + 'data')
|
|
351
|
+
if ('id' in parsed && parsed.stream === true && 'data' in parsed) {
|
|
352
|
+
const pending = this.pendingRequests.get(parsed.id);
|
|
353
|
+
if (pending?.streaming) {
|
|
354
|
+
// Reset inactivity timeout
|
|
355
|
+
clearTimeout(pending.timer);
|
|
356
|
+
const timeoutMs = this.options.streamTimeoutMs ?? this.options.requestTimeoutMs;
|
|
357
|
+
pending.timer = setTimeout(() => {
|
|
358
|
+
this.pendingRequests.delete(parsed.id);
|
|
359
|
+
pending.reject(new Error(`Streaming command timed out after ${timeoutMs}ms of inactivity`));
|
|
360
|
+
}, timeoutMs);
|
|
361
|
+
pending.streaming.pushChunk(parsed.data);
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
224
366
|
// Otherwise it's a response (has 'id' field)
|
|
225
367
|
if ('id' in parsed) {
|
|
226
368
|
const response = parsed as IManagementResponse;
|
|
@@ -240,11 +382,8 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
|
|
|
240
382
|
private cleanup(): void {
|
|
241
383
|
this.isRunning = false;
|
|
242
384
|
this.childProcess = null;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
this.readlineInterface.close();
|
|
246
|
-
this.readlineInterface = null;
|
|
247
|
-
}
|
|
385
|
+
this.stdoutBuffer = Buffer.alloc(0);
|
|
386
|
+
this.stderrRemainder = '';
|
|
248
387
|
|
|
249
388
|
// Reject all pending requests
|
|
250
389
|
for (const [, pending] of this.pendingRequests) {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a streaming response from a Rust bridge command.
|
|
3
|
+
* Implements AsyncIterable to allow `for await...of` consumption of chunks,
|
|
4
|
+
* and exposes `.result` for the final response once the stream ends.
|
|
5
|
+
*
|
|
6
|
+
* @typeParam TChunk - Type of each streamed chunk
|
|
7
|
+
* @typeParam TResult - Type of the final result
|
|
8
|
+
*/
|
|
9
|
+
export class StreamingResponse<TChunk, TResult> implements AsyncIterable<TChunk> {
|
|
10
|
+
/** Resolves with the final result when the stream ends successfully. */
|
|
11
|
+
public readonly result: Promise<TResult>;
|
|
12
|
+
|
|
13
|
+
private resolveResult!: (value: TResult) => void;
|
|
14
|
+
private rejectResult!: (error: Error) => void;
|
|
15
|
+
|
|
16
|
+
/** Buffered chunks not yet consumed by the iterator. */
|
|
17
|
+
private buffer: TChunk[] = [];
|
|
18
|
+
/** Waiting consumer resolve callback (when iterator is ahead of producer). */
|
|
19
|
+
private waiting: ((value: IteratorResult<TChunk>) => void) | null = null;
|
|
20
|
+
/** Waiting consumer reject callback. */
|
|
21
|
+
private waitingReject: ((error: Error) => void) | null = null;
|
|
22
|
+
|
|
23
|
+
private done = false;
|
|
24
|
+
private error: Error | null = null;
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
this.result = new Promise<TResult>((resolve, reject) => {
|
|
28
|
+
this.resolveResult = resolve;
|
|
29
|
+
this.rejectResult = reject;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Push a chunk into the stream. Called internally by RustBridge.
|
|
35
|
+
*/
|
|
36
|
+
public pushChunk(chunk: TChunk): void {
|
|
37
|
+
if (this.done) return;
|
|
38
|
+
|
|
39
|
+
if (this.waiting) {
|
|
40
|
+
// A consumer is waiting — deliver immediately
|
|
41
|
+
const resolve = this.waiting;
|
|
42
|
+
this.waiting = null;
|
|
43
|
+
this.waitingReject = null;
|
|
44
|
+
resolve({ value: chunk, done: false });
|
|
45
|
+
} else {
|
|
46
|
+
// No consumer waiting — buffer the chunk
|
|
47
|
+
this.buffer.push(chunk);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* End the stream successfully with a final result. Called internally by RustBridge.
|
|
53
|
+
*/
|
|
54
|
+
public finish(result: TResult): void {
|
|
55
|
+
if (this.done) return;
|
|
56
|
+
this.done = true;
|
|
57
|
+
this.resolveResult(result);
|
|
58
|
+
|
|
59
|
+
// If a consumer is waiting, signal end of iteration
|
|
60
|
+
if (this.waiting) {
|
|
61
|
+
const resolve = this.waiting;
|
|
62
|
+
this.waiting = null;
|
|
63
|
+
this.waitingReject = null;
|
|
64
|
+
resolve({ value: undefined as any, done: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* End the stream with an error. Called internally by RustBridge.
|
|
70
|
+
*/
|
|
71
|
+
public fail(error: Error): void {
|
|
72
|
+
if (this.done) return;
|
|
73
|
+
this.done = true;
|
|
74
|
+
this.error = error;
|
|
75
|
+
this.rejectResult(error);
|
|
76
|
+
|
|
77
|
+
// If a consumer is waiting, reject it
|
|
78
|
+
if (this.waitingReject) {
|
|
79
|
+
const reject = this.waitingReject;
|
|
80
|
+
this.waiting = null;
|
|
81
|
+
this.waitingReject = null;
|
|
82
|
+
reject(error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[Symbol.asyncIterator](): AsyncIterator<TChunk> {
|
|
87
|
+
return {
|
|
88
|
+
next: (): Promise<IteratorResult<TChunk>> => {
|
|
89
|
+
// If there are buffered chunks, deliver one
|
|
90
|
+
if (this.buffer.length > 0) {
|
|
91
|
+
return Promise.resolve({ value: this.buffer.shift()!, done: false });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If the stream is done, signal end
|
|
95
|
+
if (this.done) {
|
|
96
|
+
if (this.error) {
|
|
97
|
+
return Promise.reject(this.error);
|
|
98
|
+
}
|
|
99
|
+
return Promise.resolve({ value: undefined as any, done: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// No buffered chunks and not done — wait for the next push
|
|
103
|
+
return new Promise<IteratorResult<TChunk>>((resolve, reject) => {
|
|
104
|
+
this.waiting = resolve;
|
|
105
|
+
this.waitingReject = reject;
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
package/ts/index.ts
CHANGED