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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+538
View File
@@ -0,0 +1,538 @@
// 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;
}