@oh-my-pi/pi-utils 6.8.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 +41 -0
- package/src/abortable.ts +86 -0
- package/src/index.ts +7 -0
- package/src/logger.ts +111 -0
- package/src/postmortem.ts +147 -0
- package/src/ptree.ts +365 -0
- package/src/stream.ts +241 -0
- package/src/temp.ts +73 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oh-my-pi/pi-utils",
|
|
3
|
+
"version": "6.8.0",
|
|
4
|
+
"description": "Shared utilities for pi packages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"check": "tsgo --noEmit",
|
|
20
|
+
"build": "tsgo -p tsconfig.build.json",
|
|
21
|
+
"test": "vitest --run"
|
|
22
|
+
},
|
|
23
|
+
"author": "Can Bölük",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
|
28
|
+
"directory": "packages/pi-utils"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"winston": "^3.17.0",
|
|
32
|
+
"winston-daily-rotate-file": "^5.0.0",
|
|
33
|
+
"strip-ansi": "^7.1.2"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.3.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/abortable.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
export class AbortError extends Error {
|
|
4
|
+
constructor(signal: AbortSignal) {
|
|
5
|
+
assert(signal.aborted, "Abort signal must be aborted");
|
|
6
|
+
|
|
7
|
+
const message = signal.reason instanceof Error ? signal.reason.message : "Cancelled";
|
|
8
|
+
super(`Aborted: ${message}`, { cause: message });
|
|
9
|
+
this.name = "AbortError";
|
|
10
|
+
this.cause = signal.reason;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sleep for a given number of milliseconds, respecting abort signal.
|
|
16
|
+
*/
|
|
17
|
+
export async function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
18
|
+
return untilAborted(signal, () => Bun.sleep(ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a deferred { promise, resolve, reject } triple which automatically rejects
|
|
23
|
+
* with { name: "AbortError" } if the given abort signal fires before resolve/reject.
|
|
24
|
+
*
|
|
25
|
+
* @param signal - Optional AbortSignal to cancel the operation
|
|
26
|
+
* @returns A deferred { promise, resolve, reject } triple
|
|
27
|
+
*/
|
|
28
|
+
export function createAbortablePromise<T>(signal?: AbortSignal): {
|
|
29
|
+
promise: Promise<T>;
|
|
30
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
31
|
+
reject: (reason?: unknown) => void;
|
|
32
|
+
} {
|
|
33
|
+
if (!signal) {
|
|
34
|
+
return Promise.withResolvers<T>();
|
|
35
|
+
} else if (signal.aborted) {
|
|
36
|
+
return { promise: Promise.reject(new AbortError(signal)), resolve: () => {}, reject: () => {} };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
40
|
+
|
|
41
|
+
const abortHandler = () => {
|
|
42
|
+
reject(new AbortError(signal));
|
|
43
|
+
};
|
|
44
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
45
|
+
promise.finally(() => {
|
|
46
|
+
signal.removeEventListener("abort", abortHandler);
|
|
47
|
+
});
|
|
48
|
+
return { promise, resolve, reject };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
|
|
53
|
+
* execution, the promise is rejected with a standard error.
|
|
54
|
+
*
|
|
55
|
+
* @param signal - Optional AbortSignal to cancel the operation
|
|
56
|
+
* @param pr - Function returning a promise to run
|
|
57
|
+
* @returns Promise resolving as `pr` would, or rejecting on abort
|
|
58
|
+
*/
|
|
59
|
+
export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
|
|
60
|
+
if (!signal) {
|
|
61
|
+
return pr();
|
|
62
|
+
} else if (signal.aborted) {
|
|
63
|
+
return Promise.reject(new AbortError(signal));
|
|
64
|
+
}
|
|
65
|
+
const { promise, resolve, reject } = createAbortablePromise<T>(signal);
|
|
66
|
+
pr().then(resolve, reject);
|
|
67
|
+
return promise;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Memoizes a function with no arguments, calling it once and caching the result.
|
|
72
|
+
*
|
|
73
|
+
* @param fn - Function to be called once
|
|
74
|
+
* @returns A function that returns the cached result of `fn`
|
|
75
|
+
*/
|
|
76
|
+
export function once<T>(fn: () => T): () => T {
|
|
77
|
+
let store = undefined as { value: T } | undefined;
|
|
78
|
+
return () => {
|
|
79
|
+
if (store) {
|
|
80
|
+
return store.value;
|
|
81
|
+
}
|
|
82
|
+
const value = fn();
|
|
83
|
+
store = { value };
|
|
84
|
+
return value;
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./abortable";
|
|
2
|
+
export * as logger from "./logger";
|
|
3
|
+
export * as postmortem from "./postmortem";
|
|
4
|
+
export * as ptree from "./ptree";
|
|
5
|
+
export { AbortError, ChildProcess, cspawn, Exception, NonZeroExitError } from "./ptree";
|
|
6
|
+
export * from "./stream";
|
|
7
|
+
export * from "./temp";
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized file logger for omp.
|
|
3
|
+
*
|
|
4
|
+
* Logs to ~/.omp/logs/ with size-based rotation, supporting concurrent omp instances.
|
|
5
|
+
* Each log entry includes process.pid for traceability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import winston from "winston";
|
|
12
|
+
import DailyRotateFile from "winston-daily-rotate-file";
|
|
13
|
+
|
|
14
|
+
/** Get the logs directory (~/.omp/logs/) */
|
|
15
|
+
function getLogsDir(): string {
|
|
16
|
+
return join(homedir(), ".omp", "logs");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Ensure logs directory exists */
|
|
20
|
+
function ensureLogsDir(): string {
|
|
21
|
+
const logsDir = getLogsDir();
|
|
22
|
+
if (!existsSync(logsDir)) {
|
|
23
|
+
mkdirSync(logsDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
return logsDir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Custom format that includes pid and flattens metadata */
|
|
29
|
+
const logFormat = winston.format.combine(
|
|
30
|
+
winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
|
|
31
|
+
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
|
32
|
+
const entry: Record<string, unknown> = {
|
|
33
|
+
timestamp,
|
|
34
|
+
level,
|
|
35
|
+
pid: process.pid,
|
|
36
|
+
message,
|
|
37
|
+
};
|
|
38
|
+
// Flatten metadata into entry
|
|
39
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
40
|
+
if (key !== "level" && key !== "timestamp" && key !== "message") {
|
|
41
|
+
entry[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return JSON.stringify(entry);
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/** Size-based rotating file transport */
|
|
49
|
+
const fileTransport = new DailyRotateFile({
|
|
50
|
+
dirname: ensureLogsDir(),
|
|
51
|
+
filename: "omp.%DATE%.log",
|
|
52
|
+
datePattern: "YYYY-MM-DD",
|
|
53
|
+
maxSize: "10m",
|
|
54
|
+
maxFiles: 5,
|
|
55
|
+
zippedArchive: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The winston logger instance */
|
|
59
|
+
const winstonLogger = winston.createLogger({
|
|
60
|
+
level: "debug",
|
|
61
|
+
format: logFormat,
|
|
62
|
+
transports: [fileTransport],
|
|
63
|
+
// Don't exit on error - logging failures shouldn't crash the app
|
|
64
|
+
exitOnError: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** Logger type exposed to plugins and internal code */
|
|
68
|
+
export interface Logger {
|
|
69
|
+
error(message: string, context?: Record<string, unknown>): void;
|
|
70
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
71
|
+
debug(message: string, context?: Record<string, unknown>): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Centralized logger for omp.
|
|
76
|
+
*
|
|
77
|
+
* Logs to ~/.omp/logs/omp.YYYY-MM-DD.log with size-based rotation.
|
|
78
|
+
* Safe for concurrent access from multiple omp instances.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* import { logger } from "@oh-my-pi/pi-utils";
|
|
83
|
+
*
|
|
84
|
+
* logger.error("MCP request failed", { url, method });
|
|
85
|
+
* logger.warn("Theme file invalid, using fallback", { path });
|
|
86
|
+
* logger.debug("LSP fallback triggered", { reason });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function error(message: string, context?: Record<string, unknown>): void {
|
|
90
|
+
try {
|
|
91
|
+
winstonLogger.error(message, context);
|
|
92
|
+
} catch {
|
|
93
|
+
// Silently ignore logging failures
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function warn(message: string, context?: Record<string, unknown>): void {
|
|
98
|
+
try {
|
|
99
|
+
winstonLogger.warn(message, context);
|
|
100
|
+
} catch {
|
|
101
|
+
// Silently ignore logging failures
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function debug(message: string, context?: Record<string, unknown>): void {
|
|
106
|
+
try {
|
|
107
|
+
winstonLogger.debug(message, context);
|
|
108
|
+
} catch {
|
|
109
|
+
// Silently ignore logging failures
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup and postmortem handler utilities.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a system for registering and running cleanup callbacks
|
|
5
|
+
* in response to process exit, signals, or fatal exceptions. It is intended to
|
|
6
|
+
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { logger } from ".";
|
|
10
|
+
|
|
11
|
+
// Cleanup reasons, in order of priority/meaning.
|
|
12
|
+
export enum Reason {
|
|
13
|
+
PRE_EXIT = "pre_exit", // Pre-exit phase (not used by default)
|
|
14
|
+
EXIT = "exit", // Normal process exit
|
|
15
|
+
SIGINT = "sigint", // Ctrl-C or SIGINT
|
|
16
|
+
SIGTERM = "sigterm", // SIGTERM
|
|
17
|
+
SIGHUP = "sighup", // SIGHUP
|
|
18
|
+
UNCAUGHT_EXCEPTION = "uncaught_exception", // Fatal exception
|
|
19
|
+
UNHANDLED_REJECTION = "unhandled_rejection", // Unhandled promise rejection
|
|
20
|
+
MANUAL = "manual", // Manual cleanup (not triggered by process)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Internal list of active cleanup callbacks (in registration order)
|
|
24
|
+
const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
|
|
25
|
+
// Tracks cleanup run state (to prevent recursion/reentry issues)
|
|
26
|
+
let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal: runs all registered cleanup callbacks for the given reason.
|
|
30
|
+
* Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
|
|
31
|
+
*
|
|
32
|
+
* Returns a Promise that settles after all cleanups complete or error out.
|
|
33
|
+
*/
|
|
34
|
+
function runCleanup(reason: Reason): Promise<void> {
|
|
35
|
+
switch (cleanupStage) {
|
|
36
|
+
case "idle":
|
|
37
|
+
cleanupStage = "running";
|
|
38
|
+
break;
|
|
39
|
+
case "running":
|
|
40
|
+
logger.error("Cleanup invoked recursively", { stack: new Error().stack });
|
|
41
|
+
return Promise.resolve();
|
|
42
|
+
case "complete":
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Call .cleanup() for each callback that is still "armed".
|
|
47
|
+
// Use Promise.try to handle sync/async, but only those armed.
|
|
48
|
+
const promises = callbackList.reverse().map((callback) => {
|
|
49
|
+
return Promise.try(() => callback(reason));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return Promise.allSettled(promises).then((results) => {
|
|
53
|
+
for (const result of results) {
|
|
54
|
+
if (result.status === "rejected") {
|
|
55
|
+
const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
|
|
56
|
+
logger.error("Cleanup callback failed", { err, stack: err.stack });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
cleanupStage = "complete";
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Register signal and error event handlers to trigger cleanup before exit.
|
|
64
|
+
process
|
|
65
|
+
.on("SIGINT", async () => {
|
|
66
|
+
await runCleanup(Reason.SIGINT);
|
|
67
|
+
process.exit(130); // 128 + SIGINT (2)
|
|
68
|
+
})
|
|
69
|
+
.on("uncaughtException", async (err) => {
|
|
70
|
+
logger.error("Uncaught exception", { err, stack: err.stack });
|
|
71
|
+
await runCleanup(Reason.UNCAUGHT_EXCEPTION);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
})
|
|
74
|
+
.on("unhandledRejection", async (reason) => {
|
|
75
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
76
|
+
logger.error("Unhandled rejection", { err, stack: err.stack });
|
|
77
|
+
await runCleanup(Reason.UNHANDLED_REJECTION);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
})
|
|
80
|
+
.on("exit", async () => {
|
|
81
|
+
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
82
|
+
})
|
|
83
|
+
.on("SIGTERM", async () => {
|
|
84
|
+
await runCleanup(Reason.SIGTERM);
|
|
85
|
+
process.exit(143); // 128 + SIGTERM (15)
|
|
86
|
+
})
|
|
87
|
+
.on("SIGHUP", async () => {
|
|
88
|
+
await runCleanup(Reason.SIGHUP);
|
|
89
|
+
process.exit(129); // 128 + SIGHUP (1)
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
|
|
94
|
+
*
|
|
95
|
+
* Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
|
|
96
|
+
* If register is called after cleanup already began, invokes callback on a microtask.
|
|
97
|
+
*/
|
|
98
|
+
export function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void {
|
|
99
|
+
let done = false;
|
|
100
|
+
const exec = (reason: Reason) => {
|
|
101
|
+
if (done) return;
|
|
102
|
+
done = true;
|
|
103
|
+
try {
|
|
104
|
+
return callback(reason);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
107
|
+
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const cancel = () => {
|
|
112
|
+
const index = callbackList.indexOf(exec);
|
|
113
|
+
if (index >= 0) {
|
|
114
|
+
callbackList.splice(index, 1);
|
|
115
|
+
}
|
|
116
|
+
done = true;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (cleanupStage !== "idle") {
|
|
120
|
+
// If cleanup is already running/completed, warn and run on microtask.
|
|
121
|
+
logger.warn("Cleanup invoked recursively", { id });
|
|
122
|
+
try {
|
|
123
|
+
callback(Reason.MANUAL);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
126
|
+
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
127
|
+
}
|
|
128
|
+
return () => {};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Register callback as "armed" (active).
|
|
132
|
+
callbackList.push(exec);
|
|
133
|
+
return cancel;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Runs all cleanup callbacks and exits the process.
|
|
138
|
+
*/
|
|
139
|
+
export async function quit(code: number = 0): Promise<void> {
|
|
140
|
+
await runCleanup(Reason.MANUAL);
|
|
141
|
+
if (process.stdout.writableLength > 0) {
|
|
142
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
143
|
+
process.stdout.once("drain", resolve);
|
|
144
|
+
await Promise.race([promise, Bun.sleep(5000)]);
|
|
145
|
+
}
|
|
146
|
+
process.exit(code);
|
|
147
|
+
}
|
package/src/ptree.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process tree management utilities for Bun subprocesses.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Managed tracking of child subprocesses for cleanup on exit/signals.
|
|
6
|
+
* - Windows and Unix support for proper tree killing.
|
|
7
|
+
* - ChildProcess wrapper for capturing output, errors, and kill/detach.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type FileSink, type Spawn, type Subprocess, spawn, spawnSync } from "bun";
|
|
11
|
+
import { postmortem } from ".";
|
|
12
|
+
|
|
13
|
+
// Platform detection: process tree kill behavior differs.
|
|
14
|
+
const isWindows = process.platform === "win32";
|
|
15
|
+
|
|
16
|
+
// Set of live children for managed termination/cleanup on shutdown.
|
|
17
|
+
const managedChildren = new Set<PipedSubprocess>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Kill a child process and its descendents.
|
|
21
|
+
* - Windows: uses taskkill for tree and forceful kill (/T /F)
|
|
22
|
+
* - Unix: negative PID sends signal to process group (tree kill)
|
|
23
|
+
*/
|
|
24
|
+
function killChild(child: PipedSubprocess, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
25
|
+
const pid = child.pid;
|
|
26
|
+
if (!pid) return;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (isWindows) {
|
|
30
|
+
// /T (tree), /F (force): ensure entire tree is killed.
|
|
31
|
+
spawnSync(["taskkill", ...(signal === "SIGKILL" ? ["/F"] : []), "/T", "/PID", pid.toString()], {
|
|
32
|
+
stdout: "ignore",
|
|
33
|
+
stderr: "ignore",
|
|
34
|
+
timeout: 1000,
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
// Send signal to process group (negative PID).
|
|
38
|
+
process.kill(-pid, signal);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If killed, remove from managed set and clean up.
|
|
42
|
+
if (child.killed) {
|
|
43
|
+
managedChildren.delete(child);
|
|
44
|
+
child.unref();
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore: process may already be dead.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
postmortem.register("managed-children", () => {
|
|
52
|
+
for (const child of [...managedChildren]) {
|
|
53
|
+
killChild(child, "SIGKILL");
|
|
54
|
+
managedChildren.delete(child);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a subprocess for managed cleanup.
|
|
60
|
+
* Will attach to exit Promise so removal happens even if child exits "naturally".
|
|
61
|
+
*/
|
|
62
|
+
function registerManaged(child: PipedSubprocess): void {
|
|
63
|
+
if (child.exitCode !== null) return;
|
|
64
|
+
if (managedChildren.has(child)) return;
|
|
65
|
+
child.ref();
|
|
66
|
+
managedChildren.add(child);
|
|
67
|
+
|
|
68
|
+
child.exited.then(() => {
|
|
69
|
+
managedChildren.delete(child);
|
|
70
|
+
child.unref();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A Bun subprocess with stdin=Writable, stdout/stderr=pipe (for tracking/cleanup).
|
|
75
|
+
type PipedSubprocess = Subprocess<"pipe" | null, "pipe", "pipe">;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ChildProcess wraps a managed subprocess, capturing output, errors, and providing
|
|
79
|
+
* cross-platform kill/detach logic plus AbortSignal integration.
|
|
80
|
+
*/
|
|
81
|
+
export class ChildProcess {
|
|
82
|
+
#proc: PipedSubprocess;
|
|
83
|
+
#detached = false;
|
|
84
|
+
#nothrow = false;
|
|
85
|
+
#stderrTee: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
86
|
+
#stderrBuffer = "";
|
|
87
|
+
#exitReason?: Exception;
|
|
88
|
+
#exitReasonPending?: Exception;
|
|
89
|
+
#exited: Promise<void>;
|
|
90
|
+
#resolveExited: (ex?: PromiseLike<Exception> | Exception) => void;
|
|
91
|
+
|
|
92
|
+
constructor(proc: PipedSubprocess) {
|
|
93
|
+
registerManaged(proc);
|
|
94
|
+
|
|
95
|
+
const [left, right] = proc.stderr.tee();
|
|
96
|
+
this.#stderrTee = right;
|
|
97
|
+
|
|
98
|
+
// Capture stderr at all times, with a capped buffer for errors.
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
void (async () => {
|
|
101
|
+
for await (const chunk of left) {
|
|
102
|
+
this.#stderrBuffer += decoder.decode(chunk, { stream: true });
|
|
103
|
+
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
104
|
+
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.#stderrBuffer += decoder.decode();
|
|
108
|
+
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
109
|
+
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
110
|
+
}
|
|
111
|
+
})().catch(() => {});
|
|
112
|
+
|
|
113
|
+
const { promise, resolve } = Promise.withResolvers<Exception | undefined>();
|
|
114
|
+
|
|
115
|
+
this.#exited = promise.then((ex?: Exception) => {
|
|
116
|
+
if (!ex) return; // success, no exception
|
|
117
|
+
if (proc.killed && this.#exitReasonPending) {
|
|
118
|
+
ex = this.#exitReasonPending; // propagate reason if killed
|
|
119
|
+
}
|
|
120
|
+
this.#exitReason = ex;
|
|
121
|
+
return Promise.reject(ex);
|
|
122
|
+
});
|
|
123
|
+
this.#resolveExited = resolve;
|
|
124
|
+
|
|
125
|
+
// On exit, resolve with a ChildError if nonzero code.
|
|
126
|
+
proc.exited.then((exitCode) => {
|
|
127
|
+
if (exitCode !== 0) {
|
|
128
|
+
resolve(new NonZeroExitError(exitCode, this.#stderrBuffer));
|
|
129
|
+
} else {
|
|
130
|
+
resolve(undefined);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.#proc = proc;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get pid(): number | undefined {
|
|
138
|
+
return this.#proc.pid;
|
|
139
|
+
}
|
|
140
|
+
get exited(): Promise<void> {
|
|
141
|
+
return this.#exited;
|
|
142
|
+
}
|
|
143
|
+
get exitCode(): number | null {
|
|
144
|
+
return this.#proc.exitCode;
|
|
145
|
+
}
|
|
146
|
+
get exitReason(): Exception | undefined {
|
|
147
|
+
return this.#exitReason;
|
|
148
|
+
}
|
|
149
|
+
get killed(): boolean {
|
|
150
|
+
return this.#proc.killed;
|
|
151
|
+
}
|
|
152
|
+
get stdin(): FileSink | undefined {
|
|
153
|
+
return this.#proc.stdin;
|
|
154
|
+
}
|
|
155
|
+
get stdout(): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
156
|
+
return this.#proc.stdout;
|
|
157
|
+
}
|
|
158
|
+
get stderr(): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
159
|
+
return this.#stderrTee;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Peek at the stderr buffer.
|
|
164
|
+
* @returns The stderr buffer.
|
|
165
|
+
*/
|
|
166
|
+
peekStderr(): string {
|
|
167
|
+
return this.#stderrBuffer;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Detach this process from management (no cleanup on shutdown).
|
|
172
|
+
*/
|
|
173
|
+
detach(): void {
|
|
174
|
+
if (this.#detached || this.#proc.killed) return;
|
|
175
|
+
this.#detached = true;
|
|
176
|
+
if (managedChildren.delete(this.#proc)) {
|
|
177
|
+
this.#proc.unref();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Prevents thrown ChildError on nonzero exit code, for optional error handling.
|
|
183
|
+
*/
|
|
184
|
+
nothrow(): this {
|
|
185
|
+
this.#nothrow = true;
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Kill the process tree.
|
|
191
|
+
* Optionally set an exit reason (for better error propagation on cancellation).
|
|
192
|
+
*/
|
|
193
|
+
kill(signal: NodeJS.Signals = "SIGTERM", reason?: Exception) {
|
|
194
|
+
if (this.#proc.killed) return;
|
|
195
|
+
if (reason) {
|
|
196
|
+
this.#exitReasonPending = reason;
|
|
197
|
+
}
|
|
198
|
+
killChild(this.#proc, signal);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async killAndWait(): Promise<void> {
|
|
202
|
+
// Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second
|
|
203
|
+
this.kill("SIGTERM");
|
|
204
|
+
await Promise.race([this.exited, Bun.sleep(1000).then(() => this.kill("SIGKILL"))]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Output utilities (aliases for easy chaining)
|
|
208
|
+
async text(): Promise<string> {
|
|
209
|
+
return (await this.blob()).text();
|
|
210
|
+
}
|
|
211
|
+
async json(): Promise<unknown> {
|
|
212
|
+
return (await this.blob()).json();
|
|
213
|
+
}
|
|
214
|
+
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
215
|
+
return (await this.blob()).arrayBuffer();
|
|
216
|
+
}
|
|
217
|
+
async bytes() {
|
|
218
|
+
return (await this.blob()).bytes();
|
|
219
|
+
}
|
|
220
|
+
async blob() {
|
|
221
|
+
const { promise, resolve, reject } = Promise.withResolvers<Blob>();
|
|
222
|
+
|
|
223
|
+
const blob = this.#proc.stdout.blob();
|
|
224
|
+
if (!this.#nothrow) {
|
|
225
|
+
this.#exited.catch((ex: Exception) => {
|
|
226
|
+
reject(ex);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
blob.then(resolve, reject);
|
|
230
|
+
return promise;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Attach an AbortSignal to this process. Will kill tree with SIGKILL if aborted.
|
|
235
|
+
*/
|
|
236
|
+
attachSignal(signal: AbortSignal): void {
|
|
237
|
+
const onAbort = () => {
|
|
238
|
+
const cause = new AbortError(signal.reason, "<cancelled>");
|
|
239
|
+
this.kill("SIGKILL", cause);
|
|
240
|
+
if (this.#proc.killed) {
|
|
241
|
+
queueMicrotask(() => {
|
|
242
|
+
try {
|
|
243
|
+
this.#resolveExited(cause);
|
|
244
|
+
} catch {
|
|
245
|
+
// Ignore
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
if (signal.aborted) {
|
|
251
|
+
return void onAbort();
|
|
252
|
+
}
|
|
253
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
254
|
+
// Use .finally().catch() to avoid unhandled rejection when #exited rejects
|
|
255
|
+
this.#exited
|
|
256
|
+
.finally(() => {
|
|
257
|
+
signal.removeEventListener("abort", onAbort);
|
|
258
|
+
})
|
|
259
|
+
.catch(() => {});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Attach a timeout to this process. Will kill the process with SIGKILL if the timeout is reached.
|
|
264
|
+
*/
|
|
265
|
+
attachTimeout(timeout: number): void {
|
|
266
|
+
if (timeout <= 0) return;
|
|
267
|
+
const timeoutId = setTimeout(() => {
|
|
268
|
+
this.kill("SIGKILL", new TimeoutError(timeout, this.#stderrBuffer));
|
|
269
|
+
}, timeout);
|
|
270
|
+
// Use .finally().catch() to avoid unhandled rejection when #exited rejects
|
|
271
|
+
this.#exited
|
|
272
|
+
.finally(() => {
|
|
273
|
+
clearTimeout(timeoutId);
|
|
274
|
+
})
|
|
275
|
+
.catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Base for all exceptions representing child process nonzero exit, killed, or cancellation.
|
|
281
|
+
*/
|
|
282
|
+
export abstract class Exception extends Error {
|
|
283
|
+
constructor(
|
|
284
|
+
message: string,
|
|
285
|
+
public readonly exitCode: number,
|
|
286
|
+
public readonly stderr: string,
|
|
287
|
+
) {
|
|
288
|
+
super(message);
|
|
289
|
+
this.name = this.constructor.name;
|
|
290
|
+
}
|
|
291
|
+
abstract get aborted(): boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Exception for nonzero exit codes (not cancellation).
|
|
296
|
+
*/
|
|
297
|
+
export class NonZeroExitError extends Exception {
|
|
298
|
+
static readonly MAX_TRACE = 32 * 1024;
|
|
299
|
+
|
|
300
|
+
constructor(
|
|
301
|
+
public readonly exitCode: number,
|
|
302
|
+
public readonly stderr: string,
|
|
303
|
+
) {
|
|
304
|
+
super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr);
|
|
305
|
+
}
|
|
306
|
+
get aborted(): boolean {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Exception for explicit process abortion (via signal).
|
|
313
|
+
*/
|
|
314
|
+
export class AbortError extends Exception {
|
|
315
|
+
constructor(
|
|
316
|
+
public readonly reason: unknown,
|
|
317
|
+
stderr: string,
|
|
318
|
+
) {
|
|
319
|
+
const reasonString = reason instanceof Error ? reason.message : String(reason ?? "aborted");
|
|
320
|
+
super(`Operation cancelled: ${reasonString}`, -1, stderr);
|
|
321
|
+
}
|
|
322
|
+
get aborted(): boolean {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Exception for process timeout.
|
|
329
|
+
*/
|
|
330
|
+
export class TimeoutError extends AbortError {
|
|
331
|
+
constructor(timeout: number, stderr: string) {
|
|
332
|
+
super(new Error(`Process timed out after ${timeout}ms`), stderr);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
|
|
338
|
+
*/
|
|
339
|
+
type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">, "stdout" | "stderr"> & {
|
|
340
|
+
signal?: AbortSignal;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Spawn a subprocess as a managed child process.
|
|
345
|
+
* - Always pipes stdout/stderr, launches in new session/process group (detached).
|
|
346
|
+
* - Optional AbortSignal integrates with kill-on-abort.
|
|
347
|
+
*/
|
|
348
|
+
export function cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
|
|
349
|
+
const { timeout, ...rest } = options ?? {};
|
|
350
|
+
const child = spawn(cmd, {
|
|
351
|
+
...rest,
|
|
352
|
+
stdout: "pipe",
|
|
353
|
+
stderr: "pipe",
|
|
354
|
+
// Windows: new console/pgroup; Unix: setsid for process group.
|
|
355
|
+
detached: true,
|
|
356
|
+
});
|
|
357
|
+
const cproc = new ChildProcess(child);
|
|
358
|
+
if (options?.signal) {
|
|
359
|
+
cproc.attachSignal(options.signal);
|
|
360
|
+
}
|
|
361
|
+
if (timeout && timeout > 0) {
|
|
362
|
+
cproc.attachTimeout(timeout);
|
|
363
|
+
}
|
|
364
|
+
return cproc;
|
|
365
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { TextDecoderStream } from "node:stream/web";
|
|
2
|
+
import stripAnsi from "strip-ansi";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize binary output for display/storage.
|
|
6
|
+
* Removes characters that crash string-width or cause display issues:
|
|
7
|
+
* - Control characters (except tab, newline, carriage return)
|
|
8
|
+
* - Lone surrogates
|
|
9
|
+
* - Unicode Format characters (crash string-width due to a bug)
|
|
10
|
+
* - Characters with undefined code points
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeBinaryOutput(str: string): string {
|
|
13
|
+
// Use Array.from to properly iterate over code points (not code units)
|
|
14
|
+
// This handles surrogate pairs correctly and catches edge cases where
|
|
15
|
+
// codePointAt() might return undefined
|
|
16
|
+
return Array.from(str)
|
|
17
|
+
.filter((char) => {
|
|
18
|
+
// Filter out characters that cause string-width to crash
|
|
19
|
+
// This includes:
|
|
20
|
+
// - Unicode format characters
|
|
21
|
+
// - Lone surrogates (already filtered by Array.from)
|
|
22
|
+
// - Control chars except \t \n \r
|
|
23
|
+
// - Characters with undefined code points
|
|
24
|
+
|
|
25
|
+
const code = char.codePointAt(0);
|
|
26
|
+
|
|
27
|
+
// Skip if code point is undefined (edge case with invalid strings)
|
|
28
|
+
if (code === undefined) return false;
|
|
29
|
+
|
|
30
|
+
// Allow tab, newline, carriage return
|
|
31
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
|
|
32
|
+
|
|
33
|
+
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
|
|
34
|
+
if (code <= 0x1f) return false;
|
|
35
|
+
|
|
36
|
+
// Filter out Unicode format characters
|
|
37
|
+
if (code >= 0xfff9 && code <= 0xfffb) return false;
|
|
38
|
+
|
|
39
|
+
return true;
|
|
40
|
+
})
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings.
|
|
46
|
+
*/
|
|
47
|
+
export function sanitizeText(text: string): string {
|
|
48
|
+
return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a transform stream that splits lines.
|
|
53
|
+
*/
|
|
54
|
+
export function createSplitterStream(delimiter: string): TransformStream<string, string> {
|
|
55
|
+
let buf = "";
|
|
56
|
+
return new TransformStream<string, string>({
|
|
57
|
+
transform(chunk, controller) {
|
|
58
|
+
buf = buf ? `${buf}${chunk}` : chunk;
|
|
59
|
+
|
|
60
|
+
while (true) {
|
|
61
|
+
const nl = buf.indexOf(delimiter);
|
|
62
|
+
if (nl === -1) break;
|
|
63
|
+
controller.enqueue(buf.slice(0, nl));
|
|
64
|
+
buf = buf.slice(nl + delimiter.length);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
flush(controller) {
|
|
68
|
+
if (buf) {
|
|
69
|
+
controller.enqueue(buf);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a transform stream that sanitizes text.
|
|
77
|
+
*/
|
|
78
|
+
export function createSanitizerStream(): TransformStream<string, string> {
|
|
79
|
+
return new TransformStream<string, string>({
|
|
80
|
+
transform(chunk, controller) {
|
|
81
|
+
controller.enqueue(sanitizeText(chunk));
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a transform stream that decodes text.
|
|
88
|
+
*/
|
|
89
|
+
export function createTextDecoderStream(): TransformStream<Uint8Array, string> {
|
|
90
|
+
return new TextDecoderStream("utf-8", { ignoreBOM: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read stream line-by-line
|
|
95
|
+
*
|
|
96
|
+
* @param delimiter Line delimiter (default: "\n")
|
|
97
|
+
*/
|
|
98
|
+
export function readLines(stream: ReadableStream<Uint8Array>, delimiter = "\n"): AsyncIterable<string> {
|
|
99
|
+
return stream.pipeThrough(createTextDecoderStream()).pipeThrough(createSplitterStream(delimiter));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// SSE (Server-Sent Events)
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parsed SSE event.
|
|
108
|
+
*/
|
|
109
|
+
export interface SseEvent {
|
|
110
|
+
/** Event type (from `event:` field, default: "message") */
|
|
111
|
+
event: string;
|
|
112
|
+
/** Event data (from `data:` field(s), joined with newlines) */
|
|
113
|
+
data: string;
|
|
114
|
+
/** Event ID (from `id:` field) */
|
|
115
|
+
id?: string;
|
|
116
|
+
/** Retry interval in ms (from `retry:` field) */
|
|
117
|
+
retry?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse a single SSE event block (lines between blank lines).
|
|
122
|
+
* Returns null if the block contains no data.
|
|
123
|
+
*/
|
|
124
|
+
export function parseSseEvent(block: string): SseEvent | null {
|
|
125
|
+
const lines = block.split("\n");
|
|
126
|
+
let event = "message";
|
|
127
|
+
const dataLines: string[] = [];
|
|
128
|
+
let id: string | undefined;
|
|
129
|
+
let retry: number | undefined;
|
|
130
|
+
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
// Comments start with ':'
|
|
133
|
+
if (line.startsWith(":")) continue;
|
|
134
|
+
|
|
135
|
+
const colonIdx = line.indexOf(":");
|
|
136
|
+
if (colonIdx === -1) continue;
|
|
137
|
+
|
|
138
|
+
const field = line.slice(0, colonIdx);
|
|
139
|
+
// Value starts after colon, with optional leading space trimmed
|
|
140
|
+
let value = line.slice(colonIdx + 1);
|
|
141
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
142
|
+
|
|
143
|
+
switch (field) {
|
|
144
|
+
case "event":
|
|
145
|
+
event = value;
|
|
146
|
+
break;
|
|
147
|
+
case "data":
|
|
148
|
+
dataLines.push(value);
|
|
149
|
+
break;
|
|
150
|
+
case "id":
|
|
151
|
+
id = value;
|
|
152
|
+
break;
|
|
153
|
+
case "retry": {
|
|
154
|
+
const n = parseInt(value, 10);
|
|
155
|
+
if (!Number.isNaN(n)) retry = n;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (dataLines.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
event,
|
|
165
|
+
data: dataLines.join("\n"),
|
|
166
|
+
id,
|
|
167
|
+
retry,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Read SSE events from a stream.
|
|
173
|
+
*
|
|
174
|
+
* Handles the SSE wire format:
|
|
175
|
+
* - Events separated by blank lines
|
|
176
|
+
* - Fields: event, data, id, retry
|
|
177
|
+
* - Comments (lines starting with :) are ignored
|
|
178
|
+
* - Multiple data: lines are joined with newlines
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* for await (const event of readSseEvents(response.body)) {
|
|
183
|
+
* if (event.data === "[DONE]") break;
|
|
184
|
+
* const payload = JSON.parse(event.data);
|
|
185
|
+
* console.log(event.event, payload);
|
|
186
|
+
* }
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export async function* readSseEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent, void, undefined> {
|
|
190
|
+
const blockLines: string[] = [];
|
|
191
|
+
|
|
192
|
+
for await (const rawLine of readLines(stream)) {
|
|
193
|
+
const line = rawLine.replace(/\r$/, "");
|
|
194
|
+
if (line === "") {
|
|
195
|
+
if (blockLines.length > 0) {
|
|
196
|
+
const event = parseSseEvent(blockLines.join("\n"));
|
|
197
|
+
if (event) yield event;
|
|
198
|
+
blockLines.length = 0;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
blockLines.push(line);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (blockLines.length > 0) {
|
|
207
|
+
const event = parseSseEvent(blockLines.join("\n"));
|
|
208
|
+
if (event) yield event;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read SSE data payloads from a stream, parsing JSON automatically.
|
|
214
|
+
*
|
|
215
|
+
* Convenience wrapper over readSseEvents that:
|
|
216
|
+
* - Skips [DONE] markers
|
|
217
|
+
* - Parses JSON data
|
|
218
|
+
* - Optionally filters by event type
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* for await (const data of readSseData<ChatChunk>(response.body)) {
|
|
223
|
+
* console.log(data.choices[0].delta);
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
export async function* readSseData<T = unknown>(
|
|
228
|
+
stream: ReadableStream<Uint8Array>,
|
|
229
|
+
eventType?: string,
|
|
230
|
+
): AsyncGenerator<T, void, undefined> {
|
|
231
|
+
for await (const event of readSseEvents(stream)) {
|
|
232
|
+
if (eventType && event.event !== eventType) continue;
|
|
233
|
+
if (event.data === "[DONE]") continue;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
yield JSON.parse(event.data) as T;
|
|
237
|
+
} catch {
|
|
238
|
+
// Skip malformed JSON
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/temp.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface AsyncTempDir {
|
|
7
|
+
path: string;
|
|
8
|
+
remove(): Promise<void>;
|
|
9
|
+
toString(): string;
|
|
10
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SyncTempDir {
|
|
14
|
+
path: string;
|
|
15
|
+
remove(): void;
|
|
16
|
+
toString(): string;
|
|
17
|
+
[Symbol.dispose](): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const kTempDir = tmpdir();
|
|
21
|
+
|
|
22
|
+
function normalizePrefix(prefix?: string): string {
|
|
23
|
+
if (!prefix) {
|
|
24
|
+
return `${kTempDir}${sep}pi-temp-`;
|
|
25
|
+
} else if (prefix.startsWith("@")) {
|
|
26
|
+
return join(kTempDir, prefix.slice(1));
|
|
27
|
+
}
|
|
28
|
+
return prefix;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function createTempDir(prefix?: string): Promise<AsyncTempDir> {
|
|
32
|
+
const path = await mkdtemp(normalizePrefix(prefix));
|
|
33
|
+
|
|
34
|
+
let promise: Promise<void> | null = null;
|
|
35
|
+
const remove = () => {
|
|
36
|
+
if (promise) {
|
|
37
|
+
return promise;
|
|
38
|
+
}
|
|
39
|
+
promise = rm(path, { recursive: true, force: true }).catch(() => {});
|
|
40
|
+
return promise;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
path: path!,
|
|
45
|
+
remove,
|
|
46
|
+
toString: () => path,
|
|
47
|
+
[Symbol.asyncDispose]: remove,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createTempDirSync(prefix?: string): SyncTempDir {
|
|
52
|
+
const path = mkdtempSync(normalizePrefix(prefix));
|
|
53
|
+
|
|
54
|
+
let done = false;
|
|
55
|
+
const remove = () => {
|
|
56
|
+
if (done) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
done = true;
|
|
60
|
+
try {
|
|
61
|
+
rmSync(path, { recursive: true, force: true });
|
|
62
|
+
} catch {
|
|
63
|
+
// Ignore cleanup errors
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
path,
|
|
69
|
+
toString: () => path,
|
|
70
|
+
remove,
|
|
71
|
+
[Symbol.dispose]: remove,
|
|
72
|
+
};
|
|
73
|
+
}
|