refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries
This commit is contained in:
1 parent
3f1c6d02f6
commit
27c0f27434
268 files changed
+7468
-7852
No files matched your search
@@ -0,0 +1,212 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync, writeSync } from "node:fs";
|
||||
import { mkdir, open, opendir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { hostname } from "node:os";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
|
||||
export const runtimeHostname = hostname;
|
||||
export const runtimeReadTextFile = (path: string) => readFile(path, "utf8");
|
||||
export const runtimeReadTextFileSync = (path: string) => readFileSync(path, "utf8");
|
||||
export const runtimeWriteTextFile = (path: string, text: string) => writeFile(path, text);
|
||||
export const runtimeMkdir = mkdir;
|
||||
export const runtimeRename = rename;
|
||||
export const runtimeRemove = rm;
|
||||
export const runtimeReadDir = opendir;
|
||||
export async function runtimeStat(path: string) {
|
||||
const s = await stat(path);
|
||||
return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() };
|
||||
}
|
||||
export async function runtimeOpen(
|
||||
path: string,
|
||||
opts: { read?: boolean; write?: boolean; create?: boolean; truncate?: boolean },
|
||||
) {
|
||||
const f = await open(path, opts.write ? (opts.truncate ? "w" : "a") : "r");
|
||||
return {
|
||||
stat: async () => {
|
||||
const s = await f.stat();
|
||||
return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() };
|
||||
},
|
||||
close: () => f.close(),
|
||||
write: async (bytes: Uint8Array) => (await f.write(bytes)).bytesWritten,
|
||||
get readable() {
|
||||
return Readable.toWeb(f.createReadStream({ autoClose: true })) as unknown as ReadableStream<Uint8Array>;
|
||||
},
|
||||
};
|
||||
}
|
||||
export const runtimeWriteSync = (bytes: Uint8Array) => writeSync(1, bytes);
|
||||
export const runtimeWrite = async (bytes: Uint8Array) => runtimeWriteSync(bytes);
|
||||
export const runtimeUnrefTimer = (timer: unknown) => (timer as { unref?: () => void }).unref?.();
|
||||
|
||||
type CommandOptions = {
|
||||
args?: string[];
|
||||
stdin?: "piped" | "null" | "inherit";
|
||||
stdout?: "piped" | "null" | "inherit";
|
||||
stderr?: "piped" | "null" | "inherit";
|
||||
env?: Record<string, string>;
|
||||
clearEnv?: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
export type ChildProcess = ReturnType<Command["spawn"]>;
|
||||
export class Command {
|
||||
constructor(
|
||||
private cmd: string,
|
||||
private opts: CommandOptions = {},
|
||||
) {}
|
||||
spawn() {
|
||||
const o = this.opts;
|
||||
const io = (s: string | undefined) => (s === "piped" ? "pipe" : s === "null" ? "ignore" : "inherit");
|
||||
const p = spawn(this.cmd, o.args ?? [], {
|
||||
stdio: [io(o.stdin), io(o.stdout ?? "piped"), io(o.stderr ?? "piped")],
|
||||
env: o.clearEnv ? o.env : { ...process.env, ...o.env },
|
||||
signal: o.signal,
|
||||
});
|
||||
const status = new Promise<{ success: boolean; code: number; signal: string | null }>((resolve, reject) => {
|
||||
p.once("error", reject);
|
||||
p.once("close", (code, signal) => resolve({ success: code === 0, code: code ?? 1, signal }));
|
||||
});
|
||||
// Attach a handler immediately: callers may still be writing stdin when spawn fails.
|
||||
status.catch(() => {});
|
||||
const child = {
|
||||
pid: p.pid!,
|
||||
stdin: p.stdin ? Writable.toWeb(p.stdin) : new WritableStream<Uint8Array>(),
|
||||
stdout: p.stdout
|
||||
? (Readable.toWeb(p.stdout) as unknown as ReadableStream<Uint8Array>)
|
||||
: new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
stderr: p.stderr
|
||||
? (Readable.toWeb(p.stderr) as unknown as ReadableStream<Uint8Array>)
|
||||
: new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
status,
|
||||
kill: (signal: string = "SIGTERM") => {
|
||||
p.kill(signal as NodeJS.Signals);
|
||||
},
|
||||
unref: () => {
|
||||
p.unref();
|
||||
},
|
||||
output: async () => {
|
||||
const [s, stdout, stderr] = await Promise.all([
|
||||
status,
|
||||
new Response(child.stdout).bytes(),
|
||||
new Response(child.stderr).bytes(),
|
||||
]);
|
||||
return { ...s, stdout, stderr };
|
||||
},
|
||||
};
|
||||
return child;
|
||||
}
|
||||
output() {
|
||||
return this.spawn().output();
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetAddr {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
export interface HttpServer {
|
||||
addr: NetAddr;
|
||||
finished: Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
type SocketData = { client: WebSocket };
|
||||
const upgrades = new WeakMap<Request, Bun.Server<SocketData>>();
|
||||
export function serve(
|
||||
opts: { port: number; hostname?: string; signal?: AbortSignal; onListen?: (addr: NetAddr) => void },
|
||||
fetcher: (req: Request) => Response | Promise<Response>,
|
||||
): HttpServer {
|
||||
let finish!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const server = Bun.serve<SocketData>({
|
||||
port: opts.port,
|
||||
hostname: opts.hostname,
|
||||
async fetch(req, server) {
|
||||
upgrades.set(req, server);
|
||||
const response = await fetcher(req);
|
||||
upgrades.delete(req);
|
||||
return upgradedRequests.has(req) ? undefined : response;
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const c = ws.data.client;
|
||||
socketBindings.get(c)?.(ws);
|
||||
socketBindings.delete(c);
|
||||
Object.defineProperty(c, "readyState", { value: WebSocket.OPEN, configurable: true });
|
||||
c.dispatchEvent(new Event("open"));
|
||||
},
|
||||
message(ws, data) {
|
||||
ws.data.client.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data:
|
||||
typeof data === "string" ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
|
||||
}),
|
||||
);
|
||||
},
|
||||
close(ws, code, reason) {
|
||||
const c = ws.data.client;
|
||||
Object.defineProperty(c, "readyState", { value: WebSocket.CLOSED, configurable: true });
|
||||
c.dispatchEvent(new CloseEvent("close", { code, reason }));
|
||||
},
|
||||
},
|
||||
});
|
||||
const addr = { hostname: opts.hostname ?? "0.0.0.0", port: server.port! };
|
||||
opts.onListen?.(addr);
|
||||
const shutdown = async () => {
|
||||
await server.stop(true);
|
||||
finish();
|
||||
};
|
||||
opts.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void shutdown();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
if (opts.signal?.aborted) void shutdown();
|
||||
return { addr, finished, shutdown };
|
||||
}
|
||||
const upgradedRequests = new WeakSet<Request>();
|
||||
export function upgradeWebSocket(req: Request, opts: { protocol?: string } = {}) {
|
||||
const server = upgrades.get(req);
|
||||
if (!server) throw new Error("WebSocket upgrade requires a Bun HTTP server");
|
||||
// EventTarget facade preserves the bridge's browser WebSocket callbacks.
|
||||
const client = new EventTarget() as WebSocket;
|
||||
let ws: Bun.ServerWebSocket<SocketData> | undefined;
|
||||
Object.defineProperty(client, "readyState", { value: WebSocket.CONNECTING, configurable: true });
|
||||
for (const event of ["open", "message", "close", "error"]) {
|
||||
let callback: ((e: Event) => void) | null = null;
|
||||
Object.defineProperty(client, `on${event}`, {
|
||||
get: () => callback,
|
||||
set: (fn) => {
|
||||
if (callback) client.removeEventListener(event, callback);
|
||||
callback = fn;
|
||||
if (fn) client.addEventListener(event, fn);
|
||||
},
|
||||
});
|
||||
}
|
||||
client.send = (data) => {
|
||||
ws?.send(data as string | ArrayBuffer);
|
||||
};
|
||||
client.close = (code, reason) => {
|
||||
ws?.close(code, reason);
|
||||
};
|
||||
const data = { client };
|
||||
// Capture the native socket in open before dispatching the bridge callbacks.
|
||||
socketBindings.set(client, (socket) => {
|
||||
ws = socket;
|
||||
});
|
||||
if (!server.upgrade(req, { data, headers: opts.protocol ? { "Sec-WebSocket-Protocol": opts.protocol } : undefined }))
|
||||
throw new Error("WebSocket upgrade failed");
|
||||
const response = new Response(null);
|
||||
upgradedRequests.add(req);
|
||||
return { socket: client, response };
|
||||
}
|
||||
const socketBindings = new WeakMap<WebSocket, (ws: Bun.ServerWebSocket<SocketData>) => void>();
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { type ChildProcess, Command, runtimeWriteSync } from "./index.ts";
|
||||
|
||||
/** 本地执行协议。业务类型、重试、权限与超时策略由调用方负责。 */
|
||||
export interface WorkerRequest {
|
||||
id: string;
|
||||
task: string;
|
||||
input: unknown;
|
||||
}
|
||||
export interface WorkerError {
|
||||
code: string;
|
||||
message: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
export type WorkerReply =
|
||||
| { id: string; type: "result"; result: unknown }
|
||||
| { id: string; type: "error"; error: WorkerError };
|
||||
export type WorkerMessage = WorkerReply | { id: string; type: "progress"; value: unknown };
|
||||
|
||||
export class WorkerTransportError extends Error {
|
||||
constructor(
|
||||
readonly kind: "spawn" | "protocol" | "exit" | "size",
|
||||
message: string,
|
||||
override readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkerTransportError";
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
export function encodeWorkerMessage(value: WorkerRequest | WorkerMessage, maxBytes: number): Uint8Array {
|
||||
const bytes = encoder.encode(`${JSON.stringify(value)}\n`);
|
||||
if (bytes.byteLength > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 按字节限制一行的大小;UTF-8 与换行可以跨管道分块。EOF 不能代替消息结束符。 */
|
||||
export async function* workerLines(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
maxBytes: number,
|
||||
): AsyncGenerator<string> {
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
let parts: string[] = [];
|
||||
let length = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (length) throw new WorkerTransportError("protocol", "worker 消息未以换行结束");
|
||||
return;
|
||||
}
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value[i] !== 10) continue;
|
||||
length += i - start + 1;
|
||||
if (length > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`);
|
||||
parts.push(decoder.decode(value.subarray(start, i), { stream: true }), decoder.decode());
|
||||
const line = parts.join("");
|
||||
parts = [];
|
||||
length = 0;
|
||||
start = i + 1;
|
||||
if (!line) throw new WorkerTransportError("protocol", "worker 返回空消息");
|
||||
yield line;
|
||||
}
|
||||
length += value.length - start;
|
||||
if (length > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`);
|
||||
parts.push(decoder.decode(value.subarray(start), { stream: true }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWorkerRequestText(maxBytes: number): Promise<string> {
|
||||
const stream = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
let text: string | undefined;
|
||||
for await (const line of workerLines(reader, maxBytes)) {
|
||||
if (text !== undefined) throw new WorkerTransportError("protocol", "一次性 worker 只能接收一个请求");
|
||||
text = line;
|
||||
}
|
||||
if (text === undefined) throw new WorkerTransportError("protocol", "worker 没有收到请求");
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeWorkerRequest(text: string): WorkerRequest {
|
||||
const req = JSON.parse(text) as WorkerRequest;
|
||||
if (!req || typeof req.id !== "string" || !req.id || typeof req.task !== "string" || !("input" in req))
|
||||
throw new WorkerTransportError("protocol", "worker 请求格式无效");
|
||||
return req;
|
||||
}
|
||||
|
||||
export function writeWorkerMessage(message: WorkerMessage, maxBytes: number): void {
|
||||
const bytes = encodeWorkerMessage(message, maxBytes);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) offset += runtimeWriteSync(bytes.subarray(offset));
|
||||
}
|
||||
|
||||
function decodeMessage(line: string, id: string): WorkerMessage {
|
||||
const value = JSON.parse(line) as WorkerMessage;
|
||||
if (!value || value.id !== id) throw new WorkerTransportError("protocol", "worker 响应不属于当前请求");
|
||||
if (value.type === "result" && "result" in value) return value;
|
||||
if (value.type === "progress" && "value" in value) return value;
|
||||
if (value.type === "error" && typeof value.error?.code === "string" && typeof value.error?.message === "string")
|
||||
return value;
|
||||
throw new WorkerTransportError("protocol", "worker 响应格式无效");
|
||||
}
|
||||
|
||||
/** 一个一次性进程连接。只管通信与回收,不重试、不决定超时、不安装权限策略。 */
|
||||
export class WorkerConnection {
|
||||
readonly pid: number;
|
||||
private readonly child: ChildProcess;
|
||||
private readonly stdout: ReadableStreamDefaultReader<Uint8Array>;
|
||||
private readonly stderrReader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
private reject: ((error: unknown) => void) | null = null;
|
||||
private terminating: unknown;
|
||||
private killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private settled = false;
|
||||
private started = false;
|
||||
private stderr = "";
|
||||
|
||||
constructor(command: { cmd: string; args: string[] }, env?: { values: Record<string, string>; clear?: boolean }) {
|
||||
try {
|
||||
this.child = new Command(command.cmd, {
|
||||
args: command.args,
|
||||
stdin: "piped",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
env: env?.values,
|
||||
clearEnv: env?.clear,
|
||||
}).spawn();
|
||||
} catch (error) {
|
||||
throw new WorkerTransportError("spawn", "worker 子进程无法启动", error);
|
||||
}
|
||||
this.pid = this.child.pid;
|
||||
this.stdout = this.child.stdout.getReader();
|
||||
this.stderrReader = this.child.stderr.getReader();
|
||||
}
|
||||
|
||||
/** graceMs > 0 时先 SIGTERM 供文件操作清理;解析可直接 SIGKILL。等待始终有界。 */
|
||||
terminate(error: unknown, graceMs = 0, onForceKill?: () => void): void {
|
||||
if (this.settled || this.terminating !== undefined) return;
|
||||
this.terminating = error;
|
||||
const force = () => {
|
||||
try {
|
||||
this.child.kill("SIGKILL");
|
||||
onForceKill?.();
|
||||
} catch {
|
||||
// 已退出
|
||||
}
|
||||
this.child.unref();
|
||||
this.fail(error);
|
||||
};
|
||||
if (graceMs === 0) {
|
||||
force();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// 已退出
|
||||
}
|
||||
this.killTimer = setTimeout(force, graceMs);
|
||||
}
|
||||
|
||||
request(
|
||||
req: WorkerRequest,
|
||||
options: { maxRequestBytes: number; maxResponseBytes: number; onProgress?: (value: unknown) => void },
|
||||
): Promise<WorkerReply> {
|
||||
if (this.started) throw new Error("一次性 worker 连接不能重复请求");
|
||||
this.started = true;
|
||||
if (this.terminating !== undefined) return Promise.reject(this.terminating);
|
||||
if (!this.pid)
|
||||
return this.child.status.then(
|
||||
() => {
|
||||
this.cleanup();
|
||||
throw new WorkerTransportError("exit", "worker 未启动");
|
||||
},
|
||||
(error) => {
|
||||
this.cleanup();
|
||||
throw new WorkerTransportError("spawn", "worker 子进程无法启动", error);
|
||||
},
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.reject = reject;
|
||||
const errPump = this.readStderr();
|
||||
let final: WorkerReply | undefined;
|
||||
const pump = (async () => {
|
||||
for await (const line of workerLines(this.stdout, options.maxResponseBytes)) {
|
||||
const msg = decodeMessage(line, req.id);
|
||||
if (final) throw new WorkerTransportError("protocol", "worker 结果之后仍发送消息");
|
||||
if (msg.type === "progress") options.onProgress?.(msg.value);
|
||||
else final = msg;
|
||||
}
|
||||
})();
|
||||
pump.catch((error) => this.terminate(this.transportError(error)));
|
||||
this.child.status.then(
|
||||
async (status) => {
|
||||
try {
|
||||
await Promise.all([pump, errPump]);
|
||||
if (this.settled) return;
|
||||
if (this.terminating !== undefined) return this.fail(this.terminating);
|
||||
if (!status.success || !final)
|
||||
throw new WorkerTransportError(
|
||||
"exit",
|
||||
`worker 异常退出(code=${status.code} signal=${status.signal}):${this.stderr}`,
|
||||
);
|
||||
this.cleanup();
|
||||
resolve(final);
|
||||
} catch (error) {
|
||||
this.fail(this.terminating ?? this.transportError(error));
|
||||
}
|
||||
},
|
||||
(error) => this.fail(new WorkerTransportError("spawn", "worker 子进程无法启动", error)),
|
||||
);
|
||||
(async () => {
|
||||
const bytes = encodeWorkerMessage(req, options.maxRequestBytes);
|
||||
const writer = this.child.stdin.getWriter();
|
||||
try {
|
||||
await writer.write(bytes);
|
||||
await writer.close();
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
})().catch((error) => this.terminate(this.transportError(error)));
|
||||
if (this.terminating !== undefined) this.fail(this.terminating);
|
||||
});
|
||||
}
|
||||
|
||||
private transportError(error: unknown): WorkerTransportError {
|
||||
return error instanceof WorkerTransportError
|
||||
? error
|
||||
: new WorkerTransportError("protocol", `worker 通信失败:${(error as Error).message}`, error);
|
||||
}
|
||||
|
||||
private async readStderr(): Promise<void> {
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await this.stderrReader.read();
|
||||
if (done) return;
|
||||
this.stderr = (this.stderr + decoder.decode(value, { stream: true })).slice(-2000);
|
||||
}
|
||||
} catch {
|
||||
// 终止时管道可能断开
|
||||
}
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
if (this.settled) return;
|
||||
this.cleanup();
|
||||
this.reject?.(error);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.settled = true;
|
||||
clearTimeout(this.killTimer);
|
||||
void this.stdout.cancel().catch(() => {});
|
||||
void this.stderrReader.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user