@push.rocks/smartrust 1.7.1 → 1.9.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.
@@ -32,6 +32,12 @@ interface IPendingRequest {
32
32
  abortSignal?: AbortSignal;
33
33
  abortHandler?: () => void;
34
34
  streaming?: StreamingResponse<any, any>;
35
+ admissionReservation?: IRequestAdmissionReservation;
36
+ }
37
+
38
+ interface IRequestAdmissionReservation {
39
+ method: string;
40
+ released: boolean;
35
41
  }
36
42
 
37
43
  type TPendingRequestSettlement =
@@ -57,6 +63,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
57
63
  private logger: IRustBridgeLogger;
58
64
  private transport: TConfirmableRustTransport | null = null;
59
65
  private pendingRequests = new Map<string, IPendingRequest>();
66
+ private pendingRequestReservationsByMethod = new Map<string, number>();
60
67
  private requestCounter = 0;
61
68
  private isRunning = false;
62
69
  private binaryPath: string | null = null;
@@ -67,6 +74,24 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
67
74
 
68
75
  constructor(options: IRustBridgeOptions) {
69
76
  super();
77
+ if (options.inheritedFileDescriptors) {
78
+ for (const descriptor of options.inheritedFileDescriptors) {
79
+ if (!Number.isSafeInteger(descriptor) || descriptor < 0) {
80
+ throw new RangeError(
81
+ 'RustBridge inheritedFileDescriptors values must be non-negative safe integers',
82
+ );
83
+ }
84
+ }
85
+ }
86
+ if (options.maxPendingRequestsByMethod) {
87
+ for (const [method, limit] of Object.entries(options.maxPendingRequestsByMethod)) {
88
+ if (!method || !Number.isSafeInteger(limit) || limit <= 0) {
89
+ throw new RangeError(
90
+ 'RustBridge maxPendingRequestsByMethod values must be positive safe integers',
91
+ );
92
+ }
93
+ }
94
+ }
70
95
  this.logger = options.logger || defaultLogger;
71
96
  this.options = {
72
97
  cliArgs: ['--management'],
@@ -75,6 +100,12 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
75
100
  readyEventName: 'ready',
76
101
  maxPayloadSize: 50 * 1024 * 1024,
77
102
  ...options,
103
+ inheritedFileDescriptors: options.inheritedFileDescriptors
104
+ ? [...options.inheritedFileDescriptors]
105
+ : undefined,
106
+ maxPendingRequestsByMethod: options.maxPendingRequestsByMethod
107
+ ? { ...options.maxPendingRequestsByMethod }
108
+ : undefined,
78
109
  };
79
110
  this.locator = new RustBinaryLocator(options, this.logger);
80
111
  }
@@ -98,6 +129,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
98
129
  binaryPath,
99
130
  cliArgs: this.options.cliArgs,
100
131
  env: this.options.env,
132
+ inheritedFileDescriptors: this.options.inheritedFileDescriptors,
101
133
  maxPayloadSize: this.options.maxPayloadSize,
102
134
  logger: this.logger,
103
135
  });
@@ -112,6 +144,11 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
112
144
  * @param socketOptions - Optional socket connection options (reconnect, etc.)
113
145
  */
114
146
  public connect(socketPath: string, socketOptions?: ISocketConnectOptions): Promise<boolean> {
147
+ if (this.options.inheritedFileDescriptors?.length) {
148
+ return Promise.reject(
149
+ new Error('RustBridge inheritedFileDescriptors are available only with spawn()'),
150
+ );
151
+ }
115
152
  return this.activate(async () => new SocketTransport({
116
153
  socketPath,
117
154
  maxPayloadSize: this.options.maxPayloadSize,
@@ -396,95 +433,137 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
396
433
  );
397
434
  }
398
435
 
399
- if (!this.transport?.connected || !this.isRunning) {
436
+ const transport = this.transport;
437
+ if (!transport?.connected || !this.isRunning) {
400
438
  throw new Error(`${this.options.binaryName} bridge is not running`);
401
439
  }
402
440
 
403
- const transport = this.transport;
404
- const id = `req_${++this.requestCounter}`;
405
- const request: IManagementRequest = { id, method, params };
406
- const json = JSON.stringify(request);
407
-
408
- // Check outbound payload size
409
- const byteLength = Buffer.byteLength(json, 'utf8');
410
- if (byteLength > this.options.maxPayloadSize) {
411
- throw new Error(
412
- `Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`
413
- );
414
- }
441
+ const lifecycleGeneration = this.lifecycleGeneration;
442
+ const admissionReservation = this.reservePendingRequest(method);
443
+ let reservationTransferred = false;
415
444
 
416
- return new Promise<TCommands[K]['result']>((resolve, reject) => {
417
- const pending: IPendingRequest = {
418
- resolve,
419
- reject,
420
- timer: null,
445
+ try {
446
+ const id = `req_${++this.requestCounter}`;
447
+ const request: IManagementRequest = { id, method, params };
448
+ const json = JSON.stringify(request);
449
+
450
+ const byteLength = Buffer.byteLength(json, 'utf8');
451
+ if (byteLength > this.options.maxPayloadSize) {
452
+ throw new Error(
453
+ `Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`
454
+ );
455
+ }
456
+
457
+ const availabilityError = this.getRequestAvailabilityError(
421
458
  method,
422
- abortSignal: options?.signal,
423
- };
424
- this.pendingRequests.set(id, pending);
459
+ options?.signal,
460
+ transport,
461
+ lifecycleGeneration,
462
+ );
463
+ if (availabilityError) {
464
+ throw availabilityError;
465
+ }
425
466
 
426
- if (options?.signal) {
427
- pending.abortHandler = () => {
428
- this.settlePendingRequest(id, {
429
- outcome: 'reject',
430
- error: new RustBridgeRequestError(
431
- `Command '${method}' was aborted`,
432
- {
433
- code: 'ERR_RUST_BRIDGE_REQUEST_ABORTED',
434
- requestId: id,
435
- method,
436
- cause: options.signal?.reason,
437
- },
438
- ),
439
- });
467
+ return new Promise<TCommands[K]['result']>((resolve, reject) => {
468
+ const pending: IPendingRequest = {
469
+ resolve,
470
+ reject,
471
+ timer: null,
472
+ method,
473
+ abortSignal: options?.signal,
474
+ admissionReservation,
440
475
  };
441
- options.signal.addEventListener('abort', pending.abortHandler, { once: true });
442
- }
476
+ this.pendingRequests.set(id, pending);
477
+ reservationTransferred = true;
443
478
 
444
- pending.timer = setTimeout(() => {
445
- this.settlePendingRequest(id, {
446
- outcome: 'reject',
447
- error: new RustBridgeRequestError(
448
- `Command '${method}' timed out after ${timeoutMs}ms`,
449
- {
450
- code: 'ERR_RUST_BRIDGE_REQUEST_TIMEOUT',
451
- requestId: id,
452
- method,
453
- },
454
- ),
455
- });
456
- }, timeoutMs);
479
+ try {
480
+ if (options?.signal) {
481
+ pending.abortHandler = () => {
482
+ this.settlePendingRequest(id, {
483
+ outcome: 'reject',
484
+ error: new RustBridgeRequestError(
485
+ `Command '${method}' was aborted`,
486
+ {
487
+ code: 'ERR_RUST_BRIDGE_REQUEST_ABORTED',
488
+ requestId: id,
489
+ method,
490
+ cause: options.signal?.reason,
491
+ },
492
+ ),
493
+ });
494
+ };
495
+ options.signal.addEventListener('abort', pending.abortHandler, { once: true });
496
+ if (options.signal.aborted) {
497
+ pending.abortHandler();
498
+ return;
499
+ }
500
+ }
457
501
 
458
- try {
459
- void transport.write(json + '\n').catch((err: unknown) => {
502
+ pending.timer = setTimeout(() => {
503
+ this.settlePendingRequest(id, {
504
+ outcome: 'reject',
505
+ error: new RustBridgeRequestError(
506
+ `Command '${method}' timed out after ${timeoutMs}ms`,
507
+ {
508
+ code: 'ERR_RUST_BRIDGE_REQUEST_TIMEOUT',
509
+ requestId: id,
510
+ method,
511
+ },
512
+ ),
513
+ });
514
+ }, timeoutMs);
515
+
516
+ const currentAvailabilityError = this.getRequestAvailabilityError(
517
+ method,
518
+ options?.signal,
519
+ transport,
520
+ lifecycleGeneration,
521
+ id,
522
+ );
523
+ if (currentAvailabilityError) {
524
+ this.settlePendingRequest(id, {
525
+ outcome: 'reject',
526
+ error: currentAvailabilityError,
527
+ });
528
+ return;
529
+ }
530
+
531
+ void transport.write(json + '\n').catch((err: unknown) => {
532
+ this.settlePendingRequest(id, {
533
+ outcome: 'reject',
534
+ error: new RustBridgeRequestError(
535
+ `Failed to write to transport: ${this.getErrorMessage(err)}`,
536
+ {
537
+ code: 'ERR_RUST_BRIDGE_REQUEST_WRITE',
538
+ requestId: id,
539
+ method,
540
+ cause: err,
541
+ },
542
+ ),
543
+ });
544
+ });
545
+ } catch (err: unknown) {
460
546
  this.settlePendingRequest(id, {
461
547
  outcome: 'reject',
462
- error: new RustBridgeRequestError(
463
- `Failed to write to transport: ${this.getErrorMessage(err)}`,
464
- {
465
- code: 'ERR_RUST_BRIDGE_REQUEST_WRITE',
466
- requestId: id,
467
- method,
468
- cause: err,
469
- },
470
- ),
548
+ error: err instanceof RustBridgeRequestError
549
+ ? err
550
+ : new RustBridgeRequestError(
551
+ `Failed to write to transport: ${this.getErrorMessage(err)}`,
552
+ {
553
+ code: 'ERR_RUST_BRIDGE_REQUEST_WRITE',
554
+ requestId: id,
555
+ method,
556
+ cause: err,
557
+ },
558
+ ),
471
559
  });
472
- });
473
- } catch (err: unknown) {
474
- this.settlePendingRequest(id, {
475
- outcome: 'reject',
476
- error: new RustBridgeRequestError(
477
- `Failed to write to transport: ${this.getErrorMessage(err)}`,
478
- {
479
- code: 'ERR_RUST_BRIDGE_REQUEST_WRITE',
480
- requestId: id,
481
- method,
482
- cause: err,
483
- },
484
- ),
485
- });
560
+ }
561
+ });
562
+ } finally {
563
+ if (!reservationTransferred) {
564
+ this.releasePendingRequestReservation(admissionReservation);
486
565
  }
487
- });
566
+ }
488
567
  }
489
568
 
490
569
  /**
@@ -498,51 +577,120 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
498
577
  ): StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']> {
499
578
  const streaming = new StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']>();
500
579
 
501
- if (!this.transport?.connected || !this.isRunning) {
580
+ const transport = this.transport;
581
+ if (!transport?.connected || !this.isRunning) {
502
582
  streaming.fail(new Error(`${this.options.binaryName} bridge is not running`));
503
583
  return streaming;
504
584
  }
505
585
 
506
- const id = `req_${++this.requestCounter}`;
507
- const request: IManagementRequest = { id, method, params };
508
- const json = JSON.stringify(request);
586
+ const lifecycleGeneration = this.lifecycleGeneration;
587
+ let admissionReservation: IRequestAdmissionReservation | undefined;
588
+ let reservationTransferred = false;
589
+ let requestId: string | undefined;
509
590
 
510
- const byteLength = Buffer.byteLength(json, 'utf8');
511
- if (byteLength > this.options.maxPayloadSize) {
512
- streaming.fail(
513
- new Error(`Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`)
591
+ try {
592
+ admissionReservation = this.reservePendingRequest(method);
593
+ const id = `req_${++this.requestCounter}`;
594
+ requestId = id;
595
+ const request: IManagementRequest = { id, method, params };
596
+ const json = JSON.stringify(request);
597
+
598
+ const byteLength = Buffer.byteLength(json, 'utf8');
599
+ if (byteLength > this.options.maxPayloadSize) {
600
+ throw new Error(
601
+ `Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`,
602
+ );
603
+ }
604
+
605
+ const availabilityError = this.getRequestAvailabilityError(
606
+ method,
607
+ undefined,
608
+ transport,
609
+ lifecycleGeneration,
514
610
  );
515
- return streaming;
516
- }
611
+ if (availabilityError) {
612
+ throw availabilityError;
613
+ }
517
614
 
518
- const timeoutMs = this.options.streamTimeoutMs ?? this.options.requestTimeoutMs;
519
- const timer = setTimeout(() => {
520
- this.settlePendingRequest(id, {
521
- outcome: 'reject',
522
- error: new Error(`Streaming command '${method}' timed out after ${timeoutMs}ms`),
615
+ this.pendingRequests.set(id, {
616
+ resolve: (result: any) => streaming.finish(result),
617
+ reject: (error: Error) => streaming.fail(error),
618
+ timer: null,
619
+ method,
620
+ streaming,
621
+ admissionReservation,
523
622
  });
524
- }, timeoutMs);
525
-
526
- this.pendingRequests.set(id, {
527
- resolve: (result: any) => streaming.finish(result),
528
- reject: (error: Error) => streaming.fail(error),
529
- timer,
530
- method,
531
- streaming,
532
- });
623
+ reservationTransferred = true;
533
624
 
534
- try {
535
- void this.transport!.write(json + '\n').catch((err: unknown) => {
625
+ try {
626
+ const timeoutMs = this.options.streamTimeoutMs ?? this.options.requestTimeoutMs;
627
+ const pending = this.pendingRequests.get(id)!;
628
+ pending.timer = setTimeout(() => {
629
+ this.settlePendingRequest(id, {
630
+ outcome: 'reject',
631
+ error: new Error(`Streaming command '${method}' timed out after ${timeoutMs}ms`),
632
+ });
633
+ }, timeoutMs);
634
+
635
+ const currentAvailabilityError = this.getRequestAvailabilityError(
636
+ method,
637
+ undefined,
638
+ transport,
639
+ lifecycleGeneration,
640
+ id,
641
+ );
642
+ if (currentAvailabilityError) {
643
+ this.settlePendingRequest(id, {
644
+ outcome: 'reject',
645
+ error: currentAvailabilityError,
646
+ });
647
+ return streaming;
648
+ }
649
+
650
+ void transport.write(json + '\n').catch((err: unknown) => {
651
+ this.settlePendingRequest(id, {
652
+ outcome: 'reject',
653
+ error: new RustBridgeRequestError(
654
+ `Failed to write to transport: ${this.getErrorMessage(err)}`,
655
+ {
656
+ code: 'ERR_RUST_BRIDGE_REQUEST_WRITE',
657
+ requestId: id,
658
+ method,
659
+ cause: err,
660
+ },
661
+ ),
662
+ });
663
+ });
664
+ } catch (err: unknown) {
536
665
  this.settlePendingRequest(id, {
537
666
  outcome: 'reject',
538
- error: new Error(`Failed to write to transport: ${this.getErrorMessage(err)}`),
667
+ error: err instanceof RustBridgeRequestError
668
+ ? err
669
+ : new RustBridgeRequestError(
670
+ `Failed to write to transport: ${this.getErrorMessage(err)}`,
671
+ {
672
+ code: 'ERR_RUST_BRIDGE_REQUEST_WRITE',
673
+ requestId: id,
674
+ method,
675
+ cause: err,
676
+ },
677
+ ),
539
678
  });
540
- });
679
+ }
541
680
  } catch (err: unknown) {
542
- this.settlePendingRequest(id, {
543
- outcome: 'reject',
544
- error: new Error(`Failed to write to transport: ${this.getErrorMessage(err)}`),
545
- });
681
+ const error = err instanceof Error ? err : new Error(this.getErrorMessage(err));
682
+ if (reservationTransferred && requestId) {
683
+ this.settlePendingRequest(requestId, {
684
+ outcome: 'reject',
685
+ error,
686
+ });
687
+ } else {
688
+ streaming.fail(error);
689
+ }
690
+ } finally {
691
+ if (!reservationTransferred) {
692
+ this.releasePendingRequestReservation(admissionReservation);
693
+ }
546
694
  }
547
695
 
548
696
  return streaming;
@@ -746,6 +894,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
746
894
  }
747
895
 
748
896
  this.pendingRequests.delete(id);
897
+ this.releasePendingRequestReservation(pending.admissionReservation);
749
898
  if (pending.timer) {
750
899
  clearTimeout(pending.timer);
751
900
  pending.timer = null;
@@ -782,6 +931,86 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
782
931
  }
783
932
  }
784
933
 
934
+ private reservePendingRequest(method: string): IRequestAdmissionReservation | undefined {
935
+ const configuredLimits = this.options.maxPendingRequestsByMethod;
936
+ const limit = configuredLimits
937
+ && Object.prototype.hasOwnProperty.call(configuredLimits, method)
938
+ ? configuredLimits[method]
939
+ : undefined;
940
+ if (limit === undefined) {
941
+ return undefined;
942
+ }
943
+
944
+ const currentCount = this.pendingRequestReservationsByMethod.get(method) || 0;
945
+ if (currentCount >= limit) {
946
+ throw new RustBridgeRequestError(
947
+ `Command '${method}' reached its pending request limit of ${limit}`,
948
+ {
949
+ code: 'ERR_RUST_BRIDGE_REQUEST_LIMIT',
950
+ method,
951
+ },
952
+ );
953
+ }
954
+
955
+ this.pendingRequestReservationsByMethod.set(method, currentCount + 1);
956
+ return { method, released: false };
957
+ }
958
+
959
+ private releasePendingRequestReservation(
960
+ reservation: IRequestAdmissionReservation | undefined,
961
+ ): void {
962
+ if (!reservation || reservation.released) {
963
+ return;
964
+ }
965
+ reservation.released = true;
966
+
967
+ const currentCount = this.pendingRequestReservationsByMethod.get(reservation.method) || 0;
968
+ if (currentCount <= 1) {
969
+ this.pendingRequestReservationsByMethod.delete(reservation.method);
970
+ } else {
971
+ this.pendingRequestReservationsByMethod.set(reservation.method, currentCount - 1);
972
+ }
973
+ }
974
+
975
+ private getRequestAvailabilityError(
976
+ method: string,
977
+ signal: AbortSignal | undefined,
978
+ transport: TConfirmableRustTransport,
979
+ lifecycleGeneration: number,
980
+ requestId?: string,
981
+ ): RustBridgeRequestError | null {
982
+ if (signal?.aborted) {
983
+ return new RustBridgeRequestError(
984
+ `Command '${method}' was aborted before it was sent`,
985
+ {
986
+ code: 'ERR_RUST_BRIDGE_REQUEST_ABORTED',
987
+ requestId,
988
+ method,
989
+ cause: signal.reason,
990
+ },
991
+ );
992
+ }
993
+
994
+ if (
995
+ this.lifecycleState !== 'ready'
996
+ || !this.isRunning
997
+ || this.lifecycleGeneration !== lifecycleGeneration
998
+ || this.transport !== transport
999
+ || !transport.connected
1000
+ ) {
1001
+ return new RustBridgeRequestError(
1002
+ `Command '${method}' could not be sent because the bridge lifecycle changed`,
1003
+ {
1004
+ code: 'ERR_RUST_BRIDGE_REQUEST_TRANSPORT',
1005
+ requestId,
1006
+ method,
1007
+ },
1008
+ );
1009
+ }
1010
+
1011
+ return null;
1012
+ }
1013
+
785
1014
  private getErrorMessage(error: unknown): string {
786
1015
  return error instanceof Error ? error.message : String(error);
787
1016
  }
@@ -4,6 +4,7 @@
4
4
  export type TRustBridgeRequestErrorCode =
5
5
  | 'ERR_RUST_BRIDGE_REQUEST_TIMEOUT'
6
6
  | 'ERR_RUST_BRIDGE_REQUEST_ABORTED'
7
+ | 'ERR_RUST_BRIDGE_REQUEST_LIMIT'
7
8
  | 'ERR_RUST_BRIDGE_REQUEST_WRITE'
8
9
  | 'ERR_RUST_BRIDGE_REQUEST_TRANSPORT'
9
10
  | 'ERR_RUST_BRIDGE_REQUEST_RUST_RESPONSE';
@@ -20,8 +21,8 @@ export interface IRustBridgeRequestErrorOptions {
20
21
  * Error raised while preparing or waiting for a Rust bridge request.
21
22
  *
22
23
  * Existing error messages are preserved while `code` provides a stable way to
23
- * distinguish timeout, cancellation, transport, write, and Rust response
24
- * failures.
24
+ * distinguish admission, timeout, cancellation, transport, write, and Rust
25
+ * response failures.
25
26
  */
26
27
  export class RustBridgeRequestError extends Error {
27
28
  public readonly code: TRustBridgeRequestErrorCode;
@@ -13,6 +13,7 @@ export interface IStdioTransportOptions {
13
13
  binaryPath: string;
14
14
  cliArgs: string[];
15
15
  env?: Record<string, string>;
16
+ inheritedFileDescriptors?: readonly number[];
16
17
  maxPayloadSize: number;
17
18
  logger: IRustBridgeLogger;
18
19
  }
@@ -70,10 +71,16 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
70
71
 
71
72
  let childProcess: plugins.childProcess.ChildProcess;
72
73
  try {
74
+ const stdio: plugins.childProcess.StdioOptions = [
75
+ 'pipe',
76
+ 'pipe',
77
+ 'pipe',
78
+ ...(this.options.inheritedFileDescriptors ?? []),
79
+ ];
73
80
  childProcess = plugins.childProcess.spawn(
74
81
  this.options.binaryPath,
75
82
  this.options.cliArgs,
76
- { stdio: ['pipe', 'pipe', 'pipe'], env },
83
+ { stdio, env },
77
84
  );
78
85
  } catch (error: unknown) {
79
86
  return Promise.reject(this.toError(error, 'Failed to spawn child process'));
@@ -48,6 +48,13 @@ export interface IBinaryLocatorOptions {
48
48
  export interface IRustBridgeOptions extends IBinaryLocatorOptions {
49
49
  /** CLI arguments passed to the binary (default: ['--management']) */
50
50
  cliArgs?: string[];
51
+ /**
52
+ * Caller-owned descriptors inherited only by stdio children. The first
53
+ * source descriptor is duplicated to child fd 3, the next to fd 4, and so
54
+ * on. Sources must remain valid through each spawn call. Smartrust never
55
+ * closes the caller's source descriptors.
56
+ */
57
+ inheritedFileDescriptors?: readonly number[];
51
58
  /** Timeout for individual requests in ms (default: 30000) */
52
59
  requestTimeoutMs?: number;
53
60
  /** Timeout for the ready event during spawn in ms (default: 10000) */
@@ -63,6 +70,12 @@ export interface IRustBridgeOptions extends IBinaryLocatorOptions {
63
70
  /** Inactivity timeout for streaming commands in ms (default: same as requestTimeoutMs).
64
71
  * Resets on each chunk received. */
65
72
  streamTimeoutMs?: number;
73
+ /**
74
+ * Optional per-method limits for locally pending requests. A saturated
75
+ * method fails before serialization or transport writes, while other
76
+ * methods remain available for control and callback traffic.
77
+ */
78
+ maxPendingRequestsByMethod?: Record<string, number>;
66
79
  }
67
80
 
68
81
  /**