Files
ttrpg-tools/src/cli/commands/serve.ts
T
hypercross d1384c2ccc refactor(cli): Share the content-index walk
Extract readContentIndex from buildRegistry so the validator can read raw files while the server keeps the scanned registry, both from one walk.
2026-09-12 16:38:05 +08:00

377 lines
9.5 KiB
TypeScript
Raw 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.
import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } from "fs";
import { join, resolve, extname, relative, dirname } from "path";
import { watch } from "chokidar";
import { networkInterfaces } from "os";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
buildRegistryFromIndex,
normalizePathKey,
deriveCompletions,
type ContentRegistry,
} from "../content-registry.js";
import { readContentIndex } from "../content-index.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
}
/**
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录)
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/**
* MIME 类型映射表
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".md": "text/markdown",
".csv": "text/csv",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* 根据文件扩展名获取对应的 MIME 类型
*/
function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
/**
* 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost)
*/
function getBestIP(): string {
const interfaces = networkInterfaces();
let best: string | null = null;
for (const [, addrs] of Object.entries(interfaces)) {
if (!addrs) continue;
for (const addr of addrs) {
if (addr.family !== "IPv4" || addr.internal) continue;
// Prefer a 192.168.x.x or 10.x.x.x address
if (addr.address.startsWith("192.168.")) {
best = addr.address;
break;
}
if (
!best &&
(addr.address.startsWith("10.") || addr.address.startsWith("172."))
) {
best = addr.address;
}
}
if (best?.startsWith("192.168.")) break;
}
return best || "localhost";
}
/**
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
*/
export function buildRegistry(dir: string): ContentRegistry {
return buildRegistryFromIndex(readContentIndex(dir));
}
/**
* 发送文件响应(流式传输)
*/
function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, {
"Content-Type": getMimeType(filePath),
"Access-Control-Allow-Origin": "*",
});
createReadStream(filePath).pipe(res);
}
/**
* 发送 404 未找到响应
*/
function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
}
/**
* 发送 JSON 格式的响应
*/
function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
res.end(JSON.stringify(data, null, 2));
}
/**
* 尝试提供静态文件
* @returns 文件存在并成功发送时返回 true
*/
function tryServeStatic(
res: ServerResponse,
filePath: string,
dir: string,
): boolean {
const fullPath = join(dir, filePath);
if (!existsSync(fullPath)) {
return false;
}
const stats = statSync(fullPath);
if (!stats.isFile()) {
return false;
}
sendFile(res, fullPath);
return true;
}
/**
* 创建 HTTP 请求处理器
*/
function createRequestHandler(
contentDir: string,
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) {
return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/";
const filePath = decodeURIComponent(url.split("?")[0]);
// 1. 处理 /__CONTENT_INDEX.json
if (filePath === "/__CONTENT_INDEX.json") {
sendJson(res, getIndex());
return;
}
// 1b. 处理 /__COMPLETIONS.json
if (filePath === "/__COMPLETIONS.json") {
sendJson(res, getCompletions());
return;
}
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web)
if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) {
return;
}
send404(res);
return;
}
// 3. 处理 /content/ 目录
const relativePath = filePath.slice(1); // 去掉开头的 /
if (tryServeStatic(res, relativePath, contentDir)) {
return;
}
// 4. 处理 SPA 路由:返回 index.html
if (tryServeStatic(res, "index.html", distDir)) {
return;
}
// 5. 404
send404(res);
};
}
/**
* 内容服务器实例接口
*/
export interface ContentServer {
/**
* HTTP 服务器实例
*/
server: Server;
/**
* 文件监听器
*/
watcher: ReturnType<typeof watch>;
/**
* 内容索引
*/
index: ContentIndex;
/**
* 补全数据
*/
completions: CompletionsPayload;
/**
* 关闭服务器
*/
close(): void;
}
/**
* 创建内容服务器(HTTP + WebSocket + 文件监听)
*/
export function createContentServer(
contentDir: string,
port: number,
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
declarations: [],
tagModifiers: [],
};
/** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void {
completionsIndex = deriveCompletions(registry);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
);
}
// 扫描内容目录生成注册表
console.log("正在扫描内容目录...");
registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化
console.log("正在监听文件变化...");
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./,
persistent: true,
ignoreInitial: true,
});
watcher
.on("add", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
// 全量重建注册表以刷新跨文件派生(spark 注入)
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[新增] ${path}`);
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
}
})
.on("change", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[更新] ${path}`);
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
}
})
.on("unlink", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[删除] ${path}`);
}
});
// ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => registry.pathIndex,
() => completionsIndex,
() => registry,
);
const server = createServer(handleRequest);
createJournalServer(contentDir, server).then((j) => {
journal = j;
});
server.listen(port, host, () => {
const bestIP = getBestIP();
const displayHost = host === "0.0.0.0" ? bestIP : host;
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
console.log(`本机访问:http://localhost:${port}`);
if (bestIP !== "localhost") {
console.log(`局域网访问:http://${bestIP}:${port}`);
}
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`);
console.log(`日志连接:ws://${bestIP}:${port}\n`);
});
return {
server,
watcher,
index: registry.pathIndex,
completions: completionsIndex,
close() {
console.log("正在关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
},
};
}
/**
* 启动开发服务器
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0";
createContentServer(contentDir, port, distDir, host);
};