@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.
- package/package.json +2 -2
- package/src/ptree.ts +175 -275
- 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.
|
|
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/
|
|
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
|
-
|
|
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
|
|
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()
|
|
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
|
|
162
|
-
super(`Operation cancelled: ${
|
|
53
|
+
const msg = reason instanceof Error ? reason.message : String(reason ?? "aborted");
|
|
54
|
+
super(`Operation cancelled: ${msg}`, -1, stderr);
|
|
163
55
|
}
|
|
164
|
-
get aborted()
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
|
|
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.#
|
|
210
|
-
this.#
|
|
211
|
-
}
|
|
113
|
+
if (this.#stderrTail.length > NonZeroExitError.MAX_TRACE)
|
|
114
|
+
this.#stderrTail = this.#stderrTail.slice(-NonZeroExitError.MAX_TRACE);
|
|
212
115
|
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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.#
|
|
230
|
-
|
|
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.#
|
|
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.#
|
|
264
|
-
: new NonZeroExitError(-1, this.#
|
|
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
|
-
|
|
167
|
+
// ── Properties ───────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
get pid() {
|
|
272
170
|
return this.proc.pid;
|
|
273
171
|
}
|
|
274
|
-
get exited()
|
|
172
|
+
get exited() {
|
|
275
173
|
return this.#exited;
|
|
276
174
|
}
|
|
277
|
-
get
|
|
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()
|
|
178
|
+
get exitReason() {
|
|
288
179
|
return this.#exitReason;
|
|
289
180
|
}
|
|
290
|
-
get killed()
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
322
|
-
if (this.#nothrow) return
|
|
323
|
-
const [blob] = await Promise.all([
|
|
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
|
-
|
|
327
|
-
return (await this.blob()).text();
|
|
328
|
-
}
|
|
236
|
+
|
|
329
237
|
async json(): Promise<unknown> {
|
|
330
|
-
return
|
|
238
|
+
return new Response(this.stdout).json();
|
|
331
239
|
}
|
|
240
|
+
|
|
332
241
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
333
|
-
return (
|
|
242
|
+
return new Response(this.stdout).arrayBuffer();
|
|
334
243
|
}
|
|
244
|
+
|
|
335
245
|
async bytes(): Promise<Uint8Array> {
|
|
336
|
-
return new
|
|
246
|
+
return new Response(this.stdout).bytes();
|
|
337
247
|
}
|
|
338
248
|
|
|
339
|
-
|
|
340
|
-
const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = options ?? {};
|
|
249
|
+
// ── Wait ─────────────────────────────────────────────────────────────
|
|
341
250
|
|
|
342
|
-
|
|
343
|
-
const
|
|
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
|
|
346
|
-
: (
|
|
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([
|
|
260
|
+
const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
|
|
352
261
|
|
|
353
262
|
let exitError: Exception | undefined;
|
|
354
263
|
try {
|
|
355
|
-
await this
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.#
|
|
309
|
+
this.kill(new AbortError("process disposed", this.#stderrTail));
|
|
404
310
|
}
|
|
405
311
|
}
|
|
406
312
|
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
|
436
|
-
if (signal)
|
|
437
|
-
if (timeout > 0)
|
|
438
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
|
452
|
-
using child = spawn(cmd,
|
|
453
|
-
return
|
|
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
|
|
368
|
+
if (i !== n) signals[n] = s;
|
|
369
|
+
n++;
|
|
466
370
|
} else if (typeof s === "number" && s > 0) {
|
|
467
|
-
timeout = Math.min(timeout
|
|
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
|
|
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
|
|
382
|
+
return signals[0] as AbortSignal;
|
|
483
383
|
default:
|
|
484
|
-
return AbortSignal.any(
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
60
|
-
if (
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
132
|
+
// "[DONE]" = [0x5b, 0x44, 0x4f, 0x4e, 0x45, 0x5d]
|
|
133
|
+
const DONE = Uint8Array.from([0x5b, 0x44, 0x4f, 0x4e, 0x45, 0x5d]);
|
|
154
134
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
*
|
|
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
|
|
175
|
-
*
|
|
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*
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
}
|