@oh-my-pi/pi-utils 18.2.0 → 18.2.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/src/stream.ts CHANGED
@@ -57,11 +57,16 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
57
57
  }
58
58
  }
59
59
 
60
- // =============================================================================
61
- // SSE (Server-Sent Events)
62
- // =============================================================================
63
-
64
- class ConcatSink {
60
+ /**
61
+ * Amortized byte accumulator for chunked stream readers.
62
+ *
63
+ * Holds the unconsumed tail of a stream in a single growing `Buffer` so that
64
+ * appending N chunks costs O(total bytes) instead of re-copying the whole
65
+ * prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
66
+ * {@link readSseEvents}; also usable directly when a reader needs its own
67
+ * framing loop (see `consume` and `flush`).
68
+ */
69
+ export class ConcatSink {
65
70
  #space?: Buffer;
66
71
  #length = 0;
67
72
  #skipLeadingLf = false;
@@ -102,11 +107,26 @@ class ConcatSink {
102
107
  return this.#length === 0;
103
108
  }
104
109
 
110
+ /**
111
+ * The buffered bytes as a live view — invalidated by the next `append`,
112
+ * `reset` or `consume`.
113
+ */
105
114
  flush(): Uint8Array | undefined {
106
115
  if (!this.#length) return undefined;
107
116
  return this.#space!.subarray(0, this.#length);
108
117
  }
109
118
 
119
+ /** Drop the first `count` buffered bytes, keeping the remainder. */
120
+ consume(count: number) {
121
+ if (count <= 0) return;
122
+ if (count >= this.#length) {
123
+ this.#length = 0;
124
+ return;
125
+ }
126
+ this.#space!.copyWithin(0, count, this.#length);
127
+ this.#length -= count;
128
+ }
129
+
110
130
  clear() {
111
131
  this.#length = 0;
112
132
  }
@@ -207,6 +227,10 @@ class ConcatSink {
207
227
  }
208
228
  }
209
229
 
230
+ // =============================================================================
231
+ // SSE (Server-Sent Events)
232
+ // =============================================================================
233
+
210
234
  /**
211
235
  * Stream parsed JSON objects from SSE `data:` lines.
212
236
  *
@@ -246,11 +270,24 @@ function isRecoverableTrailingJson(data: string): boolean {
246
270
  return typeof recovered === "object" && recovered !== null;
247
271
  }
248
272
 
249
- export async function* readSseJson<T>(
273
+ /**
274
+ * One dispatched `data:` frame from {@link readSseFrames}: either the parsed JSON
275
+ * value, or the text of a frame `JSON.parse` rejected together with the
276
+ * `SyntaxError` it raised (so the strict reader can rethrow it unchanged).
277
+ */
278
+ type SseFrame<T> = { ok: true; value: T } | { ok: false; raw: string; error: SyntaxError };
279
+
280
+ /**
281
+ * Shared `data:`-line framing for {@link readSseJson} and
282
+ * {@link readSseJsonOrText}: skips empty events, stops at the OpenAI `[DONE]`
283
+ * sentinel, notifies the diagnostic observer, and treats a container-shaped
284
+ * stream tail as a clean end of iteration.
285
+ */
286
+ async function* readSseFrames<T>(
250
287
  stream: ReadableStream<Uint8Array>,
251
288
  signal?: AbortSignal,
252
289
  onEvent?: SseEventObserver,
253
- ): AsyncGenerator<T> {
290
+ ): AsyncGenerator<SseFrame<T>> {
254
291
  for await (const sse of readSseEvents(stream, signal)) {
255
292
  const isTrailing = trailingEvents.has(sse);
256
293
  notifySseEventObserver(onEvent, sse);
@@ -260,16 +297,60 @@ export async function* readSseJson<T>(
260
297
  continue;
261
298
  }
262
299
  try {
263
- yield JSON.parse(data) as T;
300
+ yield { ok: true, value: JSON.parse(data) as T };
264
301
  } catch (err) {
265
302
  if (err instanceof SyntaxError && isTrailing && isRecoverableTrailingJson(data)) {
266
303
  return;
267
304
  }
305
+ if (err instanceof SyntaxError) {
306
+ yield { ok: false, raw: data, error: err };
307
+ continue;
308
+ }
268
309
  throw err;
269
310
  }
270
311
  }
271
312
  }
272
313
 
314
+ export async function* readSseJson<T>(
315
+ stream: ReadableStream<Uint8Array>,
316
+ signal?: AbortSignal,
317
+ onEvent?: SseEventObserver,
318
+ ): AsyncGenerator<T> {
319
+ for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
320
+ if (!frame.ok) throw frame.error;
321
+ yield frame.value;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
327
+ * as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
328
+ * stream tails stay recoverable, exactly as they are in {@link readSseJson}.
329
+ *
330
+ * Consumers that only understand objects must treat a `string` yield as a
331
+ * transport-level failure (for example a `429 Too Many Requests` or an HTML
332
+ * throttle page from a reverse proxy that already committed to the stream). This
333
+ * exists because `readSseJson`'s baseline consumers span unrelated transports
334
+ * whose error handling a text yield would subtly change; new call sites opt in.
335
+ *
336
+ * Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
337
+ * carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
338
+ * is yielded as that string and is indistinguishable from a rejected frame by
339
+ * type alone. Consumers branching on `typeof === "string"` therefore see both,
340
+ * which is the safe direction — each is classified as text rather than trusted as
341
+ * an event object.
342
+ */
343
+ export async function* readSseJsonOrText<T>(
344
+ stream: ReadableStream<Uint8Array>,
345
+ signal?: AbortSignal,
346
+ onEvent?: SseEventObserver,
347
+ ): AsyncGenerator<T | string> {
348
+ for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
349
+ if (!frame.ok) yield frame.raw;
350
+ else yield frame.value;
351
+ }
352
+ }
353
+
273
354
  /**
274
355
  * A single Server-Sent Event dispatched on a blank-line boundary.
275
356
  *
package/src/which.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
+ import { isFullyQualifiedPath } from "./path";
14
15
 
15
16
  type CacheKey = string | bigint | number;
16
17
 
@@ -179,6 +180,12 @@ export interface WhichOptions extends Bun.WhichOptions {
179
180
  * Defaults to `WhichCachePolicy.Fresh`.
180
181
  */
181
182
  cache?: WhichCachePolicy;
183
+ /**
184
+ * Only search absolute directory entries in PATH, ignoring relative entries
185
+ * (e.g. `.` or `./bin`) and empty components to prevent resolving against
186
+ * an untrusted working directory.
187
+ */
188
+ requireAbsolutePaths?: boolean;
182
189
  }
183
190
 
184
191
  // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
@@ -192,6 +199,15 @@ function darwinWhich(command: string, options?: Bun.WhichOptions): string | null
192
199
  return null;
193
200
  }
194
201
 
202
+ function filterAbsoluteSearchPath(rawPath: string | undefined): string | null {
203
+ if (!rawPath) return null;
204
+ const safePath = rawPath
205
+ .split(path.delimiter)
206
+ .filter(dir => dir.length > 0 && isFullyQualifiedPath(dir))
207
+ .join(path.delimiter);
208
+ return safePath || null;
209
+ }
210
+
195
211
  // Which function that incorporates Darwin Xcode logic if platform reports as 'darwin'.
196
212
  // Look `Bun.which` up per call rather than capturing it at import, so a `Bun.which`
197
213
  // stub installed later (the per-test seam) is honoured on every platform.
@@ -219,8 +235,15 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
219
235
  */
220
236
  export function $which(command: string, options?: WhichOptions): string | null {
221
237
  const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
222
- const lookupOptions =
238
+ let lookupOptions =
223
239
  options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH };
240
+
241
+ if (options?.requireAbsolutePaths) {
242
+ const safePath = filterAbsoluteSearchPath(lookupOptions?.PATH);
243
+ if (!safePath) return null;
244
+ lookupOptions = { ...lookupOptions, PATH: safePath };
245
+ }
246
+
224
247
  let key: CacheKey | undefined;
225
248
 
226
249
  if (cachePolicy !== WhichCachePolicy.Bypass) {
@@ -232,6 +255,9 @@ export function $which(command: string, options?: WhichOptions): string | null {
232
255
  }
233
256
 
234
257
  const result = whichFresh(command, lookupOptions);
258
+ if (result && options?.requireAbsolutePaths && !isFullyQualifiedPath(result)) {
259
+ return null;
260
+ }
235
261
  if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
236
262
  toolCache.set(key, result);
237
263
  }
package/src/xml.ts CHANGED
@@ -104,6 +104,12 @@ class XmlReader {
104
104
  this.#readProcessingInstruction(document);
105
105
  continue;
106
106
  }
107
+ if (this.#xml.startsWith("</", this.#position)) {
108
+ // Stray end tag with no element open: skip it instead of parsing it as a new element,
109
+ // whose empty name would leave #readElement stuck on the `/`.
110
+ this.#skipThrough(">");
111
+ continue;
112
+ }
107
113
  if (this.#xml[this.#position] === "<") {
108
114
  const element = this.#readElement("");
109
115
  this.#addValue(document, element.name, element.name, element.value, element.leaf, null);
@@ -138,6 +144,11 @@ class XmlReader {
138
144
  this.#position++;
139
145
  this.#skipWhitespace();
140
146
  value = this.#readAttributeValue();
147
+ } else if (attributeName === "") {
148
+ // Markup #readName cannot consume (the stray `/` in `<a / >`): skip the character
149
+ // so the loop always makes progress.
150
+ this.#position++;
151
+ continue;
141
152
  }
142
153
  attributes.push([attributeName, value]);
143
154
  }
@@ -153,6 +164,11 @@ class XmlReader {
153
164
  this.#position = close < 0 ? this.#xml.length : close + 1;
154
165
  break;
155
166
  }
167
+ if (this.#xml.startsWith("</", this.#position)) {
168
+ // End tag for an ancestor (an unclosed `<br>` inside `<p>`, or plain mismatched
169
+ // markup): close this element implicitly and leave the tag to its owner.
170
+ break;
171
+ }
156
172
  if (this.#xml.startsWith("<![CDATA[", this.#position)) {
157
173
  const end = this.#xml.indexOf("]]>", this.#position + 9);
158
174
  const raw = this.#xml.slice(this.#position + 9, end < 0 ? this.#xml.length : end);