@push.rocks/smartrust 1.1.2 → 1.2.0

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.
@@ -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();
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartrust',
6
- version: '1.1.2',
6
+ version: '1.2.0',
7
7
  description: 'a bridge between JS engines and rust'
8
8
  }
@@ -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 readlineInterface: plugins.readline.Interface | null = null;
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
- const lines = data.toString().split('\n').filter((l: string) => l.trim());
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
- this.logger.log('debug', `[${this.options.binaryName}] ${line}`);
76
- this.emit('stderr', line);
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 readline for line-delimited JSON
81
- this.readlineInterface = plugins.readline.createInterface({ input: this.childProcess.stdout! });
82
- this.readlineInterface.on('line', (line: string) => {
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
- const json = JSON.stringify(request) + '\n';
143
- this.childProcess!.stdin!.write(json, (err) => {
144
- if (err) {
145
- clearTimeout(timer);
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
- // Close readline
163
- if (this.readlineInterface) {
164
- this.readlineInterface.close();
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
- if (this.readlineInterface) {
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
@@ -1,3 +1,4 @@
1
1
  export { RustBridge } from './classes.rustbridge.js';
2
2
  export { RustBinaryLocator } from './classes.rustbinarylocator.js';
3
+ export { StreamingResponse } from './classes.streamingresponse.js';
3
4
  export * from './interfaces/index.js';
@@ -39,4 +39,9 @@ export interface IRustBridgeOptions extends IBinaryLocatorOptions {
39
39
  readyEventName?: string;
40
40
  /** Optional logger instance */
41
41
  logger?: IRustBridgeLogger;
42
+ /** Maximum message size in bytes (default: 50MB). Messages exceeding this are rejected. */
43
+ maxPayloadSize?: number;
44
+ /** Inactivity timeout for streaming commands in ms (default: same as requestTimeoutMs).
45
+ * Resets on each chunk received. */
46
+ streamTimeoutMs?: number;
42
47
  }