@gajae-code/utils 0.12.19 → 0.12.21

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.
@@ -1,5 +1,6 @@
1
1
  export declare function readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
2
- export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T>;
2
+ export type JsonlLineObserver = (raw: string) => void;
3
+ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onLine?: JsonlLineObserver): AsyncGenerator<T>;
3
4
  /**
4
5
  * Stream parsed JSON objects from SSE `data:` lines.
5
6
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.12.19",
4
+ "version": "0.12.21",
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.12.19",
34
+ "@gajae-code/natives": "0.12.21",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/postmortem.ts CHANGED
@@ -190,8 +190,197 @@ function installProcessStdoutWriteClassifier(): void {
190
190
  process.stdout.write = markedWrite as typeof process.stdout.write;
191
191
  }
192
192
 
193
- function errorForDiagnostic(reason: unknown): Error {
194
- return reason instanceof Error ? reason : new Error(String(reason));
193
+ const UNREADABLE_FIELD = "[unreadable]";
194
+ const UNREADABLE_THROWABLE = "[unreadable throwable]";
195
+ const ERROR_FIELDS = ["name", "message", "stack"] as const;
196
+ type ErrorField = (typeof ERROR_FIELDS)[number];
197
+ const CRASH_CONTEXT_FIELDS = new Set([
198
+ "code",
199
+ "errno",
200
+ "syscall",
201
+ "path",
202
+ "dest",
203
+ "address",
204
+ "port",
205
+ "fd",
206
+ "status",
207
+ "statusCode",
208
+ "url",
209
+ "method",
210
+ "phase",
211
+ "reason",
212
+ "exitCode",
213
+ "stderr",
214
+ ]);
215
+ const CRASH_CONTEXT_FIELD_MAX_BYTES = 4 * 1024;
216
+ const CRASH_CONTEXT_FIELD_TRUNCATION_MARKER = "… [field truncated]";
217
+ const UNDEFINED_FIELD = "[undefined]";
218
+
219
+ type FieldRead =
220
+ | { readonly kind: "missing" }
221
+ | { readonly kind: "unreadable" }
222
+ | { readonly kind: "value"; readonly value: unknown };
223
+
224
+ interface CapturedPayload {
225
+ readonly serialized: string;
226
+ }
227
+
228
+ /** Reads one top-level field exactly once and keeps both its value and refusal state. */
229
+ function readField(reason: object, key: string, preserveUndefined = false): FieldRead {
230
+ try {
231
+ const value = (reason as Record<string, unknown>)[key];
232
+ return value === undefined && !preserveUndefined ? { kind: "missing" } : { kind: "value", value };
233
+ } catch {
234
+ return { kind: "unreadable" };
235
+ }
236
+ }
237
+
238
+ function capturedValue(field: FieldRead): unknown {
239
+ if (field.kind === "value") return field.value;
240
+ if (field.kind === "unreadable") return UNREADABLE_FIELD;
241
+ return undefined;
242
+ }
243
+
244
+ function boundedJsonString(value: string): string {
245
+ const redacted = redactCrashSecrets(value);
246
+ if (Buffer.byteLength(JSON.stringify(redacted), "utf8") <= CRASH_CONTEXT_FIELD_MAX_BYTES)
247
+ return JSON.stringify(redacted);
248
+
249
+ let low = 0;
250
+ let high = redacted.length;
251
+ while (low < high) {
252
+ const midpoint = Math.ceil((low + high) / 2);
253
+ const candidate = JSON.stringify(`${redacted.slice(0, midpoint)}${CRASH_CONTEXT_FIELD_TRUNCATION_MARKER}`);
254
+ if (Buffer.byteLength(candidate, "utf8") <= CRASH_CONTEXT_FIELD_MAX_BYTES) low = midpoint;
255
+ else high = midpoint - 1;
256
+ }
257
+ let end = low;
258
+ if (end > 0 && /[\uD800-\uDBFF]/.test(redacted[end - 1] ?? "")) end--;
259
+ return JSON.stringify(`${redacted.slice(0, end)}${CRASH_CONTEXT_FIELD_TRUNCATION_MARKER}`);
260
+ }
261
+
262
+ /**
263
+ * Serializes one already-captured diagnostic value. Credential shapes are
264
+ * redacted before output, and oversized fields become a bounded string preview
265
+ * so one request body cannot evict the remaining crash context.
266
+ */
267
+ function serializeCapturedValue(value: unknown): string {
268
+ if (value === undefined) return JSON.stringify(UNDEFINED_FIELD);
269
+ if (typeof value === "string") return boundedJsonString(value);
270
+
271
+ try {
272
+ const serialized = JSON.stringify(value);
273
+ if (typeof serialized !== "string") return boundedJsonString(String(value));
274
+ const redacted = redactCrashSecrets(serialized);
275
+ if (Buffer.byteLength(redacted, "utf8") <= CRASH_CONTEXT_FIELD_MAX_BYTES) return redacted;
276
+ return boundedJsonString(redacted);
277
+ } catch {
278
+ try {
279
+ return boundedJsonString(String(value));
280
+ } catch {
281
+ return JSON.stringify(UNREADABLE_FIELD);
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Captures only stable diagnostic context. Arbitrary request objects and bodies
288
+ * are intentionally excluded: they are large, routinely contain credentials,
289
+ * and add less crash value than transport, process, and lifecycle metadata.
290
+ */
291
+ function capturePayload(reason: object): CapturedPayload | undefined {
292
+ let keys: string[];
293
+ try {
294
+ keys = Object.keys(reason);
295
+ } catch {
296
+ return undefined;
297
+ }
298
+
299
+ const properties: string[] = [];
300
+ for (const key of keys) {
301
+ if (!CRASH_CONTEXT_FIELDS.has(key)) continue;
302
+ const serialized = serializeCapturedValue(capturedValue(readField(reason, key, true)));
303
+ properties.push(`${JSON.stringify(key)}:${serialized}`);
304
+ }
305
+ return properties.length > 0 ? { serialized: `{${properties.join(",")}}` } : undefined;
306
+ }
307
+
308
+ function fieldText(field: FieldRead, fallback: string): string {
309
+ if (field.kind === "unreadable") return UNREADABLE_FIELD;
310
+ if (field.kind !== "value") return fallback;
311
+ try {
312
+ const text = typeof field.value === "string" ? field.value : String(field.value);
313
+ return text || fallback;
314
+ } catch {
315
+ return UNREADABLE_FIELD;
316
+ }
317
+ }
318
+
319
+ /** A throwable reduced to captured text; the rest of this module reads nothing else. */
320
+ interface FatalDiagnostic {
321
+ name: string;
322
+ message: string;
323
+ stack: string;
324
+ payload?: string;
325
+ }
326
+
327
+ /**
328
+ * The single read of an unknown throwable, and the only one this module has.
329
+ *
330
+ * Objects retain the named diagnostic fields independently from the optional
331
+ * allowlisted context payload. Context never replaces a readable identity,
332
+ * message, or stack.
333
+ */
334
+ function describeFatal(reason: unknown): FatalDiagnostic {
335
+ try {
336
+ if (typeof reason !== "object" || reason === null) {
337
+ let message: string;
338
+ try {
339
+ message = String(reason);
340
+ } catch {
341
+ message = UNREADABLE_THROWABLE;
342
+ }
343
+ return { name: "Error", message: message || "(no message)", stack: "" };
344
+ }
345
+
346
+ const fields: Record<ErrorField, FieldRead> = {
347
+ name: readField(reason, "name"),
348
+ message: readField(reason, "message"),
349
+ stack: readField(reason, "stack"),
350
+ };
351
+ const payload = capturePayload(reason);
352
+ const payloadIsMessage = payload !== undefined && ERROR_FIELDS.every(key => fields[key].kind === "missing");
353
+
354
+ const name = fieldText(fields.name, "Error");
355
+ const message = fieldText(fields.message, "(no message)");
356
+ let stack = "";
357
+ if (fields.stack.kind === "unreadable") {
358
+ stack = `${name}: ${message}\n${UNREADABLE_FIELD}`;
359
+ } else if (
360
+ fields.stack.kind === "value" &&
361
+ typeof fields.stack.value === "string" &&
362
+ fields.stack.value.length > 0
363
+ ) {
364
+ stack = fields.stack.value.includes("\n") ? fields.stack.value : `${name}: ${message}\n${fields.stack.value}`;
365
+ }
366
+
367
+ return {
368
+ name,
369
+ message: payloadIsMessage ? payload.serialized : message,
370
+ stack,
371
+ payload: payloadIsMessage ? undefined : payload?.serialized,
372
+ };
373
+ } catch {
374
+ return { name: "Error", message: UNREADABLE_THROWABLE, stack: "" };
375
+ }
376
+ }
377
+
378
+ /** Rebuild an `Error` for structured logging out of strings that are already safe to read. */
379
+ function fatalErrorForLog(fatal: FatalDiagnostic): Error {
380
+ const error = new Error(fatal.message);
381
+ error.name = fatal.name;
382
+ if (fatal.stack) error.stack = fatal.stack;
383
+ return error;
195
384
  }
196
385
 
197
386
  // Register signal and error event handlers to trigger cleanup before exit.
@@ -199,13 +388,13 @@ function errorForDiagnostic(reason: unknown): Error {
199
388
  // Worker thread: exit only (workers use self.addEventListener for exceptions)
200
389
  let inspectorOpened = false;
201
390
 
202
- function formatFatalError(label: string, err: Error): string {
203
- const name = err.name || "Error";
204
- const message = err.message || "(no message)";
205
- const stack = err.stack || "";
206
- const stackLines = stack.split("\n").slice(1);
391
+ function formatFatalError(label: string, fatal: FatalDiagnostic): string {
392
+ const stackLines = fatal.stack.split("\n").slice(1);
207
393
  const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
208
- return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
394
+ const formattedPayload = fatal.payload ? `\n${fatal.payload}` : "";
395
+ return boundCrashRecord(
396
+ redactCrashSecrets(`\n[${label}] ${fatal.name}: ${fatal.message}${formattedStack}${formattedPayload}\n`),
397
+ );
209
398
  }
210
399
  /** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
211
400
  export const CRASH_LOG_MAX_BYTES = 512 * 1024;
@@ -283,15 +472,24 @@ export function recordFatalCrash(
283
472
  label: string,
284
473
  reason: unknown,
285
474
  options: { path?: string; now?: Date } = {},
475
+ ): string | undefined {
476
+ return writeCrashRecord(label, describeFatal(reason), options);
477
+ }
478
+
479
+ function writeCrashRecord(
480
+ label: string,
481
+ fatal: FatalDiagnostic,
482
+ options: { path?: string; now?: Date } = {},
286
483
  ): string | undefined {
287
484
  try {
288
- const err = errorForDiagnostic(reason);
289
485
  const target = options.path ?? getCrashLogPath();
290
486
  const now = options.now ?? new Date();
487
+ const stack = fatal.stack ? `${redactCrashSecrets(fatal.stack)}\n` : "";
488
+ const payload = fatal.payload ? `${redactCrashSecrets(fatal.payload)}\n` : "";
291
489
  const report = boundCrashRecord(
292
490
  `${now.toISOString()} pid=${process.pid} [${label}] ` +
293
- `${err.name || "Error"}: ${redactCrashSecrets(err.message || "(no message)")}\n` +
294
- `${redactCrashSecrets(err.stack ?? "")}\n\n`,
491
+ `${redactCrashSecrets(fatal.name)}: ${redactCrashSecrets(fatal.message)}\n` +
492
+ `${stack}${payload}\n`,
295
493
  );
296
494
  fs.mkdirSync(path.dirname(target), { recursive: true });
297
495
  let existingSize = 0;
@@ -336,14 +534,15 @@ async function handleFatalError(label: string, reason: unknown, cleanupReason: R
336
534
  // contract, including when it arrives while quiet cleanup is still pending.
337
535
  ordinaryFatalStarted = true;
338
536
  process.exitCode = 1;
339
- const err = errorForDiagnostic(reason);
537
+ const fatal = describeFatal(reason);
340
538
  // Persist first: the rotation-immune record must land before any
341
539
  // best-effort stderr output, so a slow or failing stderr cannot cost the
342
540
  // crash record. Cleanup (which may itself hang or fail) runs afterwards.
343
- const crashLogPath = recordFatalCrash(label, err);
344
- safeStderrWrite(formatFatalError(label, err));
541
+ const crashLogPath = writeCrashRecord(label, fatal);
542
+ safeStderrWrite(formatFatalError(label, fatal));
345
543
  if (crashLogPath) safeStderrWrite(`[${label}] crash recorded at ${crashLogPath}\n`);
346
544
  if (!quietShutdownStarted) {
545
+ const err = fatalErrorForLog(fatal);
347
546
  logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
348
547
  err,
349
548
  stack: err.stack,
package/src/stream.ts CHANGED
@@ -44,13 +44,44 @@ export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: Ab
44
44
  }
45
45
  }
46
46
 
47
- export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T> {
47
+ export type JsonlLineObserver = (raw: string) => void;
48
+
49
+ function notifyJsonlLineObserver(observer: JsonlLineObserver | undefined, raw: string): void {
50
+ if (!observer) return;
51
+ try {
52
+ observer(raw);
53
+ } catch {
54
+ // Diagnostic observers must never perturb provider stream consumption.
55
+ }
56
+ }
57
+
58
+ export async function* readJsonl<T>(
59
+ stream: ReadableStream<Uint8Array>,
60
+ signal?: AbortSignal,
61
+ onLine?: JsonlLineObserver,
62
+ ): AsyncGenerator<T> {
48
63
  const buffer = new ConcatSink();
64
+ const rawLineState = onLine
65
+ ? {
66
+ buffer: new ConcatSink(),
67
+ decoder: new TextDecoder(),
68
+ }
69
+ : undefined;
49
70
  const source = createAbortableStream(stream, signal);
50
71
  try {
51
72
  for await (const chunk of source) {
73
+ if (rawLineState) {
74
+ for (const line of rawLineState.buffer.appendAndFlushLines(chunk)) {
75
+ notifyJsonlLineObserver(onLine, rawLineState.decoder.decode(line));
76
+ }
77
+ }
52
78
  yield* buffer.pullJSONL<T>(chunk, 0, chunk.length);
53
79
  }
80
+ if (rawLineState && !rawLineState.buffer.isEmpty) {
81
+ const rawTail = rawLineState.buffer.flush();
82
+ if (rawTail) notifyJsonlLineObserver(onLine, rawLineState.decoder.decode(rawTail));
83
+ rawLineState.buffer.clear();
84
+ }
54
85
  if (!buffer.isEmpty) {
55
86
  const tail = buffer.flush();
56
87
  if (tail) {