Files
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

539 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pnpm dev —— 本地调试服:把 dist/releases/ 下的分发目录按发布结构服务出来,并注入 WE 模拟器。
//
// 为什么不直接服务某个分发目录当根:把**服务根设在 releases 的父目录**,任何一个分发里若存在
// 越出自身目录的引用(../ 或根绝对路径),浏览器会立刻 404 —— 这是对"目录可整体搬走"最严的检验。
// 每档分发各占一个端口反而可能掩盖问题(越界路径会意外命中别的分发)。
//
// 路由:
// / 分发索引(读 dist-map.json)
// /release/<dir>/ 某档分发的根(自动补 index.html)
// /release/<dir>/<相对路径> 其内部资源,按扩展名给正确 MIME
// /simulator/wallpaper-engine.js WE 模拟器本体(调试期专有,见下)
//
// 为什么模拟器单独一条路由:按 ADR 0006 §1,模拟器**默认不进发布产物**,只有
// `pnpm build --with-sim` 才会往分发目录里放一份。而调试服总是需要它。早先驱动脚本直接
// 从 `/release/<dir>/scripts/wallpaper-engine.js` 取,于是默认构建(不带 --with-sim)下
// 动态 import 必然 404、面板静默消失——现象是"调试时没有 WE 模拟面板"。
// 现在由调试服从编译产物 build/scripts/wallpaper-engine.js 提供,
// 分发目录则始终保持"就是发布产物"的样子。
//
// 查询参数(与既有对拍工具的参数保持兼容,见 tools/serve.mjs):
// ?sim=0 不注入 WE 模拟器
// ?__props={"preset":"kv37"} 覆盖初始用户属性
// ?__propsAt=dom|load 属性下发时序;dom 复现"load 之前就到达"的竞态
// ?fps=30 初始 fps(经 applyGeneralProperties 下发);0 = 不限流
// ?__paused=1 初始 setPaused(true)
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
// ?__freeze=1 注入既有的对拍驱动(冻结动画相位,供 cdp/shot 断言)
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的参考帧
//
// 热更新:监听 src/ 与 wallpapers/,按改动只跑必要的那几步构建,然后经 SSE 通知浏览器。
// 改 CSS → 只重铺产物,然后**只换样式表**(不整页重载,Spine 播放器与面板状态都保住)
// 改 runtime → tsc runtime + 重铺产物 → 整页刷新
// 改 simulator → tsc simulator(若产物里也带模拟器,再重铺一遍)→ 整页刷新
// 改壁纸数据 → 重铺产物 → 整页刷新
// 构建失败 → 只报错,**不刷新**(刷新只会把半成品页面端上来)
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, watch } from "node:fs";
import { spawn } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { extname, join, resolve, sep } from "node:path";
import { abs, exists, log, readJson } from "./lib/fs.ts";
import { readVault, VaultError } from "./lib/vault.ts";
import type { DistMap } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL, LIVE_RELOAD_CLIENT, NOJS_DRIVER } from "./lib/drivers.ts";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "video/webm",
};
interface Args {
port: number;
build: boolean;
open: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { port: 5173, build: true, open: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--port") args.port = Number(argv[++i] ?? 5173);
else if (arg.startsWith("--port=")) args.port = Number(arg.slice(7));
else if (arg === "--no-build") args.build = false;
else if (arg === "--open") args.open = true;
// 构建开关本身由 main() 读 process.argv 决定(初始构建与热更新重建共用一份)。
// 这里必须显式放行,否则它会在参数解析阶段就被当成未知参数拒掉——
// 那样 main() 里那段判断永远走不到,整个开关是死代码。
else if (arg === "--with-sim") continue;
else if (/^\d+$/.test(arg)) args.port = Number(arg);
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
}
return args;
}
const HELP = `用法:pnpm dev [--port 5173] [--no-build] [--open] [--with-sim]
--no-build 不先跑一次增量构建(dist/ 不存在时会直接报错)
--open 在默认浏览器里打开索引页
--with-sim 产物里也带一份 WE 模拟器(静态托管预览用)。
会一直带着:初始构建与热更新重建用的是同一组开关。`;
/** 读某分发目录的 project.json 与所需注入数据。 */
async function releaseInfo(
dir: string,
): Promise<
| { properties: Record<string, unknown>; version: string; title: string; defaultPresetId: string; preview?: string }
| undefined
> {
const root = abs(`dist/releases/${dir}`);
if (!(await exists(join(root, "project.json")))) return undefined;
const project = await readJson<{
version?: string;
title?: string;
preview?: string;
general?: { properties?: Record<string, unknown> };
}>(join(root, "project.json"));
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
return {
properties: project.general?.properties ?? {},
version: project.version ?? "",
title: project.title ?? dir,
defaultPresetId: map.releases.find((r) => r.dir === dir)?.defaultPresetId ?? "",
preview: project.preview,
};
}
/** 索引页:列出所有分发,点进去即可调试。 */
async function indexPage(): Promise<string> {
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const rows = map.releases
.map((release) => {
const wallpapers = release.wallpapers.map((w) => `${w.name}<span class="id">${w.id}</span>`).join("、");
return `<tr>
<td><a href="/release/${encodeURIComponent(release.dir)}/">${release.displayName}</a>
<div class="dir">${release.dir}</div></td>
<td class="type">${releaseTypeLabel(release.type, release.scope)}</td>
<td>${wallpapers}</td>
<td class="num">${(release.bytes / 1024 / 1024).toFixed(1)} MB</td>
</tr>`;
})
.join("\n");
/** 类型列显示人话:单档 / 游戏合集 / 全部合集(内部值不给人看)。 */
function releaseTypeLabel(type: string, scope?: string): string {
if (type === "single") return "单档";
return scope === "all" ? "全部合集" : "游戏合集";
}
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<title>SpineWallpaper 调试服</title>
<style>
body { margin: 32px auto; max-width: 900px; font: 14px/1.6 "Segoe UI", system-ui, sans-serif;
background: #14141a; color: #e8e8ea; }
h1 { font-size: 18px; } code { background: #1e1e28; padding: 1px 5px; border-radius: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2c2c38; vertical-align: top; }
th { color: #8d8da0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
a { color: #8fb8ff; text-decoration: none; } a:hover { text-decoration: underline; }
.dir { color: #6f6f85; font-size: 11px; }
.type { color: #b9a0ff; } .num { text-align: right; color: #9b9bb0; }
.id { color: #6f6f85; margin-left: 4px; font-size: 11px; }
.hint { color: #8d8da0; font-size: 12px; margin-top: 20px; }
</style></head><body>
<h1>SpineWallpaper 调试服 <span class="dir">version ${map.version}</span></h1>
<p>每档分发都是 <b>自包含</b> 的:服务根设在 <code>dist/releases/</code>,某个分发里若有越界引用会立刻 404。</p>
<table><thead><tr><th>分发</th><th>类型</th><th>壁纸</th><th>体积</th></tr></thead>
<tbody>${rows}</tbody></table>
<div class="hint">
进入某档分发后可用:<code>?sim=0</code> 关模拟器 · <code>&amp;__props={"preset":"kv37"}</code> 覆盖属性 ·
<code>&amp;__propsAt=dom</code> 复现 load 前下发 · <code>&amp;fps=30</code> 限流 · <code>&amp;nojs=1</code> 剥脚本对照
</div>
</body></html>`;
}
/** 往 index.html 注入驱动脚本(与既有对拍管线共用 tools/lib/drivers.ts)。 */
async function html(file: string, dir: string, search: URLSearchParams): Promise<string> {
let body = await readFile(file, "utf8");
if (search.get("nojs") === "1") {
const stripped = body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "<!-- script removed by ?nojs=1 -->");
return `${stripped}\n${NOJS_DRIVER}`;
}
const info = await releaseInfo(dir);
if (!info) return body;
// 热更新客户端放进 <head>,尽早连上 SSE:晚一步就会漏掉"保存后立刻开始构建"的那条消息。
body = body.replace(/<\/head>/i, () => `${LIVE_RELOAD_CLIENT}\n</head>`);
// project.json 的属性定义要交给模拟器面板渲染控件;用内联脚本最省一次请求。
const propertiesTag = `<script>window.__weProperties = ${JSON.stringify(info.properties).replace(/</g, "\\u003c")};</script>`;
// 驱动脚本注入在壁纸模块**之前**:它用动态 import 装载模拟器,从而在 index.js 注册
// wallpaperPropertyListener 之前就把 API 备好(模拟器源码本身仍是 ES module,见 drivers.ts)。
const inject = SIMULATOR_DRIVER({
search: search.toString(),
dir,
title: info.title,
version: info.version,
defaultPresetId: info.defaultPresetId,
preview: info.preview,
});
if (search.get("sim") !== "0") {
body = body.replace(
/<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i,
(match) => `${propertiesTag}\n${inject}\n${match}`,
);
}
return body;
}
function safeJoin(root: string, rel: string): string | undefined {
const target = resolve(root, rel.replace(/^\/+/, ""));
if (target !== root && !target.startsWith(root + sep)) return undefined;
return target;
}
/* ------------------------------------------------------------------ 热更新 */
/** 浏览器端注入的 LIVE_RELOAD_CLIENT 会连到 /__dev/events。 */
const liveClients = new Set<ServerResponse>();
function broadcast(payload: Record<string, unknown>): void {
const data = `data: ${JSON.stringify(payload)}\n\n`;
for (const client of liveClients) {
try {
client.write(data);
} catch {
liveClients.delete(client);
}
}
}
const TSC = abs("node_modules/typescript/bin/tsc");
interface RebuildSteps {
runtime: boolean;
simulator: boolean;
build: boolean;
}
/**
* 按改动文件决定跑哪几步。
*
* 全量 `pnpm build` 是 2.2 秒,而绝大多数改动只涉及其中一步:改样式只要重铺产物(0.85s),
* 改模拟器只要重编模拟器(0.6s)。省下来的是每次保存的等待时间。
*/
function stepsFor(files: string[], releasesCarrySimulator: boolean): RebuildSteps {
const steps: RebuildSteps = { runtime: false, simulator: false, build: false };
for (const file of files) {
if (file === "src/globals.d.ts") {
// 类型声明同时影响运行时与模拟器
steps.runtime = true;
steps.simulator = true;
steps.build = true;
} else if (file.startsWith("src/runtime/")) {
steps.runtime = true;
steps.build = true; // 编译产物要重铺进各分发
} else if (file.startsWith("src/simulator/")) {
steps.simulator = true;
// --with-sim 的产物里也有一份模拟器,且它的驱动排在调试服注入的那份之前
// (幂等闸先到先得),那份不更新的话浏览器会一直跑旧模拟器。
steps.build = releasesCarrySimulator;
} else if (file.startsWith("src/") || file.startsWith("wallpapers/") || file === "VERSION") {
steps.build = true; // 样式、模板、vendor、project.template.json、壁纸数据
}
}
return steps;
}
/** 跑一个子进程,成功返回它的输出,失败抛出带输出的错误(要显示到浏览器上)。 */
function runStep(args: string[], label: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(output);
else reject(new VaultError(`${label} 失败(exit ${code})\n${output.trim().split("\n").slice(-12).join("\n")}`));
});
});
}
/** 监听源码目录。**绝不能监听 dist/**:构建写 dist,监听它就是一个死循环。 */
function startWatcher(onChange: (file: string) => void): void {
for (const root of ["src", "wallpapers"]) {
try {
watch(abs(root), { recursive: true }, (_event, filename) => {
if (filename) onChange(`${root}/${String(filename).replace(/\\/g, "/")}`);
});
} catch (error) {
log(` ⚠ 无法监听 ${root}/:${String(error)}`);
}
}
try {
watch(abs("VERSION"), () => onChange("VERSION"));
} catch {
// VERSION 不存在就算了,构建自己会报
}
}
/** 串行化重建:构建期间来的改动先攒着,跑完再补一轮。 */
function createRebuilder(releasesCarrySimulator: boolean, buildFlags: string[]): (files: string[]) => void {
const pending = new Set<string>();
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
let running = false;
const run = async (): Promise<void> => {
if (running) return; // 正在跑的那一轮会在循环里把 pending 收走
running = true;
try {
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const steps = stepsFor(batch, releasesCarrySimulator);
const labels: string[] = [];
if (steps.runtime) labels.push("runtime");
if (steps.simulator) labels.push("simulator");
if (steps.build) labels.push("releases");
if (labels.length === 0) continue;
broadcast({ type: "building", steps: labels });
const started = Date.now();
try {
if (steps.runtime) await runStep([TSC, "-p", "tsconfig.runtime.json"], "tsc runtime");
if (steps.simulator) await runStep([TSC, "-p", "tsconfig.simulator.json"], "tsc simulator");
// 必须带上与启动时相同的开关。否则 `pnpm dev --with-sim` 会在第一次保存后
// 被悄悄换成干净构建——产物里的模拟器没了,而没人会想到是"保存"干的。
if (steps.build) await runStep([abs("tools/build.ts"), ...buildFlags], "build");
} catch (error) {
const message = error instanceof VaultError ? error.message : String(error);
log(` ✗ 重建失败:${message.split("\n")[0]}`);
broadcast({ type: "error", message });
continue; // 不刷新页面:刷新只会把半成品端上来
}
// 只改了样式 → 让浏览器换个样式表就行,不必重载整页
const cssOnly =
steps.build &&
!steps.runtime &&
!steps.simulator &&
batch.every((f) => /^src\/styles\/.*\.css$/.test(f));
log(` ↻ 已重建 ${labels.join(" + ")}(${Date.now() - started} ms)${cssOnly ? " 只换样式表" : " 整页刷新"}`);
broadcast({ type: cssOnly ? "css" : "reload" });
}
} finally {
running = false;
}
};
// 防抖:一次保存常常触发多个事件(编辑器写临时文件再改名),攒一下再跑。
return (files: string[]): void => {
for (const file of files) pending.add(file);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void run();
}, 120);
};
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
// 构建开关只算一次,初始构建与热更新重建共用,免得两处走偏(已经偏过一次)。
const buildFlags = process.argv.includes("--with-sim") ? ["--with-sim"] : [];
if (args.build && !(await exists(abs("dist/dist-map.json")))) {
log("dist/ 不存在,先跑一次构建…(--no-build 可跳过)");
const child = spawn(process.execPath, [abs("tools/build.ts"), ...buildFlags], {
stdio: "inherit",
});
await new Promise<void>((done, fail) => {
child.on("close", (code) => (code === 0 ? done() : fail(new VaultError(`构建失败(exit ${code})`))));
});
}
const vault = await readVault();
const releasesRoot = abs("dist/releases");
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const server = createServer((req, res) => {
void handle(req, res, releasesRoot);
});
// 产物里带不带模拟器,决定"改模拟器"要不要顺带重铺产物(见 stepsFor)。
const releasesCarrySimulator = (
await Promise.all(
map.releases.map((release) => exists(abs(`dist/releases/${release.dir}/scripts/wallpaper-engine.js`))),
)
).some(Boolean);
const schedule = createRebuilder(releasesCarrySimulator, buildFlags);
startWatcher((file) => {
schedule([file]);
});
server.listen(args.port, "127.0.0.1", () => {
log(`SpineWallpaper 调试服 version ${map.version}`);
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
log(` 索引:http://127.0.0.1:${args.port}/`);
for (const release of map.releases) {
log(` /release/${release.dir}/ ${release.displayName}`);
}
// 启动时就把"模拟器能不能加载"说清楚。否则它只会在浏览器里变成一条 404,
// 表现成"面板没出来",而没人会想到是构建产物缺文件。
void stat(abs("build/scripts/wallpaper-engine.js")).then(
(info) =>
log(` 模拟器:${SIMULATOR_URL} ✓ ${(info.size / 1024).toFixed(0)} KB(调试期专有,不进发布产物)`),
() => log(` 模拟器:✗ 未编译(build/scripts/wallpaper-engine.js 不存在)→ 面板不会出现,请先跑 pnpm build`),
);
log(
` 热更新:监听 src/ 与 wallpapers/(改完自动重建并刷新;CSS 只换样式表)` +
(buildFlags.length > 0 ? ` 构建开关:${buildFlags.join(" ")}` : ""),
);
log(" 注意:重建会清掉本次未产出的目录,\`sim/\` 需要时单独跑 pnpm build sim。");
if (args.open) {
void import("node:child_process").then(({ spawn }) => {
spawn("cmd", ["/c", "start", "", `http://127.0.0.1:${args.port}/`], { stdio: "ignore", detached: true });
});
}
});
}
async function handle(req: IncomingMessage, res: ServerResponse, releasesRoot: string): Promise<void> {
try {
// 每次请求重读:改了目录名、加了新壁纸之后,调试服不该需要重启才认。
// 索引页本来就这么做;这里以前用的是启动时那一份,于是磁盘上已经是 single-hsr-kv37,
// 服务端还按 wallpaper-kv37 校验,整档 404。
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const pathname = decodeURIComponent(url.pathname);
if (pathname === "/" || pathname === "/index.html") {
const body = await indexPage();
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(body);
return;
}
// 热更新事件流。text/event-stream 要一直挂着,所以这里不能 end()。
if (pathname === "/__dev/events") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
});
res.write(": connected\n\n");
liveClients.add(res);
req.on("close", () => liveClients.delete(res));
return;
}
// 模拟器本体:调试期专有资源,从编译产物读(见文件头"为什么模拟器单独一条路由")。
// 刻意**不**回落到分发目录里的副本:分发里那份只有 --with-sim 才有,可能是旧的,
// 而这里要的永远是"当前源码编出来的那一个"。
if (pathname === SIMULATOR_URL) {
const file = abs("build/scripts/wallpaper-engine.js");
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
res
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
.end(`模拟器未编译:${file} 不存在。跑一次 pnpm build(或 pnpm build:sim)。`);
return;
}
res.writeHead(200, {
"content-type": MIME[".js"] ?? "text/javascript; charset=utf-8",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(file).pipe(res);
return;
}
const match = /^\/release\/([^/]+)(\/.*)?$/.exec(pathname);
if (!match) {
res
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
.end("只服务 /、/release/<dir>/… 与 " + SIMULATOR_URL);
return;
}
const dir = match[1] ?? "";
if (!map.releases.some((release) => release.dir === dir)) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`没有这档分发:${dir}`);
return;
}
const root = join(releasesRoot, dir);
let rel = (match[2] ?? "/").replace(/^\//, "");
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = safeJoin(root, rel);
if (!file) {
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("越出分发目录");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`);
return;
}
if (extname(file).toLowerCase() === ".html") {
const body = await html(file, dir, url.searchParams);
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(body),
"cache-control": "no-store",
});
res.end(body);
return;
}
res.writeHead(200, {
"content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(file).pipe(res);
} catch (error) {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(error));
}
}
try {
await main();
} catch (error) {
if (error instanceof VaultError) {
console.error(`\n调试服启动失败:${error.message}\n`);
process.exit(1);
}
throw error;
}