@oh-my-pi/pi-utils 18.4.2 → 18.4.3
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 +14 -0
- package/dist/types/stream.d.ts +11 -4
- package/package.json +2 -2
- package/src/postmortem.ts +7 -1
- package/src/stream.ts +26 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.3] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added an optional `onDone` callback to `readSseJsonOrText` that reports the `[DONE]` sentinel without attaching a raw-event observer ([#13650](https://github.com/can1357/oh-my-pi/pull/13650) by [@H4vC](https://github.com/H4vC)).
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- SSE events read without raw capture now share one frozen empty `raw` array instead of allocating one per event ([#13650](https://github.com/can1357/oh-my-pi/pull/13650) by [@H4vC](https://github.com/H4vC)).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed the unsettled-command report overriding an explicit non-zero exit code with 1 and printing a spurious "ended before completing" line ([#13623](https://github.com/can1357/oh-my-pi/pull/13623) by [@H4vC](https://github.com/H4vC))
|
|
18
|
+
|
|
5
19
|
## [18.4.2] - 2026-09-28
|
|
6
20
|
|
|
7
21
|
### Added
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -74,7 +74,13 @@ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signa
|
|
|
74
74
|
* which is the safe direction — each is classified as text rather than trusted as
|
|
75
75
|
* an event object.
|
|
76
76
|
*/
|
|
77
|
-
export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver
|
|
77
|
+
export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver,
|
|
78
|
+
/**
|
|
79
|
+
* Called when iteration ends on the OpenAI `[DONE]` sentinel. Lets a
|
|
80
|
+
* consumer tell a server-agreed end from a bare EOF without attaching an
|
|
81
|
+
* `onEvent` observer (which turns on per-line raw capture).
|
|
82
|
+
*/
|
|
83
|
+
onDone?: () => void): AsyncGenerator<T | string>;
|
|
78
84
|
/**
|
|
79
85
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
80
86
|
*
|
|
@@ -95,12 +101,13 @@ export interface ServerSentEvent {
|
|
|
95
101
|
* Decoded wire lines for this event (`event:`/`data:`/etc.), for the
|
|
96
102
|
* diagnostic pipeline. Populated only when the reader opts in via
|
|
97
103
|
* {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
|
|
98
|
-
* observer to the JSON readers, which opt in automatically); otherwise
|
|
99
|
-
*
|
|
104
|
+
* observer to the JSON readers, which opt in automatically); otherwise a
|
|
105
|
+
* shared, frozen empty array — copy or reassign it, never mutate it in
|
|
106
|
+
* place. Direct `readSseEvents` callers that need wire text must pass
|
|
100
107
|
* `{ captureRaw: true }` — the field is allocation-free by default so
|
|
101
108
|
* the token path pays no per-frame array/slice cost.
|
|
102
109
|
*/
|
|
103
|
-
raw: string[];
|
|
110
|
+
raw: readonly string[];
|
|
104
111
|
id?: string;
|
|
105
112
|
retry?: number;
|
|
106
113
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.4.
|
|
3
|
+
"version": "18.4.3",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.4.3"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.3.14"
|
package/src/postmortem.ts
CHANGED
|
@@ -406,8 +406,14 @@ export function reportUnsettledEntry(work: Promise<unknown>, describe?: () => st
|
|
|
406
406
|
drained = false;
|
|
407
407
|
}, 0).unref();
|
|
408
408
|
});
|
|
409
|
-
process.once("exit",
|
|
409
|
+
process.once("exit", code => {
|
|
410
410
|
if (!pending || !drained) return;
|
|
411
|
+
// A non-zero code is already a failure verdict — an explicit
|
|
412
|
+
// `process.exit(n)` (possibly on the same loop turn as a Windows
|
|
413
|
+
// mid-I/O `beforeExit`, before the clearing timer runs) or an
|
|
414
|
+
// `exitCode` the command set. Overwriting it with 1 and a second
|
|
415
|
+
// "ended before completing" line only obscures the real error.
|
|
416
|
+
if (code !== 0) return;
|
|
411
417
|
const command = describe?.();
|
|
412
418
|
const subject = command ? `\`${APP_NAME} ${command}\`` : "command";
|
|
413
419
|
const message = `${subject} ended before completing: the event loop drained while it was still pending (rerun with PI_DEBUG_STARTUP=1 to see the last phase reached)`;
|
package/src/stream.ts
CHANGED
|
@@ -292,13 +292,14 @@ type SseFrame<T> = { ok: true; value: T } | { ok: false; raw: string; error: Syn
|
|
|
292
292
|
/**
|
|
293
293
|
* Shared `data:`-line framing for {@link readSseJson} and
|
|
294
294
|
* {@link readSseJsonOrText}: skips empty events, stops at the OpenAI `[DONE]`
|
|
295
|
-
* sentinel, notifies the diagnostic observer,
|
|
296
|
-
* stream tail as a clean end of iteration.
|
|
295
|
+
* sentinel (reporting it through `onDone`), notifies the diagnostic observer,
|
|
296
|
+
* and treats a container-shaped stream tail as a clean end of iteration.
|
|
297
297
|
*/
|
|
298
298
|
async function* readSseFrames<T>(
|
|
299
299
|
stream: ReadableStream<Uint8Array>,
|
|
300
300
|
signal?: AbortSignal,
|
|
301
301
|
onEvent?: SseEventObserver,
|
|
302
|
+
onDone?: () => void,
|
|
302
303
|
): AsyncGenerator<SseFrame<T>> {
|
|
303
304
|
// The diagnostic observer is the only reader of `raw`; capture it exactly
|
|
304
305
|
// when one is attached so the hot path stays allocation-free.
|
|
@@ -307,7 +308,10 @@ async function* readSseFrames<T>(
|
|
|
307
308
|
notifySseEventObserver(onEvent, sse);
|
|
308
309
|
const data = sse.data;
|
|
309
310
|
if (data === "" || data === "[DONE]") {
|
|
310
|
-
if (data === "[DONE]")
|
|
311
|
+
if (data === "[DONE]") {
|
|
312
|
+
onDone?.();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
311
315
|
continue;
|
|
312
316
|
}
|
|
313
317
|
try {
|
|
@@ -358,8 +362,14 @@ export async function* readSseJsonOrText<T>(
|
|
|
358
362
|
stream: ReadableStream<Uint8Array>,
|
|
359
363
|
signal?: AbortSignal,
|
|
360
364
|
onEvent?: SseEventObserver,
|
|
365
|
+
/**
|
|
366
|
+
* Called when iteration ends on the OpenAI `[DONE]` sentinel. Lets a
|
|
367
|
+
* consumer tell a server-agreed end from a bare EOF without attaching an
|
|
368
|
+
* `onEvent` observer (which turns on per-line raw capture).
|
|
369
|
+
*/
|
|
370
|
+
onDone?: () => void,
|
|
361
371
|
): AsyncGenerator<T | string> {
|
|
362
|
-
for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
|
|
372
|
+
for await (const frame of readSseFrames<T>(stream, signal, onEvent, onDone)) {
|
|
363
373
|
if (!frame.ok) yield frame.raw;
|
|
364
374
|
else yield frame.value;
|
|
365
375
|
}
|
|
@@ -385,12 +395,13 @@ export interface ServerSentEvent {
|
|
|
385
395
|
* Decoded wire lines for this event (`event:`/`data:`/etc.), for the
|
|
386
396
|
* diagnostic pipeline. Populated only when the reader opts in via
|
|
387
397
|
* {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
|
|
388
|
-
* observer to the JSON readers, which opt in automatically); otherwise
|
|
389
|
-
*
|
|
398
|
+
* observer to the JSON readers, which opt in automatically); otherwise a
|
|
399
|
+
* shared, frozen empty array — copy or reassign it, never mutate it in
|
|
400
|
+
* place. Direct `readSseEvents` callers that need wire text must pass
|
|
390
401
|
* `{ captureRaw: true }` — the field is allocation-free by default so
|
|
391
402
|
* the token path pays no per-frame array/slice cost.
|
|
392
403
|
*/
|
|
393
|
-
raw: string[];
|
|
404
|
+
raw: readonly string[];
|
|
394
405
|
id?: string;
|
|
395
406
|
retry?: number;
|
|
396
407
|
}
|
|
@@ -414,6 +425,13 @@ interface SseEventState {
|
|
|
414
425
|
// an ASCII line-ending byte, which cannot split a multi-byte UTF-8 sequence.
|
|
415
426
|
const SSE_DECODER = new TextDecoder("utf-8");
|
|
416
427
|
|
|
428
|
+
/**
|
|
429
|
+
* `raw` of every event dispatched with capture off. Shared instead of one
|
|
430
|
+
* fresh array per event; frozen so an in-place mutation throws rather than
|
|
431
|
+
* leaking lines into every other event (consumers copy or reassign `raw`).
|
|
432
|
+
*/
|
|
433
|
+
const EMPTY_RAW: readonly string[] = Object.freeze<string[]>([]);
|
|
434
|
+
|
|
417
435
|
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
418
436
|
if (state.event === null && state.data === null && state.id === undefined && state.retry === undefined) {
|
|
419
437
|
if (state.raw !== null) state.raw = [];
|
|
@@ -422,7 +440,7 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
|
422
440
|
const event: ServerSentEvent = {
|
|
423
441
|
event: state.event,
|
|
424
442
|
data: state.data ?? "",
|
|
425
|
-
raw: state.raw ??
|
|
443
|
+
raw: state.raw ?? EMPTY_RAW,
|
|
426
444
|
};
|
|
427
445
|
if (state.id !== undefined) event.id = state.id;
|
|
428
446
|
if (state.retry !== undefined) event.retry = state.retry;
|