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"; import { extractInlineBlocks } from "../inline-blocks.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 = { ".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"); if (entry.endsWith(".md")) { const stripped = extractInlineBlocks( content, normalizedRelPath, index, ); index[normalizedRelPath] = stripped; } else { 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; /** * 内容索引 */ 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: [], stats: [], }; /** 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} stats=${completionsIndex.stats.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("/"); if (relPath.endsWith(".md")) { const stripped = extractInlineBlocks( content, relPath, contentIndex, ); contentIndex[relPath] = stripped; recomputeCompletions(); } else { contentIndex[relPath] = content; } console.log(`[新增] ${relPath}`); } 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("/"); if (relPath.endsWith(".md")) { const stripped = extractInlineBlocks( content, relPath, contentIndex, ); contentIndex[relPath] = stripped; recomputeCompletions(); } else { contentIndex[relPath] = content; } console.log(`[更新] ${relPath}`); } 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> | 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); };