2026-02-26 14:14:26 +08:00
|
|
|
|
import type { ServeCommandHandler } from "../types.js";
|
|
|
|
|
|
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
|
|
|
|
|
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
|
|
|
|
|
import { createReadStream } from "fs";
|
2026-02-26 16:11:56 +08:00
|
|
|
|
import { join, resolve, extname, sep, relative, dirname } from "path";
|
2026-02-26 14:14:26 +08:00
|
|
|
|
import { watch } from "chokidar";
|
2026-07-07 23:48:04 +08:00
|
|
|
|
import { networkInterfaces } from "os";
|
2026-02-26 16:11:56 +08:00
|
|
|
|
import { fileURLToPath } from "url";
|
2026-07-06 14:19:16 +08:00
|
|
|
|
import { createJournalServer } from "../journal.js";
|
2026-07-06 16:50:45 +08:00
|
|
|
|
import {
|
|
|
|
|
|
scanCompletions,
|
|
|
|
|
|
type CompletionsPayload,
|
|
|
|
|
|
} from "../completions/index.js";
|
2026-07-08 15:46:19 +08:00
|
|
|
|
import { extractInlineBlocks } from "../inline-blocks.js";
|
2026-02-26 00:17:23 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
interface ContentIndex {
|
|
|
|
|
|
[path: string]: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 16:11:56 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
|
const __dirname = dirname(__filename);
|
2026-02-26 16:35:57 +08:00
|
|
|
|
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
2026-02-26 16:11:56 +08:00
|
|
|
|
|
2026-02-26 13:37:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* MIME 类型映射
|
|
|
|
|
|
*/
|
|
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
".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",
|
2026-02-26 13:37:10 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取文件扩展名对应的 MIME 类型
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getMimeType(filePath: string): string {
|
|
|
|
|
|
const ext = extname(filePath).toLowerCase();
|
2026-02-26 14:14:26 +08:00
|
|
|
|
return MIME_TYPES[ext] || "application/octet-stream";
|
2026-02-26 13:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 23:48:04 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
/**
|
2026-02-26 14:14:26 +08:00
|
|
|
|
* 扫描目录内的 .md 文件,生成索引
|
2026-02-26 13:35:09 +08:00
|
|
|
|
*/
|
2026-03-18 15:00:13 +08:00
|
|
|
|
export function scanDirectory(dir: string): ContentIndex {
|
2026-02-26 13:35:09 +08:00
|
|
|
|
const index: ContentIndex = {};
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
function scan(currentPath: string, relativePath: string) {
|
|
|
|
|
|
const entries = readdirSync(currentPath);
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
for (const entry of entries) {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
if (entry.startsWith(".") || entry === "node_modules") continue;
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
const fullPath = join(currentPath, entry);
|
|
|
|
|
|
const relPath = relativePath ? join(relativePath, entry) : entry;
|
2026-02-26 14:14:26 +08:00
|
|
|
|
const normalizedRelPath = "/" + relPath.split(sep).join("/");
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
const stats = statSync(fullPath);
|
|
|
|
|
|
if (stats.isDirectory()) {
|
|
|
|
|
|
scan(fullPath, relPath);
|
2026-07-06 14:19:16 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
entry.endsWith(".md") ||
|
|
|
|
|
|
entry.endsWith(".csv") ||
|
|
|
|
|
|
entry.endsWith(".yarn")
|
|
|
|
|
|
) {
|
2026-02-26 13:35:09 +08:00
|
|
|
|
try {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
const content = readFileSync(fullPath, "utf-8");
|
2026-07-08 15:46:19 +08:00
|
|
|
|
if (entry.endsWith(".md")) {
|
|
|
|
|
|
const stripped = extractInlineBlocks(
|
|
|
|
|
|
content,
|
|
|
|
|
|
normalizedRelPath,
|
|
|
|
|
|
index,
|
|
|
|
|
|
);
|
|
|
|
|
|
index[normalizedRelPath] = stripped;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
index[normalizedRelPath] = content;
|
|
|
|
|
|
}
|
2026-02-26 13:35:09 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(`读取文件失败:${fullPath}`, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 14:14:26 +08:00
|
|
|
|
scan(dir, "");
|
2026-02-26 13:35:09 +08:00
|
|
|
|
return index;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-26 13:37:10 +08:00
|
|
|
|
* 发送文件响应
|
2026-02-26 13:35:09 +08:00
|
|
|
|
*/
|
2026-02-26 13:37:10 +08:00
|
|
|
|
function sendFile(res: ServerResponse, filePath: string) {
|
|
|
|
|
|
res.writeHead(200, {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
"Content-Type": getMimeType(filePath),
|
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
2026-02-26 13:37:10 +08:00
|
|
|
|
});
|
|
|
|
|
|
createReadStream(filePath).pipe(res);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送 404 响应
|
|
|
|
|
|
*/
|
|
|
|
|
|
function send404(res: ServerResponse) {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
|
|
|
|
res.end("Not Found");
|
2026-02-26 13:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送 JSON 响应
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sendJson(res: ServerResponse, data: unknown) {
|
|
|
|
|
|
res.writeHead(200, {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
2026-02-26 13:37:10 +08:00
|
|
|
|
});
|
|
|
|
|
|
res.end(JSON.stringify(data, null, 2));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 尝试提供静态文件
|
|
|
|
|
|
* @returns 如果文件存在并已成功发送则返回 true
|
|
|
|
|
|
*/
|
2026-02-26 14:14:26 +08:00
|
|
|
|
function tryServeStatic(
|
|
|
|
|
|
res: ServerResponse,
|
|
|
|
|
|
filePath: string,
|
|
|
|
|
|
dir: string,
|
|
|
|
|
|
): boolean {
|
2026-02-26 13:37:10 +08:00
|
|
|
|
const fullPath = join(dir, filePath);
|
2026-02-26 14:14:26 +08:00
|
|
|
|
|
2026-02-26 13:37:10 +08:00
|
|
|
|
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,
|
2026-07-06 16:50:45 +08:00
|
|
|
|
getCompletions: () => CompletionsPayload,
|
2026-02-26 13:37:10 +08:00
|
|
|
|
) {
|
|
|
|
|
|
return (req: IncomingMessage, res: ServerResponse) => {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
const url = req.url || "/";
|
|
|
|
|
|
const filePath = decodeURIComponent(url.split("?")[0]);
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 1. 处理 /__CONTENT_INDEX.json
|
2026-02-26 14:14:26 +08:00
|
|
|
|
if (filePath === "/__CONTENT_INDEX.json") {
|
2026-02-26 13:37:10 +08:00
|
|
|
|
sendJson(res, getIndex());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 16:50:45 +08:00
|
|
|
|
// 1b. 处理 /__COMPLETIONS.json
|
|
|
|
|
|
if (filePath === "/__COMPLETIONS.json") {
|
|
|
|
|
|
sendJson(res, getCompletions());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 13:37:10 +08:00
|
|
|
|
// 2. 处理 /static/ 目录(从 dist/web)
|
2026-02-26 14:14:26 +08:00
|
|
|
|
if (filePath.startsWith("/static/")) {
|
2026-02-26 13:37:10 +08:00
|
|
|
|
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
|
2026-02-26 14:14:26 +08:00
|
|
|
|
if (tryServeStatic(res, "index.html", distDir)) {
|
2026-02-26 13:37:10 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. 404
|
|
|
|
|
|
send404(res);
|
2026-02-26 13:35:09 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-18 15:00:13 +08:00
|
|
|
|
* 内容服务器接口
|
2026-02-26 13:35:09 +08:00
|
|
|
|
*/
|
2026-03-18 15:00:13 +08:00
|
|
|
|
export interface ContentServer {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* HTTP 服务器实例
|
|
|
|
|
|
*/
|
|
|
|
|
|
server: Server;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 文件监听器
|
|
|
|
|
|
*/
|
|
|
|
|
|
watcher: ReturnType<typeof watch>;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 内容索引
|
|
|
|
|
|
*/
|
|
|
|
|
|
index: ContentIndex;
|
2026-07-06 16:50:45 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 补全数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
completions: CompletionsPayload;
|
2026-03-18 15:00:13 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 关闭服务器
|
|
|
|
|
|
*/
|
|
|
|
|
|
close(): void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建内容服务器
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function createContentServer(
|
|
|
|
|
|
contentDir: string,
|
|
|
|
|
|
port: number,
|
|
|
|
|
|
distPath: string = distDir,
|
2026-03-21 00:29:36 +08:00
|
|
|
|
host: string = "0.0.0.0",
|
2026-03-18 15:00:13 +08:00
|
|
|
|
): ContentServer {
|
2026-02-26 13:35:09 +08:00
|
|
|
|
let contentIndex: ContentIndex = {};
|
2026-07-07 19:02:17 +08:00
|
|
|
|
let completionsIndex: CompletionsPayload = {
|
|
|
|
|
|
dice: [],
|
|
|
|
|
|
links: [],
|
|
|
|
|
|
sparkTables: [],
|
|
|
|
|
|
};
|
2026-07-06 16:50:45 +08:00
|
|
|
|
|
|
|
|
|
|
/** Re-scan completions from current content index (cached) */
|
|
|
|
|
|
function recomputeCompletions(): void {
|
|
|
|
|
|
completionsIndex = scanCompletions(contentIndex);
|
|
|
|
|
|
console.log(
|
2026-07-07 19:02:17 +08:00
|
|
|
|
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`,
|
2026-07-06 16:50:45 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
// 扫描内容目录生成索引
|
2026-02-26 14:14:26 +08:00
|
|
|
|
console.log("扫描内容目录...");
|
2026-02-26 13:35:09 +08:00
|
|
|
|
contentIndex = scanDirectory(contentDir);
|
|
|
|
|
|
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
2026-07-06 16:50:45 +08:00
|
|
|
|
recomputeCompletions();
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
// 监听文件变化
|
2026-02-26 14:14:26 +08:00
|
|
|
|
console.log("监听文件变化...");
|
2026-02-26 13:35:09 +08:00
|
|
|
|
const watcher = watch(contentDir, {
|
2026-07-06 14:19:16 +08:00
|
|
|
|
ignored: /(^|[\/\\])\./,
|
2026-02-26 13:35:09 +08:00
|
|
|
|
persistent: true,
|
|
|
|
|
|
ignoreInitial: true,
|
|
|
|
|
|
});
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-02-26 13:35:09 +08:00
|
|
|
|
watcher
|
2026-02-26 14:14:26 +08:00
|
|
|
|
.on("add", (path) => {
|
2026-07-06 14:19:16 +08:00
|
|
|
|
if (
|
|
|
|
|
|
path.endsWith(".md") ||
|
|
|
|
|
|
path.endsWith(".csv") ||
|
|
|
|
|
|
path.endsWith(".yarn")
|
|
|
|
|
|
) {
|
2026-02-26 13:35:09 +08:00
|
|
|
|
try {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
const content = readFileSync(path, "utf-8");
|
|
|
|
|
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
2026-07-08 15:46:19 +08:00
|
|
|
|
if (relPath.endsWith(".md")) {
|
|
|
|
|
|
const stripped = extractInlineBlocks(
|
|
|
|
|
|
content,
|
|
|
|
|
|
relPath,
|
|
|
|
|
|
contentIndex,
|
|
|
|
|
|
);
|
|
|
|
|
|
contentIndex[relPath] = stripped;
|
|
|
|
|
|
recomputeCompletions();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
contentIndex[relPath] = content;
|
|
|
|
|
|
}
|
2026-02-26 13:35:09 +08:00
|
|
|
|
console.log(`[新增] ${relPath}`);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(`读取新增文件失败:${path}`, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-02-26 14:14:26 +08:00
|
|
|
|
.on("change", (path) => {
|
2026-07-06 14:19:16 +08:00
|
|
|
|
if (
|
|
|
|
|
|
path.endsWith(".md") ||
|
|
|
|
|
|
path.endsWith(".csv") ||
|
|
|
|
|
|
path.endsWith(".yarn")
|
|
|
|
|
|
) {
|
2026-02-26 13:35:09 +08:00
|
|
|
|
try {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
const content = readFileSync(path, "utf-8");
|
|
|
|
|
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
2026-07-08 15:46:19 +08:00
|
|
|
|
if (relPath.endsWith(".md")) {
|
|
|
|
|
|
const stripped = extractInlineBlocks(
|
|
|
|
|
|
content,
|
|
|
|
|
|
relPath,
|
|
|
|
|
|
contentIndex,
|
|
|
|
|
|
);
|
|
|
|
|
|
contentIndex[relPath] = stripped;
|
|
|
|
|
|
recomputeCompletions();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
contentIndex[relPath] = content;
|
|
|
|
|
|
}
|
2026-02-26 13:35:09 +08:00
|
|
|
|
console.log(`[更新] ${relPath}`);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(`读取更新文件失败:${path}`, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-02-26 14:14:26 +08:00
|
|
|
|
.on("unlink", (path) => {
|
2026-07-06 14:19:16 +08:00
|
|
|
|
if (
|
|
|
|
|
|
path.endsWith(".md") ||
|
|
|
|
|
|
path.endsWith(".csv") ||
|
|
|
|
|
|
path.endsWith(".yarn")
|
|
|
|
|
|
) {
|
2026-02-26 14:14:26 +08:00
|
|
|
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
2026-02-26 13:35:09 +08:00
|
|
|
|
delete contentIndex[relPath];
|
|
|
|
|
|
console.log(`[删除] ${relPath}`);
|
2026-07-06 16:50:45 +08:00
|
|
|
|
if (relPath.endsWith(".md")) recomputeCompletions();
|
2026-02-26 13:35:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-26 13:37:10 +08:00
|
|
|
|
|
2026-07-06 14:19:16 +08:00
|
|
|
|
// ---- Journal / MQTT broker ----
|
|
|
|
|
|
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
|
|
|
|
|
|
2026-07-06 15:30:21 +08:00
|
|
|
|
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
|
2026-02-26 13:37:10 +08:00
|
|
|
|
const handleRequest = createRequestHandler(
|
|
|
|
|
|
contentDir,
|
2026-03-18 15:00:13 +08:00
|
|
|
|
distPath,
|
2026-02-26 13:37:10 +08:00
|
|
|
|
() => contentIndex,
|
2026-07-06 16:50:45 +08:00
|
|
|
|
() => completionsIndex,
|
2026-02-26 13:37:10 +08:00
|
|
|
|
);
|
|
|
|
|
|
const server = createServer(handleRequest);
|
|
|
|
|
|
|
2026-07-06 15:30:21 +08:00
|
|
|
|
createJournalServer(contentDir, server).then((j) => {
|
|
|
|
|
|
journal = j;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-21 00:29:36 +08:00
|
|
|
|
server.listen(port, host, () => {
|
2026-07-07 23:48:04 +08:00
|
|
|
|
const bestIP = getBestIP();
|
|
|
|
|
|
const displayHost = host === "0.0.0.0" ? bestIP : host;
|
2026-03-21 00:29:36 +08:00
|
|
|
|
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
2026-07-07 23:48:04 +08:00
|
|
|
|
console.log(`本机访问:http://localhost:${port}`);
|
|
|
|
|
|
if (bestIP !== "localhost") {
|
|
|
|
|
|
console.log(`局域网访问:http://${bestIP}:${port}`);
|
|
|
|
|
|
}
|
2026-02-26 13:35:09 +08:00
|
|
|
|
console.log(`内容目录:${contentDir}`);
|
2026-03-18 15:00:13 +08:00
|
|
|
|
console.log(`静态资源目录:${distPath}`);
|
2026-07-07 23:48:04 +08:00
|
|
|
|
console.log(`Journal 连接:ws://${bestIP}:${port}\n`);
|
2026-02-26 13:35:09 +08:00
|
|
|
|
});
|
2026-03-18 15:00:13 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
server,
|
|
|
|
|
|
watcher,
|
|
|
|
|
|
index: contentIndex,
|
2026-07-06 16:50:45 +08:00
|
|
|
|
completions: completionsIndex,
|
2026-03-18 15:00:13 +08:00
|
|
|
|
close() {
|
|
|
|
|
|
console.log("关闭内容服务器...");
|
|
|
|
|
|
server.close();
|
|
|
|
|
|
watcher.close();
|
2026-07-06 14:19:16 +08:00
|
|
|
|
if (journal) journal.close();
|
2026-03-18 15:00:13 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 启动开发服务器
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
|
|
|
|
|
const contentDir = resolve(dir);
|
|
|
|
|
|
const port = parseInt(options.port, 10);
|
2026-03-21 00:29:36 +08:00
|
|
|
|
const host = options.host || "0.0.0.0";
|
2026-03-18 15:00:13 +08:00
|
|
|
|
|
2026-07-06 15:30:21 +08:00
|
|
|
|
createContentServer(contentDir, port, distDir, host);
|
2026-02-26 00:17:23 +08:00
|
|
|
|
};
|