@linxiraos/pi-utils 1.4.0 → 1.4.2

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.2] - 2026-10-24
4
+
5
+ - Added `ZETA_WEB_PORT` (43141), `ZETA_GATEWAY_PORT` (43143) and `ZETA_STATS_PORT` (43147) as the single source for Zeta's local service defaults
6
+
7
+ ## [1.4.1] - 2026-10-23
8
+
9
+ - Version alignment with the 1.4.1 release line (no behavioral changes).
10
+
3
11
  ## [1.4.0] - 2026-10-21
4
12
 
5
13
  - Version line moves to 1.4.0 with the rest of the suite; no user-visible change in this package.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Shared utilities for [omp](https://github.com/can1357/oh-my-pi) packages. Zero c
6
6
 
7
7
  | Module | Purpose |
8
8
  | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
9
- | `logger` | Centralized logger writing to `~/.zeta/logs/` with rotation (TUI-safe — never stdout) |
9
+ | `logger` | Centralized logger writing to `~/.zeta/logs/` (`bun test`: temp dir), rotating, never stdout |
10
10
  | `prompt` | Handlebars-based prompt templating and formatting helpers |
11
11
  | `dirs` | Path helpers for zetacode config directories (`~/.zeta`, XDG-aware on Linux) |
12
12
  | `stream` | `readStream` / `readLines` helpers over `ReadableStream` |
@@ -7,7 +7,15 @@ export interface CreateTerminalResponse {
7
7
  }
8
8
  /** Client callbacks handled by a client-side connection. */
9
9
  export interface Client {
10
- requestPermission(params: RequestPermissionRequest): MaybePromise<RequestPermissionResponse>;
10
+ /**
11
+ * Serve a permission elicit. `options.signal` aborts when the agent
12
+ * cancels the request (`$/cancel_request`) — e.g. user steering superseded
13
+ * the dialog — so a held handler can end instead of stranding the
14
+ * connection's clean-EOF drain.
15
+ */
16
+ requestPermission(params: RequestPermissionRequest, options?: {
17
+ signal?: AbortSignal;
18
+ }): MaybePromise<RequestPermissionResponse>;
11
19
  sessionUpdate(params: SessionNotification): MaybePromise<void>;
12
20
  readTextFile?(params: ReadTextFileRequest): MaybePromise<ReadTextFileResponse>;
13
21
  writeTextFile?(params: WriteTextFileRequest): MaybePromise<WriteTextFileResponse | void>;
@@ -45,6 +53,15 @@ export declare class AgentSideConnection {
45
53
  sessionUpdate(params: SessionNotification): Promise<void>;
46
54
  /** Requests permission from the client. */
47
55
  requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse>;
56
+ /**
57
+ * Requests permission from the client, returning a handle that cancels the
58
+ * pending wire request (`$/cancel_request`) — used when user steering
59
+ * supersedes an open permission dialog instead of waiting behind it.
60
+ */
61
+ requestPermissionWithCancel(params: RequestPermissionRequest): {
62
+ promise: Promise<RequestPermissionResponse>;
63
+ cancel(): void;
64
+ };
48
65
  /** Reads a client-side text file. */
49
66
  readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
50
67
  /** Writes a client-side text file. */
@@ -69,17 +69,41 @@ export declare class RequestError extends Error {
69
69
  /** Converts this error into a JSON-RPC error payload. */
70
70
  toErrorResponse(): ErrorResponse;
71
71
  }
72
- type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
72
+ type Dispatcher = (method: string, params: unknown, notification: boolean,
73
+ /** Abort signal for an inbound *request*: fires when the peer cancels it via `$/cancel_request`. */
74
+ signal?: AbortSignal) => MaybePromise<unknown>;
75
+ /** Options for {@link RpcConnection}. */
76
+ export interface RpcConnectionOptions {
77
+ /**
78
+ * The dispatcher honors the abort signal it receives for inbound requests.
79
+ * Only then does a peer `$/cancel_request` abort the handler and answer the
80
+ * request `-32800` immediately; otherwise the handler runs to completion and
81
+ * its real result is the response, since answering "cancelled" while the
82
+ * work keeps going would hide its outcome from the peer.
83
+ */
84
+ honorsInboundCancellation?: boolean;
85
+ }
73
86
  /** Correlated bidirectional JSON-RPC connection. */
74
87
  export declare class RpcConnection {
75
88
  #private;
76
- constructor(stream: Stream, dispatcher: Dispatcher);
89
+ constructor(stream: Stream, dispatcher: Dispatcher, options?: RpcConnectionOptions);
77
90
  /** Signal aborted when the stream closes. */
78
91
  get signal(): AbortSignal;
79
92
  /** Promise resolved when the stream closes. */
80
93
  get closed(): Promise<void>;
81
94
  /** Sends a correlated JSON-RPC request. */
82
95
  request<Response>(method: string, params?: unknown): Promise<Response>;
96
+ /**
97
+ * Sends a JSON-RPC request whose pending entry can be cancelled locally:
98
+ * `cancel()` rejects the promise with a request-cancelled error, notifies
99
+ * the peer via `$/cancel_request`, and drops the pending entry so a late
100
+ * response is ignored. Used for agent→client requests a mid-turn event can
101
+ * supersede (e.g. a permission elicit cancelled when user steering arrives).
102
+ */
103
+ requestCancellable<Response>(method: string, params?: unknown): {
104
+ promise: Promise<Response>;
105
+ cancel(): void;
106
+ };
83
107
  /** Sends a JSON-RPC notification. */
84
108
  notify(method: string, params?: unknown): Promise<void>;
85
109
  /** Closes the connection and rejects outstanding requests. */
@@ -1,6 +1,9 @@
1
+ import { type ByteSource } from "../source.js";
1
2
  /** gzip magic: 0x1f 0x8b. */
2
3
  export declare function isGzip(bytes: Uint8Array): boolean;
3
4
  /** Decompress one gzip stream, bounded so a gzip bomb cannot inflate without limit. */
4
5
  export declare function gzipDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
6
+ /** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
7
+ export declare function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
5
8
  /** Compress bytes as one gzip stream (tar.gz writing). */
6
9
  export declare function gzipCompress(bytes: Uint8Array): Promise<Uint8Array>;
@@ -1,6 +1,9 @@
1
+ import { type ByteSource } from "../source.js";
1
2
  /** zstd frame magic: 28 b5 2f fd. */
2
3
  export declare function isZstd(bytes: Uint8Array): boolean;
3
4
  /** Decompress one zstd frame, bounded to `maxOutput` bytes. */
4
5
  export declare function zstdDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
6
+ /** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
7
+ export declare function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
5
8
  /** Compress bytes as one zstd frame (tar.zst writing). */
6
9
  export declare function zstdCompress(bytes: Uint8Array): Promise<Uint8Array>;
@@ -9,6 +9,8 @@ export interface ArchiveLimits {
9
9
  maxEntries: number;
10
10
  /** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
11
11
  maxInMemorySize: number;
12
+ /** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
13
+ maxStreamSize: number;
12
14
  /** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
13
15
  maxIndexSize: number;
14
16
  /** Max declared bytes for a single extracted member. */
@@ -1,3 +1,4 @@
1
+ import { type Transform } from "node:stream";
1
2
  /**
2
3
  * A byte window into an archive — file-backed (lazy, ranged reads) or
3
4
  * in-memory. Format readers index through this so ZIP/ASAR/RAR payloads are
@@ -17,6 +18,14 @@ export declare function memoryByteSource(buffer: Uint8Array): ByteSource;
17
18
  export declare function fileByteSource(filePath: string): ByteSource;
18
19
  /** Materialize an entire {@link ByteSource}; use only under a limits check. */
19
20
  export declare function readAllBytes(source: ByteSource): Promise<Uint8Array>;
21
+ /** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
22
+ export declare const STREAM_CHUNK_SIZE: number;
23
+ /**
24
+ * Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
25
+ * the compressed nor the decompressed stream is ever held whole. Output past
26
+ * `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
27
+ */
28
+ export declare function decodeByteSource(source: ByteSource, decoder: Transform, maxOutput: number): AsyncGenerator<Uint8Array>;
20
29
  /** Options for {@link httpByteSource}. */
21
30
  export interface HttpByteSourceOptions {
22
31
  /** Extra request headers (e.g. authorization). */
@@ -1,6 +1,11 @@
1
1
  import type { ArchiveIndexEntry, FormatReader, FormatReadOptions } from "./types.js";
2
- /** Index an already-decompressed tar buffer with bounded GNU, ustar, and PAX handling. */
3
- export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[];
2
+ /** Index an already-decompressed tar buffer; members are views into it. */
3
+ export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
4
+ /**
5
+ * Index a decompressed tar stream that is too large to hold in memory: one
6
+ * pass over `open()` walks the headers, and member reads open it again.
7
+ */
8
+ export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
4
9
  /** Read and index a tar source after one bounded whole-stream read. */
5
10
  export declare const readTar: FormatReader;
6
11
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
@@ -49,7 +49,14 @@ export declare function normalizeProfileName(profile: string | undefined): strin
49
49
  export declare function resolveProfileEnv(zeta: string | undefined): string | undefined;
50
50
  /** Profile-independent config root (~/.zeta), shared by every zeta profile. */
51
51
  export declare function getBaseConfigRoot(): string;
52
- export declare function resolveEquivalentPath(inputPath: string): string;
52
+ export interface EquivalentPathOptions {
53
+ /**
54
+ * Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
55
+ * missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
56
+ */
57
+ throughMissing?: boolean;
58
+ }
59
+ export declare function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string;
53
60
  export declare function normalizePathForComparison(inputPath: string): string;
54
61
  /**
55
62
  * Compare paths already normalized by {@link normalizePathForComparison}.
@@ -1,3 +1,4 @@
1
+ export * from "./test-runtime.js";
1
2
  export * from "./worker-host.js";
2
3
  /**
3
4
  * Strict shell-identifier shape. Used for dotenv keys we accept into
@@ -81,8 +82,6 @@ export declare function $envExact(name: string, env?: Record<string, string | un
81
82
  * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
82
83
  */
83
84
  export declare function $envpos(name: string, defaultValue: number): number;
84
- /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
85
- export declare function isBunTestRuntime(): boolean;
86
85
  /**
87
86
  * True when real-terminal side effects must be suppressed: stdout escape/frame
88
87
  * writes, stdin raw-mode + resume, CSI/OSC capability probes, SIGWINCH, window
@@ -2,7 +2,8 @@
2
2
  * `fs.openSync` with close-on-exec, matching Node's default.
3
3
  *
4
4
  * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
5
- * dies at `exec`. Bun's `fs.open*` does not, and descriptors it returns survive
5
+ * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
6
+ * `bun@>=1.4` pin), and descriptors it returns survive
6
7
  * into any child spawned through a path that does not close strays itself — the
7
8
  * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
8
9
  * this for every descriptor kept open past such a spawn (session transcripts,
@@ -22,6 +22,7 @@ export * from "./mermaid-ascii.js";
22
22
  export * from "./mime.js";
23
23
  export * from "./path.js";
24
24
  export * from "./path-tree.js";
25
+ export * from "./ports.js";
25
26
  export * from "./peek-file.js";
26
27
  export * as postmortem from "./postmortem.js";
27
28
  export * from "./process-name.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Default loopback ports for Zeta's local services.
3
+ *
4
+ * OMP installs on the same machine use 30141/30142/3847, and common developer
5
+ * tools squat on 3000/8080/8787/3847. Zeta therefore owns a dedicated block:
6
+ * three uncommon ports, at least two apart, so a side-by-side OMP install and
7
+ * unrelated services cannot take the gateway or the stats dashboard away.
8
+ *
9
+ * Every default in the repository derives from these constants. The standalone
10
+ * Electron shell (`desktop/src/main.ts`, not a Bun workspace package) carries
11
+ * literals that must match; its serve-discovery test asserts the values.
12
+ */
13
+ export declare const ZETA_WEB_PORT = 43141;
14
+ export declare const ZETA_GATEWAY_PORT = 43143;
15
+ export declare const ZETA_STATS_PORT = 43147;
@@ -0,0 +1,2 @@
1
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
2
+ export declare function isBunTestRuntime(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -54,7 +54,7 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@linxiraos/pi-natives": "1.4.0"
57
+ "@linxiraos/pi-natives": "1.4.2"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "1.4.3"
@@ -47,7 +47,16 @@ export interface CreateTerminalResponse {
47
47
  }
48
48
  /** Client callbacks handled by a client-side connection. */
49
49
  export interface Client {
50
- requestPermission(params: RequestPermissionRequest): MaybePromise<RequestPermissionResponse>;
50
+ /**
51
+ * Serve a permission elicit. `options.signal` aborts when the agent
52
+ * cancels the request (`$/cancel_request`) — e.g. user steering superseded
53
+ * the dialog — so a held handler can end instead of stranding the
54
+ * connection's clean-EOF drain.
55
+ */
56
+ requestPermission(
57
+ params: RequestPermissionRequest,
58
+ options?: { signal?: AbortSignal },
59
+ ): MaybePromise<RequestPermissionResponse>;
51
60
  sessionUpdate(params: SessionNotification): MaybePromise<void>;
52
61
  readTextFile?(params: ReadTextFileRequest): MaybePromise<ReadTextFileResponse>;
53
62
  writeTextFile?(params: WriteTextFileRequest): MaybePromise<WriteTextFileResponse | void>;
@@ -90,6 +99,17 @@ export class AgentSideConnection {
90
99
  requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
91
100
  return this.#connection.request("session/request_permission", params);
92
101
  }
102
+ /**
103
+ * Requests permission from the client, returning a handle that cancels the
104
+ * pending wire request (`$/cancel_request`) — used when user steering
105
+ * supersedes an open permission dialog instead of waiting behind it.
106
+ */
107
+ requestPermissionWithCancel(params: RequestPermissionRequest): {
108
+ promise: Promise<RequestPermissionResponse>;
109
+ cancel(): void;
110
+ } {
111
+ return this.#connection.requestCancellable<RequestPermissionResponse>("session/request_permission", params);
112
+ }
93
113
  /** Reads a client-side text file. */
94
114
  readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
95
115
  return this.#connection.request("fs/read_text_file", params);
@@ -168,10 +188,18 @@ export class ClientSideConnection {
168
188
  #connection: RpcConnection;
169
189
 
170
190
  constructor(toClient: (connection: ClientSideConnection) => Client, stream: Stream) {
171
- this.#connection = new RpcConnection(stream, async (method, params, notification) => {
172
- if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
173
- return dispatchClient(client, method, params, notification);
174
- });
191
+ // Client handlers receive the cancel signal (`requestPermission`'s
192
+ // `options.signal`), so a peer `$/cancel_request` may answer `-32800`
193
+ // at once. The agent side keeps the default: its handlers ignore the
194
+ // signal, and `session/cancel` is ACP's cancellation path for prompts.
195
+ this.#connection = new RpcConnection(
196
+ stream,
197
+ async (method, params, notification, signal) => {
198
+ if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
199
+ return dispatchClient(client, method, params, notification, signal);
200
+ },
201
+ { honorsInboundCancellation: true },
202
+ );
175
203
  const client = toClient(this);
176
204
  }
177
205
  /** Signal aborted when the transport closes. */
@@ -272,10 +300,11 @@ async function dispatchClient(
272
300
  method: string,
273
301
  params: unknown,
274
302
  notification: boolean,
303
+ signal?: AbortSignal,
275
304
  ): Promise<unknown> {
276
305
  switch (method) {
277
306
  case "session/request_permission":
278
- return client.requestPermission(params as RequestPermissionRequest);
307
+ return client.requestPermission(params as RequestPermissionRequest, { signal });
279
308
  case "session/update":
280
309
  return client.sessionUpdate(params as SessionNotification);
281
310
  case "fs/read_text_file":
@@ -322,6 +351,9 @@ async function dispatchClient(
322
351
  params as CompleteElicitationNotification,
323
352
  );
324
353
  default:
354
+ // The transport already aborted the matching inbound handler;
355
+ // like the agent side, the notification itself carries no work.
356
+ if (method === "$/cancel_request") return;
325
357
  if (notification && client.extNotification)
326
358
  return client.extNotification(method, params as Record<string, unknown>);
327
359
  if (!notification && client.extMethod) return client.extMethod(method, params as Record<string, unknown>);
@@ -105,9 +105,27 @@ function createStandardError(
105
105
  return new RequestError(code, additionalMessage ? `${message}: ${additionalMessage}` : message, data);
106
106
  }
107
107
 
108
- type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
108
+ type Dispatcher = (
109
+ method: string,
110
+ params: unknown,
111
+ notification: boolean,
112
+ /** Abort signal for an inbound *request*: fires when the peer cancels it via `$/cancel_request`. */
113
+ signal?: AbortSignal,
114
+ ) => MaybePromise<unknown>;
109
115
  type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
110
116
 
117
+ /** Options for {@link RpcConnection}. */
118
+ export interface RpcConnectionOptions {
119
+ /**
120
+ * The dispatcher honors the abort signal it receives for inbound requests.
121
+ * Only then does a peer `$/cancel_request` abort the handler and answer the
122
+ * request `-32800` immediately; otherwise the handler runs to completion and
123
+ * its real result is the response, since answering "cancelled" while the
124
+ * work keeps going would hide its outcome from the peer.
125
+ */
126
+ honorsInboundCancellation?: boolean;
127
+ }
128
+
111
129
  /** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
112
130
  const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
113
131
 
@@ -117,15 +135,18 @@ export class RpcConnection {
117
135
  #pending = new Map<JsonRpcId, Pending>();
118
136
  #inbound = new Set<Promise<void>>();
119
137
  #openInboundIds = new Set<JsonRpcId>();
138
+ /** Abort controller per inbound request, fired by a peer `$/cancel_request` (opt-in). */
139
+ #inboundCancellers: Map<JsonRpcId, AbortController> | undefined;
120
140
  #writable: WritableStream<AnyMessage>;
121
141
  #writeTail: Promise<void> = Promise.resolve();
122
142
  #abort = new AbortController();
123
143
  #closed = Promise.withResolvers<void>();
124
144
  #dispatcher: Dispatcher;
125
145
 
126
- constructor(stream: Stream, dispatcher: Dispatcher) {
146
+ constructor(stream: Stream, dispatcher: Dispatcher, options?: RpcConnectionOptions) {
127
147
  this.#writable = stream.writable;
128
148
  this.#dispatcher = dispatcher;
149
+ this.#inboundCancellers = options?.honorsInboundCancellation ? new Map() : undefined;
129
150
  void this.#read(stream.readable);
130
151
  }
131
152
 
@@ -141,6 +162,48 @@ export class RpcConnection {
141
162
  /** Sends a correlated JSON-RPC request. */
142
163
  request<Response>(method: string, params?: unknown): Promise<Response> {
143
164
  if (this.signal.aborted) return Promise.reject(new Error("Connection closed"));
165
+ return this.#beginRequest(method, params).deferred.promise as Promise<Response>;
166
+ }
167
+
168
+ /**
169
+ * Sends a JSON-RPC request whose pending entry can be cancelled locally:
170
+ * `cancel()` rejects the promise with a request-cancelled error, notifies
171
+ * the peer via `$/cancel_request`, and drops the pending entry so a late
172
+ * response is ignored. Used for agent→client requests a mid-turn event can
173
+ * supersede (e.g. a permission elicit cancelled when user steering arrives).
174
+ */
175
+ requestCancellable<Response>(
176
+ method: string,
177
+ params?: unknown,
178
+ ): {
179
+ promise: Promise<Response>;
180
+ cancel(): void;
181
+ } {
182
+ if (this.signal.aborted) {
183
+ return {
184
+ promise: Promise.reject(new Error("Connection closed")),
185
+ cancel: () => {},
186
+ };
187
+ }
188
+ const { id, deferred } = this.#beginRequest(method, params);
189
+ return {
190
+ promise: deferred.promise as Promise<Response>,
191
+ cancel: () => {
192
+ if (!this.#pending.delete(id)) return;
193
+ deferred.reject(RequestError.requestCancelled({ requestId: id, method }));
194
+ void this.notify("$/cancel_request", { requestId: id }).catch(() => undefined);
195
+ },
196
+ };
197
+ }
198
+
199
+ /** Shared request plumbing: allocate an id, register the pending entry, write the frame. */
200
+ #beginRequest(
201
+ method: string,
202
+ params: unknown,
203
+ ): {
204
+ id: number;
205
+ deferred: { promise: Promise<unknown>; resolve: (value?: unknown) => void; reject: (reason?: unknown) => void };
206
+ } {
144
207
  const id = this.#nextId++;
145
208
  const deferred = Promise.withResolvers<unknown>();
146
209
  this.#pending.set(id, deferred);
@@ -148,7 +211,7 @@ export class RpcConnection {
148
211
  this.#pending.delete(id);
149
212
  deferred.reject(error);
150
213
  });
151
- return deferred.promise as Promise<Response>;
214
+ return { id, deferred };
152
215
  }
153
216
 
154
217
  /** Sends a JSON-RPC notification. */
@@ -235,6 +298,18 @@ export class RpcConnection {
235
298
  }
236
299
  if (!("method" in message)) return;
237
300
  if (!("id" in message)) {
301
+ // ACP v1 names the cancelled request `requestId`. A cancelled
302
+ // inbound request must still be answered (spec: result or
303
+ // `-32800`): the handler is aborted and the error response is
304
+ // written here, so a late-settling handler lands in `#respond`'s
305
+ // closed-id no-op instead of answering a request the peer gave
306
+ // up on.
307
+ if (message.method === "$/cancel_request" && this.#inboundCancellers) {
308
+ this.#cancelInbound(
309
+ this.#inboundCancellers,
310
+ (message.params as { requestId?: JsonRpcId } | undefined)?.requestId,
311
+ );
312
+ }
238
313
  try {
239
314
  await this.#dispatcher(message.method, message.params, true);
240
315
  } catch {
@@ -242,8 +317,11 @@ export class RpcConnection {
242
317
  }
243
318
  return;
244
319
  }
320
+ const cancellers = this.#inboundCancellers;
321
+ const canceller = cancellers ? new AbortController() : undefined;
322
+ if (canceller) cancellers?.set(message.id, canceller);
245
323
  try {
246
- const result = await this.#dispatcher(message.method, message.params, false);
324
+ const result = await this.#dispatcher(message.method, message.params, false, canceller?.signal);
247
325
  await this.#respond(message.id, { result: result ?? {} });
248
326
  } catch (error) {
249
327
  const protocolError =
@@ -251,6 +329,25 @@ export class RpcConnection {
251
329
  ? error
252
330
  : RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
253
331
  await this.#respond(message.id, { error: protocolError.toErrorResponse() });
332
+ } finally {
333
+ cancellers?.delete(message.id);
254
334
  }
255
335
  }
336
+
337
+ #cancelInbound(cancellers: Map<JsonRpcId, AbortController>, id: JsonRpcId | undefined): void {
338
+ if (id === undefined) return;
339
+ const canceller = cancellers.get(id);
340
+ if (canceller) {
341
+ cancellers.delete(id);
342
+ canceller.abort(RequestError.requestCancelled({ requestId: id }));
343
+ }
344
+ // Spec: a cancelled request still gets a response. Answer `-32800`
345
+ // here — synchronously with the notification — so the peer is never
346
+ // left waiting on a handler that may never settle. `#respond` closes
347
+ // the id, so a late handler result (a handler that ignored its abort
348
+ // signal) lands in the closed-id no-op instead of answering twice.
349
+ void this.#respond(id, { error: RequestError.requestCancelled({ requestId: id }).toErrorResponse() }).catch(
350
+ () => undefined,
351
+ );
352
+ }
256
353
  }
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { ArchiveError } from "../error";
4
+ import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
4
5
 
5
6
  const gzipAsync = promisify(zlib.gzip);
6
7
  const gunzipAsync = promisify(zlib.gunzip);
@@ -19,6 +20,11 @@ export async function gzipDecompress(bytes: Uint8Array, maxOutput: number): Prom
19
20
  }
20
21
  }
21
22
 
23
+ /** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
24
+ export function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
25
+ return decodeByteSource(source, zlib.createGunzip({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
26
+ }
27
+
22
28
  /** Compress bytes as one gzip stream (tar.gz writing). */
23
29
  export function gzipCompress(bytes: Uint8Array): Promise<Uint8Array> {
24
30
  return gzipAsync(bytes);
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { ArchiveError } from "../error";
4
+ import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
4
5
 
5
6
  const zstdCompressAsync = promisify(zlib.zstdCompress);
6
7
  const zstdDecompressAsync = promisify(zlib.zstdDecompress);
@@ -19,6 +20,11 @@ export async function zstdDecompress(bytes: Uint8Array, maxOutput: number): Prom
19
20
  }
20
21
  }
21
22
 
23
+ /** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
24
+ export function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
25
+ return decodeByteSource(source, zlib.createZstdDecompress({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
26
+ }
27
+
22
28
  /** Compress bytes as one zstd frame (tar.zst writing). */
23
29
  export function zstdCompress(bytes: Uint8Array): Promise<Uint8Array> {
24
30
  return zstdCompressAsync(bytes);
package/src/ar/deb.ts CHANGED
@@ -135,7 +135,7 @@ async function readDebImpl(source: ByteSource, options: FormatReadOptions): Prom
135
135
  }
136
136
  const compressed = await readOuterMember(outerEntry);
137
137
  const tarBytes = await decompressDebTar(compressed, tar.compression, options);
138
- const innerEntries = readTarEntriesFromBuffer(tarBytes, options);
138
+ const innerEntries = await readTarEntriesFromBuffer(tarBytes, options);
139
139
  for (const innerEntry of innerEntries) {
140
140
  upsertArchiveEntry(result, tar.kind === "control" ? prefixControlEntry(innerEntry) : innerEntry);
141
141
  }
package/src/ar/limits.ts CHANGED
@@ -13,6 +13,8 @@ export interface ArchiveLimits {
13
13
  maxEntries: number;
14
14
  /** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
15
15
  maxInMemorySize: number;
16
+ /** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
17
+ maxStreamSize: number;
16
18
  /** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
17
19
  maxIndexSize: number;
18
20
  /** Max declared bytes for a single extracted member. */
@@ -26,6 +28,7 @@ export interface ArchiveLimits {
26
28
  export const DEFAULT_ARCHIVE_LIMITS: ArchiveLimits = {
27
29
  maxEntries: 1_000_000,
28
30
  maxInMemorySize: 256 * 1024 * 1024,
31
+ maxStreamSize: 4 * 1024 * 1024 * 1024,
29
32
  maxIndexSize: 64 * 1024 * 1024,
30
33
  maxMemberSize: 64 * 1024 * 1024,
31
34
  maxPathBytes: 4096,