// pnpm dev —— 本地调试服:把 dist/releases/ 下的分发目录按发布结构服务出来,并注入 WE 模拟器。 // // 为什么不直接服务某个分发目录当根:把**服务根设在 releases 的父目录**,任何一个分发里若存在 // 越出自身目录的引用(../ 或根绝对路径),浏览器会立刻 404 —— 这是对"目录可整体搬走"最严的检验。 // 每档分发各占一个端口反而可能掩盖问题(越界路径会意外命中别的分发)。 // // 路由: // / 分发索引(读 dist-map.json) // /release// 某档分发的根(自动补 index.html) // /release//<相对路径> 其内部资源,按扩展名给正确 MIME // /simulator/wallpaper-engine.js WE 模拟器本体(调试期专有,见下) // // 为什么模拟器单独一条路由:按 ADR 0006 §1,模拟器**默认不进发布产物**,只有 // `pnpm build --with-sim` 才会往分发目录里放一份。而调试服总是需要它。早先驱动脚本直接 // 从 `/release//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 剥掉所有 `; // 驱动脚本注入在壁纸模块**之前**:它用动态 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( /]*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(); function broadcast(payload: Record): 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 { 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(); let timer: ReturnType | undefined = undefined; let running = false; const run = async (): Promise => { 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 { 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((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(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 { try { // 每次请求重读:改了目录名、加了新壁纸之后,调试服不该需要重启才认。 // 索引页本来就这么做;这里以前用的是启动时那一份,于是磁盘上已经是 single-hsr-kv37, // 服务端还按 wallpaper-kv37 校验,整档 404。 const map = await readJson(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//… 与 " + 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; }