@oh-my-pi/pi-utils 11.1.0 → 11.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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/ptree.ts +175 -275
  3. package/src/stream.ts +152 -140
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "11.1.0",
3
+ "version": "11.2.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -31,7 +31,7 @@
31
31
  "winston-daily-rotate-file": "^5.0.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@types/node": "^25.2.0"
34
+ "@types/bun": "^1.3.8"
35
35
  },
36
36
  "engines": {
37
37
  "bun": ">=1.3.7"
package/src/ptree.ts CHANGED
@@ -1,125 +1,24 @@
1
1
  /**
2
2
  * Process tree management utilities for Bun subprocesses.
3
3
  *
4
- * Exposes the same public interface as the original implementation, but with
5
- * much less code:
6
4
  * - Track managed child processes for cleanup on shutdown (postmortem).
7
5
  * - Drain stdout/stderr to avoid subprocess pipe deadlocks.
8
6
  * - Cross-platform tree kill for process groups (Windows taskkill, Unix -pid).
9
7
  * - Convenience helpers: captureText / execText, AbortSignal, timeouts.
10
8
  */
11
-
12
9
  import type { Spawn, Subprocess } from "bun";
13
10
  import { terminate } from "./procmgr";
14
11
 
12
+ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
13
+
15
14
  /** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
16
15
  type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
17
16
 
18
- /** Minimal push-based ReadableStream that buffers unboundedly (like the old queue). */
19
- function pushStream<T>() {
20
- let controller!: ReadableStreamDefaultController<T>;
21
- let closed = false;
22
-
23
- const stream = new ReadableStream<T>({
24
- start(c) {
25
- controller = c;
26
- },
27
- cancel() {
28
- closed = true; // consumer no longer cares; keep draining but drop
29
- },
30
- });
31
-
32
- return {
33
- stream,
34
- push(value: T) {
35
- if (closed) return;
36
- try {
37
- controller.enqueue(value);
38
- } catch {
39
- closed = true;
40
- }
41
- },
42
- close() {
43
- if (closed) return;
44
- closed = true;
45
- try {
46
- controller.close();
47
- } catch {}
48
- },
49
- };
50
- }
51
-
52
- const DONE = { done: true, value: undefined } as const;
53
-
54
- function abortRead(signal: AbortSignal) {
55
- if (signal.aborted) return Promise.resolve(DONE);
56
- const { promise, resolve } = Promise.withResolvers<typeof DONE>();
57
- signal.addEventListener("abort", () => resolve(DONE), { once: true });
58
- return promise;
59
- }
60
-
61
- /** Drain a ReadableStream into a pushStream, optionally tapping each chunk. */
62
- async function pump(
63
- src: ReadableStream<Uint8Array>,
64
- dst: ReturnType<typeof pushStream<Uint8Array>>,
65
- opts?: { signal?: AbortSignal; onChunk?: (chunk: Uint8Array) => void; onFinally?: () => void },
66
- ) {
67
- const reader = src.getReader();
68
- const stop = opts?.signal ? abortRead(opts.signal) : null;
69
-
70
- try {
71
- while (true) {
72
- const r = stop ? await Promise.race([reader.read(), stop]) : await reader.read();
73
- if (r.done) break;
74
- if (!r.value) continue;
75
- opts?.onChunk?.(r.value);
76
- dst.push(r.value);
77
- }
78
- } catch {
79
- // ignore; this module is "best effort" for streaming/cleanup
80
- } finally {
81
- try {
82
- await reader.cancel();
83
- } catch {}
84
- try {
85
- reader.releaseLock();
86
- } catch {}
87
- dst.close();
88
- opts?.onFinally?.();
89
- }
90
- }
91
-
92
- /**
93
- * Kill a child process and its descendents.
94
- * - Windows: taskkill /T, add /F on SIGKILL
95
- * - Unix: negative PID signals the process group
96
- */
97
- async function killChild(child: ChildProcess) {
98
- await terminate({ target: child.proc });
99
- }
100
-
101
- /**
102
- * Options for waiting for process exit and capturing output.
103
- */
104
- export interface WaitOptions {
105
- allowNonZero?: boolean;
106
- allowAbort?: boolean;
107
- stderr?: "full" | "buffer";
108
- }
109
-
110
- /**
111
- * Result from wait and captureText.
112
- */
113
- export interface ExecResult {
114
- stdout: string;
115
- stderr: string;
116
- exitCode: number | null;
117
- ok: boolean;
118
- exitError?: Exception;
119
- }
17
+ // ── Exceptions ───────────────────────────────────────────────────────────────
120
18
 
121
19
  /**
122
- * Base for all exceptions representing child process nonzero exit, killed, or cancellation.
20
+ * Base for all exceptions representing child process nonzero exit, killed, or
21
+ * cancellation.
123
22
  */
124
23
  export abstract class Exception extends Error {
125
24
  constructor(
@@ -133,119 +32,117 @@ export abstract class Exception extends Error {
133
32
  abstract get aborted(): boolean;
134
33
  }
135
34
 
136
- /**
137
- * Exception for nonzero exit codes (not cancellation).
138
- */
35
+ /** Exception for nonzero exit codes (not cancellation). */
139
36
  export class NonZeroExitError extends Exception {
140
37
  static readonly MAX_TRACE = 32 * 1024;
141
38
 
142
- constructor(
143
- public readonly exitCode: number,
144
- public readonly stderr: string,
145
- ) {
39
+ constructor(exitCode: number, stderr: string) {
146
40
  super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr);
147
41
  }
148
- get aborted(): boolean {
42
+ get aborted() {
149
43
  return false;
150
44
  }
151
45
  }
152
46
 
153
- /**
154
- * Exception for explicit process abortion (via signal).
155
- */
47
+ /** Exception for explicit process abortion (via signal). */
156
48
  export class AbortError extends Exception {
157
49
  constructor(
158
50
  public readonly reason: unknown,
159
51
  stderr: string,
160
52
  ) {
161
- const reasonString = reason instanceof Error ? reason.message : String(reason ?? "aborted");
162
- super(`Operation cancelled: ${reasonString}`, -1, stderr);
53
+ const msg = reason instanceof Error ? reason.message : String(reason ?? "aborted");
54
+ super(`Operation cancelled: ${msg}`, -1, stderr);
163
55
  }
164
- get aborted(): boolean {
56
+ get aborted() {
165
57
  return true;
166
58
  }
167
59
  }
168
60
 
169
- /**
170
- * Exception for process timeout.
171
- */
61
+ /** Exception for process timeout. */
172
62
  export class TimeoutError extends AbortError {
173
63
  constructor(timeout: number, stderr: string) {
174
64
  super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
175
65
  }
176
66
  }
177
67
 
178
- type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
68
+ // ── Wait / Exec types ────────────────────────────────────────────────────────
69
+
70
+ /** Options for waiting for process exit and capturing output. */
71
+ export interface WaitOptions {
72
+ allowNonZero?: boolean;
73
+ allowAbort?: boolean;
74
+ stderr?: "full" | "buffer";
75
+ }
76
+
77
+ /** Result from wait and exec. */
78
+ export interface ExecResult {
79
+ stdout: string;
80
+ stderr: string;
81
+ exitCode: number | null;
82
+ ok: boolean;
83
+ exitError?: Exception;
84
+ }
85
+
86
+ // ── ChildProcess ─────────────────────────────────────────────────────────────
179
87
 
180
88
  /**
181
89
  * ChildProcess wraps a managed subprocess, capturing stderr tail, providing
182
90
  * cross-platform kill/detach logic plus AbortSignal integration.
91
+ *
92
+ * Stdout is exposed directly from the underlying Bun subprocess; consumers
93
+ * must read it (via text(), wait(), etc.) to prevent pipe deadlock.
94
+ * Stderr is eagerly drained into an internal buffer.
183
95
  */
184
96
  export class ChildProcess<In extends InMask = InMask> {
185
97
  #nothrow = false;
186
-
187
- #stderrBuffer = "";
98
+ #stderrTail = "";
99
+ #stderrChunks: Uint8Array[] = [];
188
100
  #exitReason?: Exception;
189
101
  #exitReasonPending?: Exception;
190
-
191
- #stop = new AbortController();
192
-
193
- #stdoutOut = pushStream<Uint8Array>();
194
- #stderrOut = pushStream<Uint8Array>();
195
-
196
102
  #stderrDone: Promise<void>;
197
103
  #exited: Promise<number>;
104
+ #stderrStream?: ReadableStream<Uint8Array>;
198
105
 
199
- constructor(public readonly proc: PipedSubprocess<In>) {
200
- const { promise: stderrDone, resolve: resolveStderrDone } = Promise.withResolvers<void>();
201
- this.#stderrDone = stderrDone;
202
-
203
- // Drain stdout always -> expose our buffered stream to the user.
204
- void pump(proc.stdout, this.#stdoutOut, { signal: this.#stop.signal }).catch(() => this.#stdoutOut.close());
205
-
206
- // Drain stderr always -> expose stream + keep a bounded tail buffer.
207
- const decoder = new TextDecoder();
106
+ constructor(
107
+ public readonly proc: PipedSubprocess<In>,
108
+ readonly exposeStderr: boolean,
109
+ ) {
110
+ // Eagerly drain stderr into a truncated tail string + raw chunks.
111
+ const dec = new TextDecoder();
208
112
  const trim = () => {
209
- if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
210
- this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
211
- }
113
+ if (this.#stderrTail.length > NonZeroExitError.MAX_TRACE)
114
+ this.#stderrTail = this.#stderrTail.slice(-NonZeroExitError.MAX_TRACE);
212
115
  };
213
- void pump(proc.stderr, this.#stderrOut, {
214
- signal: this.#stop.signal,
215
- onChunk: chunk => {
216
- this.#stderrBuffer += decoder.decode(chunk, { stream: true });
217
- trim();
218
- },
219
- onFinally: () => {
220
- this.#stderrBuffer += decoder.decode();
221
- trim();
222
- resolveStderrDone();
223
- },
224
- }).catch(() => {
116
+ let stderrStream = proc.stderr;
117
+ if (exposeStderr) {
118
+ const [teeStream, drainStream] = stderrStream.tee();
119
+ this.#stderrStream = teeStream;
120
+ stderrStream = drainStream;
121
+ }
122
+ this.#stderrDone = (async () => {
225
123
  try {
226
- this.#stderrBuffer += decoder.decode();
227
- trim();
124
+ for await (const chunk of stderrStream) {
125
+ this.#stderrChunks.push(chunk);
126
+ this.#stderrTail += dec.decode(chunk, { stream: true });
127
+ trim();
128
+ }
228
129
  } catch {}
229
- this.#stderrOut.close();
230
- resolveStderrDone();
231
- });
130
+ this.#stderrTail += dec.decode();
131
+ trim();
132
+ })();
232
133
 
134
+ // Normalize Bun's exited promise into our exitReason / exitedCleanly model.
233
135
  const { promise, resolve, reject } = Promise.withResolvers<number>();
234
136
  this.#exited = promise;
235
137
 
236
- // Normalize Bun's exited promise into our "exitReason / exitedCleanly" model.
237
138
  proc.exited
238
139
  .catch(() => null)
239
140
  .then(async exitCode => {
240
- // Stop pumping streams - process has exited, no more data coming
241
- this.#stop.abort();
242
-
243
141
  if (this.#exitReasonPending) {
244
142
  this.#exitReason = this.#exitReasonPending;
245
143
  reject(this.#exitReasonPending);
246
144
  return;
247
145
  }
248
-
249
146
  if (exitCode === 0) {
250
147
  resolve(0);
251
148
  return;
@@ -254,54 +151,61 @@ export class ChildProcess<In extends InMask = InMask> {
254
151
  await this.#stderrDone;
255
152
 
256
153
  if (exitCode !== null) {
257
- this.#exitReason = new NonZeroExitError(exitCode, this.#stderrBuffer);
154
+ this.#exitReason = new NonZeroExitError(exitCode, this.#stderrTail);
258
155
  resolve(exitCode);
259
156
  return;
260
157
  }
261
158
 
262
159
  const ex = this.proc.killed
263
- ? new AbortError(new Error("process killed"), this.#stderrBuffer)
264
- : new NonZeroExitError(-1, this.#stderrBuffer);
265
-
160
+ ? new AbortError(new Error("process killed"), this.#stderrTail)
161
+ : new NonZeroExitError(-1, this.#stderrTail);
266
162
  this.#exitReason = ex;
267
163
  reject(ex);
268
164
  });
269
165
  }
270
166
 
271
- get pid(): number | undefined {
167
+ // ── Properties ───────────────────────────────────────────────────────
168
+
169
+ get pid() {
272
170
  return this.proc.pid;
273
171
  }
274
- get exited(): Promise<number> {
172
+ get exited() {
275
173
  return this.#exited;
276
174
  }
277
- get exitedCleanly(): Promise<number> {
278
- if (this.#nothrow) return this.exited;
279
- return this.exited.then(code => {
280
- if (code !== 0) throw new NonZeroExitError(code, this.#stderrBuffer);
281
- return code;
282
- });
283
- }
284
- get exitCode(): number | null {
175
+ get exitCode() {
285
176
  return this.proc.exitCode;
286
177
  }
287
- get exitReason(): Exception | undefined {
178
+ get exitReason() {
288
179
  return this.#exitReason;
289
180
  }
290
- get killed(): boolean {
181
+ get killed() {
291
182
  return this.proc.killed;
292
183
  }
293
184
  get stdin(): Bun.SpawnOptions.WritableToIO<In> {
294
185
  return this.proc.stdin;
295
186
  }
296
- get stdout(): ReadableStream<Uint8Array> {
297
- return this.#stdoutOut.stream;
187
+
188
+ /** Raw stdout stream. Must be consumed to prevent pipe deadlock. */
189
+ get stdout() {
190
+ return this.proc.stdout;
191
+ }
192
+
193
+ /** Optional stderr stream (only when requested in spawn options). */
194
+ get stderr() {
195
+ return this.#stderrStream;
298
196
  }
299
- get stderr(): ReadableStream<Uint8Array> {
300
- return this.#stderrOut.stream;
197
+
198
+ get exitedCleanly(): Promise<number> {
199
+ if (this.#nothrow) return this.#exited;
200
+ return this.#exited.then(code => {
201
+ if (code !== 0) throw new NonZeroExitError(code, this.#stderrTail);
202
+ return code;
203
+ });
301
204
  }
302
205
 
303
- peekStderr(): string {
304
- return this.#stderrBuffer;
206
+ /** Returns the truncated stderr tail (last 32KB). */
207
+ peekStderr() {
208
+ return this.#stderrTail;
305
209
  }
306
210
 
307
211
  nothrow(): this {
@@ -311,120 +215,116 @@ export class ChildProcess<In extends InMask = InMask> {
311
215
 
312
216
  kill(reason?: Exception) {
313
217
  if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason;
314
- this.#stop.abort();
315
- if (this.proc.killed) return;
316
- void killChild(this);
218
+ if (!this.proc.killed) void terminate({ target: this.proc });
219
+ }
220
+
221
+ // ── Output helpers ───────────────────────────────────────────────────
222
+
223
+ async text(): Promise<string> {
224
+ const p = new Response(this.stdout).text();
225
+ if (this.#nothrow) return p;
226
+ const [text] = await Promise.all([p, this.exitedCleanly]);
227
+ return text;
317
228
  }
318
229
 
319
- // Output helpers
320
230
  async blob(): Promise<Blob> {
321
- const blobPromise = new Response(this.stdout).blob();
322
- if (this.#nothrow) return await blobPromise;
323
- const [blob] = await Promise.all([blobPromise, this.exitedCleanly]);
231
+ const p = new Response(this.stdout).blob();
232
+ if (this.#nothrow) return p;
233
+ const [blob] = await Promise.all([p, this.exitedCleanly]);
324
234
  return blob;
325
235
  }
326
- async text(): Promise<string> {
327
- return (await this.blob()).text();
328
- }
236
+
329
237
  async json(): Promise<unknown> {
330
- return await new Response(await this.blob()).json();
238
+ return new Response(this.stdout).json();
331
239
  }
240
+
332
241
  async arrayBuffer(): Promise<ArrayBuffer> {
333
- return (await this.blob()).arrayBuffer();
242
+ return new Response(this.stdout).arrayBuffer();
334
243
  }
244
+
335
245
  async bytes(): Promise<Uint8Array> {
336
- return new Uint8Array(await this.arrayBuffer());
246
+ return new Response(this.stdout).bytes();
337
247
  }
338
248
 
339
- async wait(options?: WaitOptions): Promise<ExecResult> {
340
- const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = options ?? {};
249
+ // ── Wait ─────────────────────────────────────────────────────────────
341
250
 
342
- const stdoutPromise = new Response(this.stdout).text();
343
- const stderrPromise =
251
+ async wait(opts?: WaitOptions): Promise<ExecResult> {
252
+ const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = opts ?? {};
253
+
254
+ const stdoutP = new Response(this.stdout).text();
255
+ const stderrP =
344
256
  stderrMode === "full"
345
- ? new Response(this.stderr).text()
346
- : (async () => {
347
- await Promise.allSettled([stdoutPromise, this.exited, this.#stderrDone]);
348
- return this.peekStderr();
349
- })();
257
+ ? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(this.#stderrChunks)))
258
+ : this.#stderrDone.then(() => this.#stderrTail);
350
259
 
351
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
260
+ const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
352
261
 
353
262
  let exitError: Exception | undefined;
354
263
  try {
355
- await this.exited;
264
+ await this.#exited;
356
265
  } catch (err) {
357
266
  if (err instanceof Exception) exitError = err;
358
267
  else throw err;
359
268
  }
360
269
 
270
+ if (!exitError) exitError = this.exitReason;
271
+ if (!exitError && this.exitCode !== null && this.exitCode !== 0) {
272
+ exitError = new NonZeroExitError(this.exitCode, this.#stderrTail);
273
+ }
274
+
361
275
  const exitCode = this.exitCode ?? (exitError && !exitError.aborted ? exitError.exitCode : null);
362
276
  const ok = exitCode === 0;
363
277
 
364
278
  if (exitError) {
365
- if ((exitError.aborted && !allowAbort) || (!exitError.aborted && !allowNonZero)) {
366
- throw exitError;
367
- }
279
+ if ((exitError.aborted && !allowAbort) || (!exitError.aborted && !allowNonZero)) throw exitError;
368
280
  }
369
281
 
370
282
  return { stdout, stderr, exitCode, ok, exitError };
371
283
  }
372
284
 
285
+ // ── Signal / timeout ─────────────────────────────────────────────────
286
+
373
287
  attachSignal(signal: AbortSignal): void {
374
288
  const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
375
289
  if (signal.aborted) return void onAbort();
376
-
377
290
  signal.addEventListener("abort", onAbort, { once: true });
378
- this.#exited
379
- .catch(() => {})
380
- .finally(() => {
381
- signal.removeEventListener("abort", onAbort);
382
- });
383
- }
384
-
385
- attachTimeout(timeout: number): void {
386
- if (timeout <= 0 || this.proc.killed) return;
387
- void (async () => {
388
- const timedOut = await Promise.race([
389
- Bun.sleep(timeout).then(() => true),
390
- this.proc.exited.then(
391
- () => false,
392
- () => false,
393
- ),
394
- ]);
395
- if (timedOut) this.kill(new TimeoutError(timeout, this.#stderrBuffer));
396
- })();
291
+ this.#exited.catch(() => {}).finally(() => signal.removeEventListener("abort", onAbort));
292
+ }
293
+
294
+ attachTimeout(ms: number): void {
295
+ if (ms <= 0 || this.proc.killed) return;
296
+ Promise.race([
297
+ Bun.sleep(ms).then(() => true),
298
+ this.proc.exited.then(
299
+ () => false,
300
+ () => false,
301
+ ),
302
+ ]).then(timedOut => {
303
+ if (timedOut) this.kill(new TimeoutError(ms, this.#stderrTail));
304
+ });
397
305
  }
398
306
 
399
307
  [Symbol.dispose](): void {
400
- // Don't kill if process already exited - avoids race where dispose runs
401
- // before the proc.exited.then() callback, causing spurious AbortError
402
308
  if (this.proc.exitCode !== null) return;
403
- this.kill(new AbortError("process disposed", this.#stderrBuffer));
309
+ this.kill(new AbortError("process disposed", this.#stderrTail));
404
310
  }
405
311
  }
406
312
 
407
- /**
408
- * Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
409
- */
313
+ // ── Spawn / exec ─────────────────────────────────────────────────────────────
314
+
315
+ /** Options for child spawn. Always pipes stdout/stderr. */
410
316
  type ChildSpawnOptions<In extends InMask = InMask> = Omit<
411
317
  Spawn.SpawnOptions<In, "pipe", "pipe">,
412
318
  "stdout" | "stderr" | "detached"
413
319
  > & {
414
- /** AbortSignal to cancel the process */
415
320
  signal?: AbortSignal;
416
- /** Whether to detach the process */
417
321
  detached?: boolean;
322
+ stderr?: "full" | null;
418
323
  };
419
324
 
420
- /**
421
- * Spawn a child process.
422
- * @param cmd - The command to spawn.
423
- * @param options - The options for the spawn.
424
- * @returns A ChildProcess instance.
425
- */
426
- export function spawn<In extends InMask = InMask>(cmd: string[], options?: ChildSpawnOptions<In>): ChildProcess<In> {
427
- const { timeout = -1, signal, ...rest } = options ?? {};
325
+ /** Spawn a child process with piped stdout/stderr. */
326
+ export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpawnOptions<In>): ChildProcess<In> {
327
+ const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
428
328
  const child = Bun.spawn(cmd, {
429
329
  stdin: "ignore",
430
330
  stdout: "pipe",
@@ -432,55 +332,55 @@ export function spawn<In extends InMask = InMask>(cmd: string[], options?: Child
432
332
  windowsHide: true,
433
333
  ...rest,
434
334
  });
435
- const cproc = new ChildProcess(child);
436
- if (signal) cproc.attachSignal(signal);
437
- if (timeout > 0) cproc.attachTimeout(timeout);
438
- return cproc;
335
+ const cp = new ChildProcess(child, stderr === "full");
336
+ if (signal) cp.attachSignal(signal);
337
+ if (timeout > 0) cp.attachTimeout(timeout);
338
+ return cp;
439
339
  }
440
340
 
441
- /**
442
- * Options for execText.
443
- */
444
- export interface ExecOptions extends Omit<ChildSpawnOptions, "stdin">, WaitOptions {
341
+ /** Options for exec. */
342
+ export interface ExecOptions extends Omit<ChildSpawnOptions, "stderr" | "stdin">, WaitOptions {
445
343
  input?: string | Buffer | Uint8Array;
446
344
  }
447
345
 
448
- export async function exec(cmd: string[], options?: ExecOptions): Promise<ExecResult> {
449
- const { input, stderr, allowAbort, allowNonZero, ...spawnOptions } = options ?? {};
346
+ /** Spawn, wait, and return captured output. */
347
+ export async function exec(cmd: string[], opts?: ExecOptions): Promise<ExecResult> {
348
+ const { input, stderr, allowAbort, allowNonZero, ...spawnOpts } = opts ?? {};
450
349
  const stdin = typeof input === "string" ? Buffer.from(input) : input;
451
- const resolvedOptions: ChildSpawnOptions = stdin === undefined ? spawnOptions : { ...spawnOptions, stdin };
452
- using child = spawn(cmd, resolvedOptions);
453
- return await child.wait({ stderr, allowAbort, allowNonZero });
350
+ const resolved: ChildSpawnOptions = stdin === undefined ? spawnOpts : { ...spawnOpts, stdin };
351
+ using child = spawn(cmd, resolved);
352
+ return child.wait({ stderr, allowAbort, allowNonZero });
454
353
  }
455
354
 
355
+ // ── Signal combinators ───────────────────────────────────────────────────────
356
+
456
357
  type SignalValue = AbortSignal | number | null | undefined;
457
358
 
359
+ /** Combine AbortSignals and timeout values into a single signal. */
458
360
  export function combineSignals(...signals: SignalValue[]): AbortSignal | undefined {
459
361
  let timeout: number | undefined;
362
+
460
363
  let n = 0;
461
364
  for (let i = 0; i < signals.length; i++) {
462
365
  const s = signals[i];
463
366
  if (s instanceof AbortSignal) {
464
367
  if (s.aborted) return s;
465
- signals[n++] = s;
368
+ if (i !== n) signals[n] = s;
369
+ n++;
466
370
  } else if (typeof s === "number" && s > 0) {
467
- timeout = Math.min(timeout ?? s, s);
371
+ timeout = timeout === undefined ? s : Math.min(timeout, s);
468
372
  }
469
373
  }
470
-
471
- // Create timeout signal.
472
374
  if (timeout !== undefined) {
473
- signals[n++] = AbortSignal.timeout(timeout);
375
+ signals[n] = AbortSignal.timeout(timeout);
376
+ n++;
474
377
  }
475
-
476
- // Single signal, ezpz.
477
- const rawSignals = signals.slice(0, n) as AbortSignal[];
478
- switch (rawSignals.length) {
378
+ switch (n) {
479
379
  case 0:
480
380
  return undefined;
481
381
  case 1:
482
- return rawSignals[0];
382
+ return signals[0] as AbortSignal;
483
383
  default:
484
- return AbortSignal.any(rawSignals);
384
+ return AbortSignal.any(signals.slice(0, n) as AbortSignal[]);
485
385
  }
486
386
  }
package/src/stream.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { ArrayBufferSink } from "bun";
2
+
1
3
  /**
2
4
  * Sanitize binary output for display/storage.
3
5
  * Removes characters that crash string-width or cause display issues:
@@ -43,27 +45,57 @@ export function sanitizeText(text: string): string {
43
45
  /**
44
46
  * Create a transform stream that splits lines.
45
47
  */
46
- export function createSplitterStream(delimiter: string): TransformStream<string, string> {
47
- let buf = "";
48
- return new TransformStream<string, string>({
49
- transform(chunk, controller) {
50
- buf = buf ? `${buf}${chunk}` : chunk;
51
-
52
- while (true) {
53
- const nl = buf.indexOf(delimiter);
54
- if (nl === -1) break;
55
- controller.enqueue(buf.slice(0, nl));
56
- buf = buf.slice(nl + delimiter.length);
48
+ export function createSplitterStream<T>(options: {
49
+ newLine?: boolean;
50
+ mapFn: (chunk: Uint8Array) => T;
51
+ }): TransformStream<Uint8Array, T> {
52
+ const { newLine = false, mapFn } = options;
53
+ const LF = 0x0a;
54
+ const sink = new Bun.ArrayBufferSink();
55
+ sink.start({ asUint8Array: true, stream: true, highWaterMark: 4096 });
56
+ let pending = false; // whether the sink has unflushed data
57
+
58
+ return new TransformStream<Uint8Array, T>({
59
+ transform(chunk, ctrl) {
60
+ let pos = 0;
61
+
62
+ while (pos < chunk.length) {
63
+ const nl = chunk.indexOf(LF, pos);
64
+ if (nl === -1) {
65
+ sink.write(chunk.subarray(pos));
66
+ pending = true;
67
+ break;
68
+ }
69
+
70
+ const slice = chunk.subarray(pos, newLine ? nl + 1 : nl);
71
+
72
+ if (pending) {
73
+ if (slice.length > 0) sink.write(slice);
74
+ ctrl.enqueue(mapFn(sink.flush() as Uint8Array));
75
+ pending = false;
76
+ } else {
77
+ ctrl.enqueue(mapFn(slice));
78
+ }
79
+ pos = nl + 1;
57
80
  }
58
81
  },
59
- flush(controller) {
60
- if (buf) {
61
- controller.enqueue(buf);
82
+ flush(ctrl) {
83
+ if (pending) {
84
+ const tail = sink.end() as Uint8Array;
85
+ if (tail.length > 0) ctrl.enqueue(mapFn(tail));
62
86
  }
63
87
  },
64
88
  });
65
89
  }
66
90
 
91
+ export function createTextLineSplitter(sanitize = false): TransformStream<Uint8Array, string> {
92
+ const dec = new TextDecoder("utf-8", { ignoreBOM: true, fatal: true });
93
+ if (sanitize) {
94
+ return createSplitterStream({ mapFn: chunk => sanitizeText(dec.decode(chunk)) });
95
+ }
96
+ return createSplitterStream({ mapFn: dec.decode.bind(dec) });
97
+ }
98
+
67
99
  /**
68
100
  * Create a transform stream that sanitizes text.
69
101
  */
@@ -82,152 +114,132 @@ export function createTextDecoderStream(): TransformStream<Uint8Array, string> {
82
114
  return new TextDecoderStream() as TransformStream<Uint8Array, string>;
83
115
  }
84
116
 
85
- /**
86
- * Read stream line-by-line
87
- *
88
- * @param delimiter Line delimiter (default: "\n")
89
- */
90
- export function readLines(stream: ReadableStream<Uint8Array>, delimiter = "\n"): AsyncIterable<string> {
91
- return stream.pipeThrough(createTextDecoderStream()).pipeThrough(createSplitterStream(delimiter));
92
- }
93
-
94
117
  // =============================================================================
95
118
  // SSE (Server-Sent Events)
96
119
  // =============================================================================
97
120
 
98
- /**
99
- * Parsed SSE event.
100
- */
101
- export interface SseEvent {
102
- /** Event type (from `event:` field, default: "message") */
103
- event: string;
104
- /** Event data (from `data:` field(s), joined with newlines) */
105
- data: string;
106
- /** Event ID (from `id:` field) */
107
- id?: string;
108
- /** Retry interval in ms (from `retry:` field) */
109
- retry?: number;
110
- }
121
+ const LF = 0x0a;
122
+ const CR = 0x0d;
123
+ const SPACE = 0x20;
111
124
 
112
- /**
113
- * Parse a single SSE event block (lines between blank lines).
114
- * Returns null if the block contains no data.
115
- */
116
- export function parseSseEvent(block: string): SseEvent | null {
117
- const lines = block.split("\n");
118
- let event = "message";
119
- const dataLines: string[] = [];
120
- let id: string | undefined;
121
- let retry: number | undefined;
122
-
123
- for (const line of lines) {
124
- // Comments start with ':'
125
- if (line.startsWith(":")) continue;
126
-
127
- const colonIdx = line.indexOf(":");
128
- if (colonIdx === -1) continue;
129
-
130
- const field = line.slice(0, colonIdx);
131
- // Value starts after colon, with optional leading space trimmed
132
- let value = line.slice(colonIdx + 1);
133
- if (value.startsWith(" ")) value = value.slice(1);
134
-
135
- switch (field) {
136
- case "event":
137
- event = value;
138
- break;
139
- case "data":
140
- dataLines.push(value);
141
- break;
142
- case "id":
143
- id = value;
144
- break;
145
- case "retry": {
146
- const n = parseInt(value, 10);
147
- if (!Number.isNaN(n)) retry = n;
148
- break;
149
- }
150
- }
151
- }
125
+ // "data:" = [0x64, 0x61, 0x74, 0x61, 0x3a]
126
+ const DATA_0 = 0x64; // d
127
+ const DATA_1 = 0x61; // a
128
+ const DATA_2 = 0x74; // t
129
+ const DATA_3 = 0x61; // a
130
+ const DATA_4 = 0x3a; // :
152
131
 
153
- if (dataLines.length === 0) return null;
132
+ // "[DONE]" = [0x5b, 0x44, 0x4f, 0x4e, 0x45, 0x5d]
133
+ const DONE = Uint8Array.from([0x5b, 0x44, 0x4f, 0x4e, 0x45, 0x5d]);
154
134
 
155
- return {
156
- event,
157
- data: dataLines.join("\n"),
158
- id,
159
- retry,
160
- };
135
+ function isDone(buf: Uint8Array, start: number, end: number): boolean {
136
+ if (end - start !== 6) return false;
137
+ for (let i = 0; i < 6; i++) {
138
+ if (buf[start + i] !== DONE[i]) return false;
139
+ }
140
+ return true;
161
141
  }
162
142
 
163
143
  /**
164
- * Read SSE events from a stream.
165
- *
166
- * Handles the SSE wire format:
167
- * - Events separated by blank lines
168
- * - Fields: event, data, id, retry
169
- * - Comments (lines starting with :) are ignored
170
- * - Multiple data: lines are joined with newlines
144
+ * Stream parsed JSON objects from SSE `data:` lines.
171
145
  *
172
146
  * @example
173
147
  * ```ts
174
- * for await (const event of readSseEvents(response.body)) {
175
- * if (event.data === "[DONE]") break;
176
- * const payload = JSON.parse(event.data);
177
- * console.log(event.event, payload);
148
+ * for await (const obj of readSseJson(response.body!)) {
149
+ * console.log(obj);
178
150
  * }
179
151
  * ```
180
152
  */
181
- export async function* readSseEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent, void, undefined> {
182
- const blockLines: string[] = [];
183
-
184
- for await (const rawLine of readLines(stream)) {
185
- const line = rawLine.replace(/\r$/, "");
186
- if (line === "") {
187
- if (blockLines.length > 0) {
188
- const event = parseSseEvent(blockLines.join("\n"));
189
- if (event) yield event;
190
- blockLines.length = 0;
153
+ export async function* readSseJson<T>(
154
+ stream: ReadableStream<Uint8Array>,
155
+ abortSignal?: AbortSignal,
156
+ ): AsyncGenerator<T> {
157
+ const sink = new ArrayBufferSink();
158
+ sink.start({ asUint8Array: true, stream: true, highWaterMark: 4096 });
159
+ let pending = false;
160
+
161
+ // pipeThrough with { signal } makes the stream abort-aware: the pipe
162
+ // cancels the source and errors the output when the signal fires,
163
+ // so for-await-of exits cleanly without manual reader/listener management.
164
+ const source = abortSignal ? stream.pipeThrough(new TransformStream(), { signal: abortSignal }) : stream;
165
+
166
+ try {
167
+ for await (const chunk of source) {
168
+ let pos = 0;
169
+ while (pos < chunk.length) {
170
+ const nl = chunk.indexOf(LF, pos);
171
+ if (nl === -1) {
172
+ sink.write(chunk.subarray(pos));
173
+ pending = true;
174
+ break;
175
+ }
176
+
177
+ let line: Uint8Array;
178
+ if (pending) {
179
+ if (nl > pos) sink.write(chunk.subarray(pos, nl));
180
+ line = sink.flush() as Uint8Array;
181
+ pending = false;
182
+ } else {
183
+ line = chunk.subarray(pos, nl);
184
+ }
185
+ pos = nl + 1;
186
+
187
+ // Strip trailing CR, skip blank/short lines.
188
+ const len = line.length > 0 && line[line.length - 1] === CR ? line.length - 1 : line.length;
189
+ if (len < 6) continue; // "data:" + at least 1 byte
190
+
191
+ // Check "data:" prefix.
192
+ if (
193
+ line[0] !== DATA_0 ||
194
+ line[1] !== DATA_1 ||
195
+ line[2] !== DATA_2 ||
196
+ line[3] !== DATA_3 ||
197
+ line[4] !== DATA_4
198
+ )
199
+ continue;
200
+
201
+ // Payload start — skip optional space after colon.
202
+ const pStart = line[5] === SPACE ? 6 : 5;
203
+ if (pStart >= len) continue;
204
+ if (isDone(line, pStart, len)) return;
205
+
206
+ // Build payload + \n for JSONL.parse.
207
+ const pLen = len - pStart;
208
+ const buf = new Uint8Array(pLen + 1);
209
+ buf.set(line.subarray(pStart, len));
210
+ buf[pLen] = LF;
211
+
212
+ const [parsed] = Bun.JSONL.parse(buf);
213
+ if (parsed !== undefined) yield parsed as T;
191
214
  }
192
- continue;
193
215
  }
194
-
195
- blockLines.push(line);
216
+ } catch (err) {
217
+ // Abort errors are expected — just stop the generator.
218
+ if (abortSignal?.aborted) return;
219
+ throw err;
196
220
  }
197
221
 
198
- if (blockLines.length > 0) {
199
- const event = parseSseEvent(blockLines.join("\n"));
200
- if (event) yield event;
201
- }
202
- }
203
-
204
- /**
205
- * Read SSE data payloads from a stream, parsing JSON automatically.
206
- *
207
- * Convenience wrapper over readSseEvents that:
208
- * - Skips [DONE] markers
209
- * - Parses JSON data
210
- * - Optionally filters by event type
211
- *
212
- * @example
213
- * ```ts
214
- * for await (const data of readSseData<ChatChunk>(response.body)) {
215
- * console.log(data.choices[0].delta);
216
- * }
217
- * ```
218
- */
219
- export async function* readSseData<T = unknown>(
220
- stream: ReadableStream<Uint8Array>,
221
- eventType?: string,
222
- ): AsyncGenerator<T, void, undefined> {
223
- for await (const event of readSseEvents(stream)) {
224
- if (eventType && event.event !== eventType) continue;
225
- if (event.data === "[DONE]") continue;
226
-
227
- try {
228
- yield JSON.parse(event.data) as T;
229
- } catch {
230
- // Skip malformed JSON
222
+ // Trailing line without final newline.
223
+ if (pending) {
224
+ const tail = sink.end() as Uint8Array;
225
+ const len = tail.length > 0 && tail[tail.length - 1] === CR ? tail.length - 1 : tail.length;
226
+ if (
227
+ len >= 6 &&
228
+ tail[0] === DATA_0 &&
229
+ tail[1] === DATA_1 &&
230
+ tail[2] === DATA_2 &&
231
+ tail[3] === DATA_3 &&
232
+ tail[4] === DATA_4
233
+ ) {
234
+ const pStart = tail[5] === SPACE ? 6 : 5;
235
+ if (pStart < len && !isDone(tail, pStart, len)) {
236
+ const pLen = len - pStart;
237
+ const buf = new Uint8Array(pLen + 1);
238
+ buf.set(tail.subarray(pStart, len));
239
+ buf[pLen] = LF;
240
+ const [parsed] = Bun.JSONL.parse(buf);
241
+ if (parsed !== undefined) yield parsed as T;
242
+ }
231
243
  }
232
244
  }
233
245
  }