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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
+538
@@ -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>&__props={"preset":"kv37"}</code> 覆盖属性 ·
|
||||
<code>&__propsAt=dom</code> 复现 load 前下发 · <code>&fps=30</code> 限流 · <code>&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;
|
||||
}
|
||||
Reference in new issue
Block a user