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

250 lines
6.4 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 { fileURLToPath } from "url";
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";
}
/**
* 扫描目录内的 .md 文件,生成索引
*/
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,
) {
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;
}
// 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 const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
let contentIndex: ContentIndex = {};
// 扫描内容目录生成索引
console.log("扫描内容目录...");
contentIndex = scanDirectory(contentDir);
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
// 监听文件变化
console.log("监听文件变化...");
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\../,
persistent: true,
ignoreInitial: true,
});
watcher
.on("add", (path) => {
if (path.endsWith(".md")) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[新增] ${relPath}`);
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
}
})
.on("change", (path) => {
if (path.endsWith(".md")) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[更新] ${relPath}`);
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
}
})
.on("unlink", (path) => {
if (path.endsWith(".md")) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
}
});
// 创建请求处理器
const handleRequest = createRequestHandler(
contentDir,
distDir,
() => contentIndex,
);
// 创建 HTTP 服务器
const server = createServer(handleRequest);
const port = parseInt(options.port, 10);
server.listen(port, () => {
console.log(`\n开发服务器已启动http://localhost:${port}`);
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distDir}`);
console.log(`索引文件http://localhost:${port}/__CONTENT_INDEX.json\n`);
});
};