@push.rocks/smartrust 2.4.0 → 2.5.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.
@@ -12,6 +12,12 @@ export class RustBridgeRequestError extends Error {
12
12
  responseErrorData;
13
13
  requestId;
14
14
  method;
15
+ /**
16
+ * Present when a timeout or abort made the bridge send a cancel frame for a
17
+ * request already written to Rust. Settles once Rust answers the original
18
+ * request or the transport closes. It never rejects.
19
+ */
20
+ remoteSettlement;
15
21
  constructor(message, options) {
16
22
  super(message, options.cause === undefined ? undefined : { cause: options.cause });
17
23
  this.name = 'RustBridgeRequestError';
@@ -20,6 +26,7 @@ export class RustBridgeRequestError extends Error {
20
26
  this.responseErrorData = options.responseErrorData;
21
27
  this.requestId = options.requestId;
22
28
  this.method = options.method;
29
+ this.remoteSettlement = options.remoteSettlement;
23
30
  }
24
31
  }
25
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5ydXN0YnJpZGdlcmVxdWVzdGVycm9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvY2xhc3Nlcy5ydXN0YnJpZGdlcmVxdWVzdGVycm9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQXNCQTs7Ozs7O0dBTUc7QUFDSCxNQUFNLE9BQU8sc0JBQXVCLFNBQVEsS0FBSztJQUMvQixJQUFJLENBQThCO0lBQ2xDLGlCQUFpQixDQUFVO0lBQzNDLDhFQUE4RTtJQUM5RCxpQkFBaUIsQ0FBVztJQUM1QixTQUFTLENBQVU7SUFDbkIsTUFBTSxDQUFVO0lBRWhDLFlBQVksT0FBZSxFQUFFLE9BQXVDO1FBQ2xFLEtBQUssQ0FBQyxPQUFPLEVBQUUsT0FBTyxDQUFDLEtBQUssS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsRUFBRSxLQUFLLEVBQUUsT0FBTyxDQUFDLEtBQUssRUFBRSxDQUFDLENBQUM7UUFDbkYsSUFBSSxDQUFDLElBQUksR0FBRyx3QkFBd0IsQ0FBQztRQUNyQyxJQUFJLENBQUMsSUFBSSxHQUFHLE9BQU8sQ0FBQyxJQUFJLENBQUM7UUFDekIsSUFBSSxDQUFDLGlCQUFpQixHQUFHLE9BQU8sQ0FBQyxpQkFBaUIsQ0FBQztRQUNuRCxJQUFJLENBQUMsaUJBQWlCLEdBQUcsT0FBTyxDQUFDLGlCQUFpQixDQUFDO1FBQ25ELElBQUksQ0FBQyxTQUFTLEdBQUcsT0FBTyxDQUFDLFNBQVMsQ0FBQztRQUNuQyxJQUFJLENBQUMsTUFBTSxHQUFHLE9BQU8sQ0FBQyxNQUFNLENBQUM7SUFDL0IsQ0FBQztDQUNGIn0=
32
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5ydXN0YnJpZGdlcmVxdWVzdGVycm9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvY2xhc3Nlcy5ydXN0YnJpZGdlcmVxdWVzdGVycm9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQWtEQTs7Ozs7O0dBTUc7QUFDSCxNQUFNLE9BQU8sc0JBQXVCLFNBQVEsS0FBSztJQUMvQixJQUFJLENBQThCO0lBQ2xDLGlCQUFpQixDQUFVO0lBQzNDLDhFQUE4RTtJQUM5RCxpQkFBaUIsQ0FBVztJQUM1QixTQUFTLENBQVU7SUFDbkIsTUFBTSxDQUFVO0lBQ2hDOzs7O09BSUc7SUFDYSxnQkFBZ0IsQ0FBd0M7SUFFeEUsWUFBWSxPQUFlLEVBQUUsT0FBdUM7UUFDbEUsS0FBSyxDQUFDLE9BQU8sRUFBRSxPQUFPLENBQUMsS0FBSyxLQUFLLFNBQVMsQ0FBQyxDQUFDLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFFLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQztRQUNuRixJQUFJLENBQUMsSUFBSSxHQUFHLHdCQUF3QixDQUFDO1FBQ3JDLElBQUksQ0FBQyxJQUFJLEdBQUcsT0FBTyxDQUFDLElBQUksQ0FBQztRQUN6QixJQUFJLENBQUMsaUJBQWlCLEdBQUcsT0FBTyxDQUFDLGlCQUFpQixDQUFDO1FBQ25ELElBQUksQ0FBQyxpQkFBaUIsR0FBRyxPQUFPLENBQUMsaUJBQWlCLENBQUM7UUFDbkQsSUFBSSxDQUFDLFNBQVMsR0FBRyxPQUFPLENBQUMsU0FBUyxDQUFDO1FBQ25DLElBQUksQ0FBQyxNQUFNLEdBQUcsT0FBTyxDQUFDLE1BQU0sQ0FBQztRQUM3QixJQUFJLENBQUMsZ0JBQWdCLEdBQUcsT0FBTyxDQUFDLGdCQUFnQixDQUFDO0lBQ25ELENBQUM7Q0FDRiJ9
@@ -1,5 +1,5 @@
1
1
  export { RustBridge } from './classes.rustbridge.js';
2
- export { RustBridgeRequestError, type IRustBridgeRequestErrorOptions, type TRustBridgeRequestErrorCode, } from './classes.rustbridgerequesterror.js';
2
+ export { RustBridgeRequestError, type IRustBridgeRequestErrorOptions, type IRustBridgeRemoteSettlement, type TRustBridgeRemoteSettlementOutcome, type TRustBridgeRequestErrorCode, } from './classes.rustbridgerequesterror.js';
3
3
  export { RustBinaryLocator } from './classes.rustbinarylocator.js';
4
4
  export { RustBinaryLocatorError } from './classes.rustbinarylocatorerror.js';
5
5
  export { StreamingResponse } from './classes.streamingresponse.js';
package/dist_ts/index.js CHANGED
@@ -7,4 +7,4 @@ export { StdioTransport } from './classes.stdiotransport.js';
7
7
  export { SocketTransport } from './classes.sockettransport.js';
8
8
  export { LineScanner } from './classes.linescanner.js';
9
9
  export * from './interfaces/index.js';
10
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDckQsT0FBTyxFQUNMLHNCQUFzQixHQUd2QixNQUFNLHFDQUFxQyxDQUFDO0FBQzdDLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLGdDQUFnQyxDQUFDO0FBQ25FLE9BQU8sRUFBRSxzQkFBc0IsRUFBRSxNQUFNLHFDQUFxQyxDQUFDO0FBQzdFLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLGdDQUFnQyxDQUFDO0FBQ25FLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw2QkFBNkIsQ0FBQztBQUM3RCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFDL0QsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBQ3ZELGNBQWMsdUJBQXVCLENBQUMifQ==
10
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDckQsT0FBTyxFQUNMLHNCQUFzQixHQUt2QixNQUFNLHFDQUFxQyxDQUFDO0FBQzdDLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLGdDQUFnQyxDQUFDO0FBQ25FLE9BQU8sRUFBRSxzQkFBc0IsRUFBRSxNQUFNLHFDQUFxQyxDQUFDO0FBQzdFLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLGdDQUFnQyxDQUFDO0FBQ25FLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw2QkFBNkIsQ0FBQztBQUM3RCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFDL0QsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBQ3ZELGNBQWMsdUJBQXVCLENBQUMifQ==
@@ -8,11 +8,26 @@ export interface IRustBridgeLogger {
8
8
  * Options for one non-streaming Rust bridge request.
9
9
  */
10
10
  export interface IRustBridgeRequestOptions {
11
- /** Abort only the local wait for this request. */
11
+ /**
12
+ * Abort the local wait for this request. When the binary advertised the
13
+ * `cancel` capability and the request was already written, the bridge also
14
+ * sends a cancel frame.
15
+ */
12
16
  signal?: AbortSignal;
13
17
  /** Override the bridge request timeout for this call, in milliseconds. */
14
18
  timeoutMs?: number;
15
19
  }
20
+ /**
21
+ * Options for one streaming Rust bridge request.
22
+ */
23
+ export interface IRustBridgeStreamingRequestOptions {
24
+ /**
25
+ * Abort the local stream. When the binary advertised the `cancel`
26
+ * capability and the request was already written, the bridge also sends a
27
+ * cancel frame.
28
+ */
29
+ signal?: AbortSignal;
30
+ }
16
31
  /** Options for confirmed bridge termination. */
17
32
  export interface IRustBridgeTerminateOptions {
18
33
  /**
@@ -8,6 +8,16 @@ export interface IManagementRequest {
8
8
  /** Value-free metadata for an associated one-shot binary frame. */
9
9
  sensitive?: IManagementSensitivePayload;
10
10
  }
11
+ /**
12
+ * Cancel frame sent to a binary that advertised the `cancel` capability, after
13
+ * a timeout or abort abandoned a request that was already written. The binary
14
+ * ignores it for ids it no longer runs and otherwise still answers the
15
+ * original id exactly once.
16
+ */
17
+ export interface IManagementCancelRequest {
18
+ id: string;
19
+ cancel: true;
20
+ }
11
21
  export interface IManagementSensitivePayload {
12
22
  protocol: 'smartrust-sensitive-v1';
13
23
  fileDescriptor: number;
@@ -29,6 +39,8 @@ export interface IManagementResponse {
29
39
  errorCode?: string;
30
40
  /** Optional structured details of a failure, for example per-field validation errors. */
31
41
  errorData?: unknown;
42
+ /** Set with `success: false` when the command stopped because of a cancel frame. */
43
+ cancelled?: boolean;
32
44
  /** Required after the receiver consumed the exact associated binary frame. */
33
45
  sensitiveAck?: IManagementSensitiveAck;
34
46
  }
@@ -38,6 +50,11 @@ export interface IManagementResponse {
38
50
  export interface IManagementEvent {
39
51
  event: string;
40
52
  data: any;
53
+ /**
54
+ * Protocol capabilities, read only from the ready event. `cancel` opts the
55
+ * current transport generation in to cancel frames.
56
+ */
57
+ capabilities?: string[];
41
58
  }
42
59
  /**
43
60
  * Definition of a single command supported by a Rust binary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartrust",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "private": false,
5
5
  "description": "A type-safe bridge between JavaScript engines and Rust binaries.",
6
6
  "main": "dist_ts/index.js",
@@ -9,10 +9,10 @@
9
9
  "author": "Task Venture Capital GmbH",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
- "@git.zone/tsbuild": "^5.0.0",
13
- "@git.zone/tsrun": "^3.0.0",
14
- "@git.zone/tstest": "^6.2.0",
15
- "@types/node": "^26.6.1"
12
+ "@git.zone/tsbuild": "^5.1.2",
13
+ "@git.zone/tsrun": "^3.0.2",
14
+ "@git.zone/tstest": "^7.1.0",
15
+ "@types/node": "^26.6.3"
16
16
  },
17
17
  "dependencies": {
18
18
  "@push.rocks/smartpath": "^6.0.0"
package/readme.md CHANGED
@@ -50,6 +50,9 @@ If you're integrating Rust into a Node.js project, you'll inevitably need:
50
50
  | **Rust → TS** (Error) | `{"id": "req_1", "success": false, "error": "msg", "errorCode": "EAPP_INVALID", "errorData": {...}}` | Error correlated by ID; `errorCode` and `errorData` are optional |
51
51
  | **Rust → TS** (Stream Chunk) | `{"id": "req_1", "stream": true, "data": {...}}` | Intermediate chunk (zero or more) |
52
52
  | **Rust → TS** (Event) | `{"event": "ready", "data": {...}}` | Unsolicited event (no ID) |
53
+ | **Rust → TS** (Ready with capabilities) | `{"event": "ready", "data": {...}, "capabilities": ["cancel"]}` | Optional opt-in to cancel frames for this connection |
54
+ | **TS → Rust** (Cancel) | `{"id": "req_1", "cancel": true}` | Sent only to binaries that advertised `cancel`, after a timeout or abort |
55
+ | **Rust → TS** (Cancelled) | `{"id": "req_1", "success": false, "cancelled": true, "error": "msg"}` | The one answer to a cancelled request; `errorCode` and `errorData` are optional |
53
56
 
54
57
  This protocol works identically over stdio and socket transports. Your Rust binary reads JSON lines from one end and writes JSON lines to the other. That's it.
55
58
 
@@ -222,9 +225,10 @@ try {
222
225
  constructor-level `requestTimeoutMs` remains the default. An already-aborted
223
226
  signal rejects before a request ID is allocated or anything is written.
224
227
 
225
- Timeout and abort only abandon the local TypeScript wait. They do not kill or
226
- disconnect the shared bridge, late responses are ignored, and later commands
227
- can continue using the same bridge.
228
+ Timeout and abort reject the local wait at once. They do not kill or
229
+ disconnect the shared bridge, and later commands can continue using the same
230
+ bridge. When the binary supports it, the bridge also asks Rust to stop the
231
+ command; see [Cancelling the Rust Side](#cancelling-the-rust-side).
228
232
 
229
233
  Use `maxPendingRequestsByMethod` to fail fast before serialization and transport
230
234
  writes when one command type reaches its local pending-request limit. Limits are
@@ -244,14 +248,66 @@ Capacity is released when the local request resolves, rejects, times out, or is
244
248
  aborted, when its transport write fails, or when the bridge terminates. Regular
245
249
  and streaming calls to the same method share its capacity. Every configured
246
250
  limit must be a positive safe integer; invalid values throw `RangeError` during
247
- construction. As with cancellation generally, timeout or abort cannot stop work
248
- that was already written to Rust, so the limit bounds local pending state rather
249
- than remote execution after local abandonment.
251
+ construction. The limit bounds local pending state, not remote execution: a
252
+ timed-out or aborted request frees its capacity immediately, even while Rust is
253
+ still working on it or answering its cancel frame.
250
254
 
251
- > The JSON-lines protocol has no cancellation message. After a command has
252
- > been written, Rust may still execute it even if the local wait times out or
253
- > is aborted. Do not blindly retry mutating commands; first establish whether
254
- > the original command executed or design the command to be idempotent.
255
+ #### Cancelling the Rust Side
256
+
257
+ A binary opts in to cancellation by adding `"capabilities": ["cancel"]` to its
258
+ ready event. The bridge records this per connection: a respawn or socket
259
+ reconnect starts without it until the next ready event advertises it again.
260
+ Binaries that do not advertise `cancel` never receive a cancel frame, so the
261
+ Rust command may keep running after the local wait ends.
262
+
263
+ With the capability, a timeout or abort of a request that was already written
264
+ sends one cancel frame, `{"id":"req_1","cancel":true}`. No cancel frame is sent
265
+ when the response already arrived, when the signal was aborted before the
266
+ request was written, or for `sendCommandWithSensitiveBytes`, whose failures
267
+ terminate the child instead.
268
+
269
+ The rejection's `remoteSettlement` then reports how Rust settled the request.
270
+ It resolves once and never rejects:
271
+
272
+ | `outcome` | Meaning |
273
+ |---|---|
274
+ | `cancelled` | Rust stopped and answered `success: false, cancelled: true` |
275
+ | `completed` | Rust finished anyway; `result` holds the result |
276
+ | `failed` | Rust answered `success: false` without `cancelled: true` |
277
+ | `disconnected` | The transport closed before Rust answered; the outcome is unknown |
278
+
279
+ `cancelled` and `failed` carry Rust's `error`, string `errorCode` and
280
+ `errorData` when it sent them. `remoteSettlement` is undefined when no cancel
281
+ frame was sent. Stream chunks and answers that arrive for a cancelled request
282
+ are consumed by the bridge and never reach the caller.
283
+
284
+ A Rust command may take a while to reach a point where it can stop, and a
285
+ faulty binary may never answer. Bound the wait yourself and terminate the child
286
+ when it runs out; the settlement then resolves as `disconnected`:
287
+
288
+ ```typescript
289
+ try {
290
+ await bridge.sendCommand('ingest', params, { timeoutMs: 60_000 });
291
+ } catch (error) {
292
+ if (error instanceof RustBridgeRequestError && error.remoteSettlement) {
293
+ let timer: ReturnType<typeof setTimeout> | undefined;
294
+ const deadline = new Promise<null>((resolve) => {
295
+ timer = setTimeout(() => resolve(null), 30_000);
296
+ });
297
+ const settlement = await Promise.race([error.remoteSettlement, deadline]);
298
+ clearTimeout(timer);
299
+ if (!settlement) {
300
+ await bridge.terminate(); // respawn with bridge.spawn() on next use
301
+ }
302
+ }
303
+ throw error;
304
+ }
305
+ ```
306
+
307
+ > Without the `cancel` capability, or when the settlement is `completed` or
308
+ > `disconnected`, Rust may have executed the command. Do not blindly retry
309
+ > mutating commands; first establish whether the original command executed or
310
+ > design the command to be idempotent.
255
311
 
256
312
  Timeout, cancellation, transport-write/exit, and Rust response failures use
257
313
  `RustBridgeRequestError`.
@@ -260,8 +316,8 @@ programmatic handling:
260
316
 
261
317
  | Code | Meaning |
262
318
  |---|---|
263
- | `ERR_RUST_BRIDGE_REQUEST_TIMEOUT` | The local request deadline elapsed |
264
- | `ERR_RUST_BRIDGE_REQUEST_ABORTED` | The caller's signal aborted the local wait |
319
+ | `ERR_RUST_BRIDGE_REQUEST_TIMEOUT` | The local request deadline or streaming inactivity deadline elapsed |
320
+ | `ERR_RUST_BRIDGE_REQUEST_ABORTED` | The caller's signal aborted the local wait or stream |
265
321
  | `ERR_RUST_BRIDGE_REQUEST_LIMIT` | The configured pending-request limit for this method is full |
266
322
  | `ERR_RUST_BRIDGE_REQUEST_WRITE` | The transport rejected the request write |
267
323
  | `ERR_RUST_BRIDGE_REQUEST_TRANSPORT` | The transport exited, disconnected, or was killed |
@@ -447,11 +503,26 @@ try {
447
503
  A final `success: false` response rejects both paths with the same
448
504
  `RustBridgeRequestError`. When the response includes an `errorCode` or
449
505
  `errorData`, they are available as `responseErrorCode` and `responseErrorData`
450
- on that error.
506
+ on that error. When the transport exits, disconnects or is terminated, both
507
+ paths reject with a `RustBridgeRequestError` whose code is
508
+ `ERR_RUST_BRIDGE_REQUEST_TRANSPORT`, as regular commands do.
509
+
510
+ #### Aborting a Stream
511
+
512
+ Pass an `AbortSignal` to stop consuming a stream. The iterator and `.result`
513
+ reject with `ERR_RUST_BRIDGE_REQUEST_ABORTED`, and a binary with the `cancel`
514
+ capability receives a cancel frame, exactly as for regular commands:
515
+
516
+ ```typescript
517
+ const controller = new AbortController();
518
+ const stream = bridge.sendCommandStreaming('processData', { count: 100 }, {
519
+ signal: controller.signal,
520
+ });
521
+ ```
451
522
 
452
523
  #### Stream Timeout
453
524
 
454
- By default, streaming commands use the same timeout as regular commands (`requestTimeoutMs`). The timeout **resets on each chunk received**, so it acts as an inactivity timeout rather than an absolute timeout. You can configure it separately:
525
+ By default, streaming commands use the same timeout as regular commands (`requestTimeoutMs`). The timeout **resets on each chunk received**, so it acts as an inactivity timeout rather than an absolute timeout. When it elapses, the stream rejects with a `RustBridgeRequestError` whose code is `ERR_RUST_BRIDGE_REQUEST_TIMEOUT`, and a binary with the `cancel` capability receives a cancel frame. You can configure it separately:
455
526
 
456
527
  ```typescript
457
528
  const bridge = new RustBridge<TMyCommands>({
@@ -621,6 +692,8 @@ Your Rust binary needs to implement a simple protocol. The transport (stdio or s
621
692
 
622
693
  6. **Use stderr** for logging — it won't interfere with the IPC protocol
623
694
 
695
+ 7. **Optionally support cancellation**: add `"capabilities":["cancel"]` to the ready event, then treat `{"id":"...","cancel":true}` as a request to stop that command at its next safe point. Answer the original id exactly once, either `{"id":"...","success":false,"cancelled":true,"error":"..."}` or its normal result when the command could no longer stop. Ignore cancel frames for ids that already finished or are unknown, and keep reading stdin while commands run so cancel frames arrive promptly.
696
+
624
697
  #### Socket Mode (Daemon)
625
698
 
626
699
  1. **Listen** on a Unix socket (e.g., `/var/run/my-daemon.sock`) or Windows named pipe
@@ -754,7 +827,7 @@ fn main() {
754
827
  | `connect(socketPath, options?)` | `Promise<boolean>` | **Socket mode**: Connect to a running daemon; returns `false` on connection/readiness failure and rejects stdio-only descriptor/byte-channel options |
755
828
  | `sendCommand(method, params, options?)` | `Promise<TCommands[K]['result']>` | Send a typed command with an optional per-call timeout or abort signal |
756
829
  | `sendCommandWithSensitiveBytes(method, params, bytes, options?)` | `Promise<TCommands[K]['result']>` | Send one exclusive JSON command with its raw bytes on the generation-owned one-shot pipe |
757
- | `sendCommandStreaming(method, params)` | `StreamingResponse<TChunk, TResult>` | Send a streaming command; returns immediately |
830
+ | `sendCommandStreaming(method, params, options?)` | `StreamingResponse<TChunk, TResult>` | Send a streaming command with an optional abort signal; returns immediately |
758
831
  | `terminate(options?)` | `Promise<void>` | Confirm stdio child exit or local socket closure; `gracePeriodMs` controls SIGKILL escalation |
759
832
  | `kill()` | `void` | Fire-and-forget compatibility wrapper around `terminate()` |
760
833
  | `running` | `boolean` | Whether the bridge is currently connected and ready |
@@ -811,6 +884,9 @@ fn main() {
811
884
  |---|---|
812
885
  | `IRustBridgeOptions` | Full configuration for `RustBridge` |
813
886
  | `IRustBridgeRequestOptions` | Per-command options: `{ signal?, timeoutMs? }` |
887
+ | `IRustBridgeStreamingRequestOptions` | Per-stream options: `{ signal? }` |
888
+ | `IRustBridgeRemoteSettlement` | Remote outcome of a cancelled request: `{ outcome, result?, error?, errorCode?, errorData? }` |
889
+ | `TRustBridgeRemoteSettlementOutcome` | `'cancelled' \| 'completed' \| 'failed' \| 'disconnected'` |
814
890
  | `IRustBridgeTerminateOptions` | Confirmed termination options: `{ gracePeriodMs? }` |
815
891
  | `IRustTransportDisconnectOptions` | Optional confirmed-disconnect grace period for owned process transports |
816
892
  | `IBinaryLocatorOptions` | Configuration for `RustBinaryLocator` |
@@ -818,14 +894,15 @@ fn main() {
818
894
  | `IRustBridgeLogger` | Logger interface: `{ log(level, message, data?) }` |
819
895
  | `IRustTransport` | Transport interface (extends `EventEmitter`) |
820
896
  | `IManagementRequest` | IPC request shape: `{ id, method, params }` |
821
- | `IManagementResponse` | IPC response shape: `{ id, success, result?, error?, errorCode?, errorData? }` |
822
- | `IManagementEvent` | IPC event shape: `{ event, data }` |
897
+ | `IManagementResponse` | IPC response shape: `{ id, success, result?, error?, errorCode?, errorData?, cancelled? }` |
898
+ | `IManagementCancelRequest` | IPC cancel frame shape: `{ id, cancel: true }` |
899
+ | `IManagementEvent` | IPC event shape: `{ event, data, capabilities? }` |
823
900
  | `IManagementStreamChunk` | IPC stream chunk shape: `{ id, stream: true, data }` |
824
901
  | `ICommandDefinition` | Single command definition: `{ params, result }` |
825
902
  | `TCommandMap` | `Record<string, ICommandDefinition>` |
826
903
  | `TStreamingCommandKeys<T>` | Extracts keys from a command map that have a `chunk` field |
827
904
  | `TExtractChunk<T>` | Extracts the chunk type from a streaming command definition |
828
- | `RustBridgeRequestError` | Request error class with stable `code`, optional `responseErrorCode`, `responseErrorData`, `requestId`, and `method` fields |
905
+ | `RustBridgeRequestError` | Request error class with stable `code`, optional `responseErrorCode`, `responseErrorData`, `requestId`, `method`, and `remoteSettlement` fields |
829
906
  | `TRustBridgeRequestErrorCode` | Union of stable request error code strings |
830
907
 
831
908
  ## License and Legal Information
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartrust',
6
- version: '2.4.0',
6
+ version: '2.5.1',
7
7
  description: 'A type-safe bridge between JavaScript engines and Rust binaries.'
8
8
  }
@@ -4,9 +4,16 @@ import type { IRustBridgeLogger } from './interfaces/index.js';
4
4
  * Buffer-based newline scanner for streaming binary data.
5
5
  * Accumulates chunks and emits complete lines via callback.
6
6
  * Used by both StdioTransport and SocketTransport.
7
+ *
8
+ * Each byte is scanned once and copied at most once into its line, so a line that arrives in many
9
+ * chunks costs time linear in its length.
7
10
  */
8
11
  export class LineScanner {
9
- private buffer: Buffer = Buffer.alloc(0);
12
+ /** Chunks of the line being received; none of them contains a newline. */
13
+ private pending: Buffer[] = [];
14
+ private pendingLength = 0;
15
+ /** Bytes after a line whose callback threw; they may contain newlines and are scanned first. */
16
+ private unscanned: Buffer = Buffer.alloc(0);
10
17
  private maxPayloadSize: number;
11
18
  private logger: IRustBridgeLogger;
12
19
 
@@ -19,44 +26,74 @@ export class LineScanner {
19
26
  * Feed a chunk of data. Calls onLine for each complete newline-terminated line found.
20
27
  */
21
28
  public push(chunk: Buffer, onLine: (line: string) => void): void {
22
- this.buffer = Buffer.concat([this.buffer, chunk]);
29
+ const input = this.unscanned.length > 0 ? Buffer.concat([this.unscanned, chunk]) : chunk;
30
+ this.unscanned = Buffer.alloc(0);
31
+ let start = 0;
23
32
 
24
33
  try {
25
34
  let newlineIndex: number;
26
- while ((newlineIndex = this.buffer.indexOf(0x0A)) !== -1) {
27
- const lineBuffer = this.buffer.subarray(0, newlineIndex);
28
- this.buffer = this.buffer.subarray(newlineIndex + 1);
29
-
30
- if (lineBuffer.length > this.maxPayloadSize) {
31
- this.logger.log('error', `Inbound message exceeds maxPayloadSize (${lineBuffer.length} bytes), dropping`);
35
+ while ((newlineIndex = input.indexOf(0x0A, start)) !== -1) {
36
+ const lineBuffer = this.takeLine(input.subarray(start, newlineIndex));
37
+ start = newlineIndex + 1;
38
+ if (!lineBuffer) {
32
39
  continue;
33
40
  }
34
-
35
41
  const line = lineBuffer.toString('utf8').trim();
36
42
  if (line) {
37
43
  onLine(line);
38
44
  }
39
45
  }
40
-
41
- // Prevent OOM if sender never sends newline
42
- if (this.buffer.length > this.maxPayloadSize) {
43
- this.logger.log('error', `Buffer exceeded maxPayloadSize (${this.buffer.length} bytes) without newline, clearing`);
44
- this.buffer = Buffer.alloc(0);
45
- }
46
- } finally {
47
- // A subarray retains its entire backing allocation, even when empty.
48
- // Compact once per chunk, including when a callback throws, without
49
- // repeatedly copying the unread suffix for each parsed line.
50
- if (this.buffer.length === 0) {
51
- this.buffer = Buffer.alloc(0);
52
- } else if (this.buffer.buffer.byteLength > Math.max(Buffer.poolSize, this.buffer.length * 2)) {
53
- this.buffer = Buffer.from(this.buffer);
54
- }
46
+ this.appendPartial(input.subarray(start));
47
+ } catch (error) {
48
+ // Keep what follows the failed line for the next push, without retaining what was consumed.
49
+ this.unscanned = LineScanner.compact(input.subarray(start));
50
+ throw error;
55
51
  }
56
52
  }
57
53
 
58
54
  /** Reset the internal buffer. */
59
55
  public clear(): void {
60
- this.buffer = Buffer.alloc(0);
56
+ this.pending = [];
57
+ this.pendingLength = 0;
58
+ this.unscanned = Buffer.alloc(0);
59
+ }
60
+
61
+ /** Joins the pending chunks with the line's last piece; null when the line is too large. */
62
+ private takeLine(tail: Buffer): Buffer | null {
63
+ const length = this.pendingLength + tail.length;
64
+ const pending = this.pending;
65
+ this.pending = [];
66
+ this.pendingLength = 0;
67
+ if (length > this.maxPayloadSize) {
68
+ this.logger.log('error', `Inbound message exceeds maxPayloadSize (${length} bytes), dropping`);
69
+ return null;
70
+ }
71
+ return pending.length === 0 ? tail : Buffer.concat([...pending, tail], length);
72
+ }
73
+
74
+ private appendPartial(partial: Buffer): void {
75
+ if (partial.length === 0) {
76
+ return;
77
+ }
78
+ // Prevent OOM if sender never sends newline
79
+ if (this.pendingLength + partial.length > this.maxPayloadSize) {
80
+ this.logger.log(
81
+ 'error',
82
+ `Buffer exceeded maxPayloadSize (${this.pendingLength + partial.length} bytes) without newline, clearing`,
83
+ );
84
+ this.pending = [];
85
+ this.pendingLength = 0;
86
+ return;
87
+ }
88
+ this.pending.push(LineScanner.compact(partial));
89
+ this.pendingLength += partial.length;
90
+ }
91
+
92
+ /** A subarray retains its entire backing allocation; copy a small piece of a large one. */
93
+ private static compact(piece: Buffer): Buffer {
94
+ if (piece.length === 0) {
95
+ return Buffer.alloc(0);
96
+ }
97
+ return piece.buffer.byteLength > Math.max(Buffer.poolSize, piece.length * 2) ? Buffer.from(piece) : piece;
61
98
  }
62
99
  }