ttrpg-tools/src/cli/commands/serve.ts

389 lines
9.8 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, sep, relative, dirname } from "path";
import { watch } from "chokidar";
import { networkInterfaces } from "os";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.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";
}
/**
* Get the best network IP for display (prefer LAN IPv4, fallback to 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 scanDirectory(dir: string): ContentIndex {
const index: ContentIndex = {};
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
for (const entry of entries) {
if (entry.startsWith(".") || entry === "node_modules") continue;
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = "/" + relPath.split(sep).join("/");
const stats = statSync(fullPath);
if (stats.isDirectory()) {
scan(fullPath, relPath);
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn")
) {
try {
const content = readFileSync(fullPath, "utf-8");
index[normalizedRelPath] = content;
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
}
}
}
scan(dir, "");
return index;
}
/**
* 发送文件响应
*/
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;
}
/**
* 创建请求处理器
*/
function createRequestHandler(
contentDir: string,
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
) {
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;
}
// 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;
}
/**
* 创建内容服务器
*/
export function createContentServer(
contentDir: string,
port: number,
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let contentIndex: ContentIndex = {};
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
};
/** Re-scan completions from current content index (cached) */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`,
);
}
// 扫描内容目录生成索引
console.log("扫描内容目录...");
contentIndex = scanDirectory(contentDir);
console.log(`已索引 ${Object.keys(contentIndex).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")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[新增] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
}
})
.on("change", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[更新] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
}
})
.on("unlink", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
}
});
// ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => contentIndex,
() => completionsIndex,
);
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(`Journal 连接ws://${bestIP}:${port}\n`);
});
return {
server,
watcher,
index: contentIndex,
completions: completionsIndex,
close() {
console.log("关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
},
};
}
/**
* 启动开发服务器
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0";
createContentServer(contentDir, port, distDir, host);
};