@gajae-code/utils 0.11.6 → 0.11.8

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.
@@ -66,3 +66,7 @@ export declare function isBunTestRuntime(): boolean;
66
66
  */
67
67
  export declare function isCompiledBinary(): boolean;
68
68
  export declare function $flag(name: string, def?: boolean): boolean;
69
+ /** Resolve the first flag among keys that has a set value (GJC-first, PI fallback). Matches $flag semantics per key. */
70
+ export declare function $pickflag(...keys: string[]): boolean;
71
+ /** Resolve the first positive integer among keys, else defaultValue (GJC-first). Set-but-invalid keys are skipped. */
72
+ export declare function $pickenvpos(keys: string[], defaultValue: number): number;
@@ -17,7 +17,11 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
17
17
  * ```
18
18
  */
19
19
  export type SseEventObserver = (event: ServerSentEvent) => void;
20
- export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T>;
20
+ export interface SseReadOptions {
21
+ maxEventBytes?: number;
22
+ maxTotalBytes?: number;
23
+ }
24
+ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver, options?: SseReadOptions): AsyncGenerator<T>;
21
25
  /**
22
26
  * A single Server-Sent Event dispatched on a blank-line boundary.
23
27
  *
@@ -53,7 +57,7 @@ export interface ServerSentEvent {
53
57
  * }
54
58
  * ```
55
59
  */
56
- export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<ServerSentEvent>;
60
+ export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, options?: SseReadOptions): AsyncGenerator<ServerSentEvent>;
57
61
  /**
58
62
  * Parse a complete JSONL string, skipping malformed lines instead of throwing.
59
63
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.11.6",
4
+ "version": "0.11.8",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.11.6",
34
+ "@gajae-code/natives": "0.11.8",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/env.ts CHANGED
@@ -279,3 +279,23 @@ export function $flag(name: string, def: boolean = false): boolean {
279
279
  // would silently read as false while only `FOO=TRUE`/`FOO=1` worked.
280
280
  return TRUTHY[value.toUpperCase()] === true;
281
281
  }
282
+
283
+ /** Resolve the first flag among keys that has a set value (GJC-first, PI fallback). Matches $flag semantics per key. */
284
+ export function $pickflag(...keys: string[]): boolean {
285
+ for (const key of keys) {
286
+ const value = $env[key]?.trim();
287
+ if (value) return TRUTHY[value.toUpperCase()] === true;
288
+ }
289
+ return false;
290
+ }
291
+
292
+ /** Resolve the first positive integer among keys, else defaultValue (GJC-first). Set-but-invalid keys are skipped. */
293
+ export function $pickenvpos(keys: string[], defaultValue: number): number {
294
+ for (const key of keys) {
295
+ const raw = $env[key]?.trim();
296
+ if (!raw) continue;
297
+ const parsed = Number.parseInt(raw, 10);
298
+ if (!Number.isNaN(parsed) && parsed > 0) return parsed;
299
+ }
300
+ return defaultValue;
301
+ }
package/src/format.ts CHANGED
@@ -9,7 +9,9 @@ const DAY = 24 * HOUR;
9
9
  */
10
10
  export function formatDuration(ms: number): string {
11
11
  if (ms < SEC) return `${ms}ms`;
12
- if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
12
+ // Truncate below 60.0s instead of rounding up into the next unit (the minute
13
+ // branch below), mirroring roundBelow/formatByteUnit: e.g. 59_999ms -> "59.9s".
14
+ if (ms < MIN) return `${(roundBelow(ms / 100, 600) / 10).toFixed(1)}s`;
13
15
  if (ms < HOUR) {
14
16
  const mins = Math.floor(ms / MIN);
15
17
  const secs = Math.floor((ms % MIN) / SEC);
@@ -59,13 +61,21 @@ export function formatBytes(bytes: number): string {
59
61
  if (bytes < 1024) return `${bytes}B`;
60
62
  if (bytes < 1024 * 1024) return `${formatByteUnit(bytes, 1024)}KB`;
61
63
  if (bytes < 1024 * 1024 * 1024) return `${formatByteUnit(bytes, 1024 * 1024)}MB`;
62
- return `${formatByteUnit(bytes, 1024 * 1024 * 1024)}GB`;
64
+ // The GB branch is terminal (no larger unit), so it must not clamp below the
65
+ // next-unit boundary the way KB/MB do; clamping here reports every value >=
66
+ // 1 TiB as "1023.9GB" (e.g. 2 TiB -> "1023.9GB" instead of "2048.0GB").
67
+ return `${formatByteUnit(bytes, 1024 * 1024 * 1024, false)}GB`;
63
68
  }
64
69
 
65
- /** Format bytes to 1 decimal without rounding up into the next byte unit. */
66
- function formatByteUnit(bytes: number, unit: number): string {
67
- const tenths = Math.min(Math.round((bytes / unit) * 10), 1024 * 10 - 1);
68
- return (tenths / 10).toFixed(1);
70
+ /**
71
+ * Format bytes to 1 decimal. Intermediate units (KB/MB) clamp just below the
72
+ * next-unit boundary so a value like 1023.95 KB never rounds up to "1024.0KB";
73
+ * the terminal GB unit passes `clampToNextUnit: false` because it has no next
74
+ * unit to protect against.
75
+ */
76
+ function formatByteUnit(bytes: number, unit: number, clampToNextUnit = true): string {
77
+ const tenths = Math.round((bytes / unit) * 10);
78
+ return ((clampToNextUnit ? Math.min(tenths, 1024 * 10 - 1) : tenths) / 10).toFixed(1);
69
79
  }
70
80
 
71
81
  /**
@@ -124,18 +124,30 @@ export function parseFrontmatter(
124
124
  const loc = location ?? source;
125
125
  const frontmatter: Record<string, unknown> = { ...fallback };
126
126
 
127
- const normalized = normalize ? stripHtmlComments(content.replace(/\r\n?/g, "\n")) : content;
128
- if (!normalized.startsWith("---")) {
127
+ // Normalize away a leading UTF-8 BOM and CRLF line endings before matching so
128
+ // a BOM-prefixed but otherwise valid document is still recognized as
129
+ // frontmatter (BOM-prefixed Markdown is common on Windows-authored files).
130
+ const normalized = normalize ? stripHtmlComments(content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")) : content;
131
+ // A frontmatter block opens with a line that is exactly `---` (trailing
132
+ // spaces/tabs allowed). A bare `----` banner or a `--- text` heading is not
133
+ // an opener, so a document without frontmatter keeps its body intact rather
134
+ // than having content silently consumed by the fixed-offset slicing below.
135
+ const open = normalized.match(/^---[ \t]*(?:\n|$)/);
136
+ if (!open) {
129
137
  return { frontmatter, body: normalized };
130
138
  }
131
139
 
132
- const endIndex = normalized.indexOf("\n---", 3);
133
- if (endIndex === -1) {
140
+ // The block closes at the next line that is exactly `---`. Searching from the
141
+ // end of the opening `---` (offset 3, not the whole opener) keeps an empty
142
+ // block (`---\n---`) matching as empty frontmatter.
143
+ const afterOpen = normalized.slice(3);
144
+ const close = afterOpen.match(/\n---[ \t]*(?:\n|$)/);
145
+ if (!close || close.index === undefined) {
134
146
  return { frontmatter, body: normalized };
135
147
  }
136
148
 
137
- const metadata = normalized.slice(4, endIndex);
138
- const body = normalized.slice(endIndex + 4).trim();
149
+ const metadata = afterOpen.slice(open[0].length - 3, close.index);
150
+ const body = afterOpen.slice(close.index + close[0].length).trim();
139
151
 
140
152
  try {
141
153
  // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
package/src/logger.ts CHANGED
@@ -410,7 +410,10 @@ function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]):
410
410
  }
411
411
  const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
412
412
  lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
413
- const showAll = process.env.PI_TIMING === "full";
413
+ // Resolve GJC-first with PI fallback inline (no ./env import) so this
414
+ // foundational logger stays off the env module's dependency graph, which the
415
+ // tab-worker native-free runtime contract (issue-2598-repro) walks.
416
+ const showAll = (process.env.GJC_TIMING?.trim() || process.env.PI_TIMING?.trim()) === "full";
414
417
  const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
415
418
  const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
416
419
  for (const span of visible) {
package/src/stream.ts CHANGED
@@ -125,16 +125,22 @@ class ConcatSink {
125
125
  this.#length = 0;
126
126
  }
127
127
 
128
- *appendAndFlushLines(chunk: Uint8Array) {
128
+ *appendAndFlushLines(chunk: Uint8Array, maxLineBytes?: () => number) {
129
129
  let pos = 0;
130
130
  while (pos < chunk.length) {
131
131
  const nl = chunk.indexOf(LF, pos);
132
132
  if (nl === -1) {
133
+ if (maxLineBytes && this.#length + chunk.length - pos > maxLineBytes()) {
134
+ throw new Error("SSE event exceeds size limit");
135
+ }
133
136
  this.append(chunk.subarray(pos));
134
137
  return;
135
138
  }
136
139
  const suffix = chunk.subarray(pos, nl);
137
140
  pos = nl + 1;
141
+ if (maxLineBytes && this.#length + suffix.length + 1 > maxLineBytes()) {
142
+ throw new Error("SSE event exceeds size limit");
143
+ }
138
144
  if (this.isEmpty) {
139
145
  yield suffix;
140
146
  } else {
@@ -201,6 +207,11 @@ class ConcatSink {
201
207
  */
202
208
  export type SseEventObserver = (event: ServerSentEvent) => void;
203
209
 
210
+ export interface SseReadOptions {
211
+ maxEventBytes?: number;
212
+ maxTotalBytes?: number;
213
+ }
214
+
204
215
  function notifySseEventObserver(observer: SseEventObserver | undefined, event: ServerSentEvent): void {
205
216
  if (!observer) return;
206
217
  try {
@@ -214,8 +225,9 @@ export async function* readSseJson<T>(
214
225
  stream: ReadableStream<Uint8Array>,
215
226
  signal?: AbortSignal,
216
227
  onEvent?: SseEventObserver,
228
+ options?: SseReadOptions,
217
229
  ): AsyncGenerator<T> {
218
- for await (const sse of readSseEvents(stream, signal)) {
230
+ for await (const sse of readSseEvents(stream, signal, options)) {
219
231
  notifySseEventObserver(onEvent, sse);
220
232
  const data = sse.data;
221
233
  if (data === "" || data === "[DONE]") {
@@ -336,14 +348,26 @@ function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent |
336
348
  export async function* readSseEvents(
337
349
  stream: ReadableStream<Uint8Array>,
338
350
  signal?: AbortSignal,
351
+ options?: SseReadOptions,
339
352
  ): AsyncGenerator<ServerSentEvent> {
340
353
  const lineBuffer = new ConcatSink();
341
354
  const state: SseEventState = { event: null, data: null, raw: [] };
342
355
  const source = createAbortableStream(stream, signal);
356
+ let eventBytes = 0;
357
+ let totalBytes = 0;
343
358
  try {
344
359
  for await (const chunk of source) {
345
- for (const line of lineBuffer.appendAndFlushLines(chunk)) {
360
+ totalBytes += chunk.length;
361
+ if (options?.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) {
362
+ throw new Error("SSE stream exceeds size limit");
363
+ }
364
+ for (const line of lineBuffer.appendAndFlushLines(
365
+ chunk,
366
+ () => (options?.maxEventBytes ?? Infinity) - eventBytes,
367
+ )) {
368
+ eventBytes += line.length + 1;
346
369
  const event = pushSseLine(line, state);
370
+ if (line.length === 0 || (line.length === 1 && line[0] === 0x0d)) eventBytes = 0;
347
371
  if (event) yield event;
348
372
  }
349
373
  }