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

238 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-02-26 13:35:09 +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 13:37:10 +08:00
import { join, resolve, extname, sep, relative } from 'path';
2026-02-26 13:35:09 +08:00
import { watch } from 'chokidar';
2026-02-26 00:17:23 +08:00
2026-02-26 13:35:09 +08:00
interface ContentIndex {
[path: string]: string;
}
2026-02-26 13:37:10 +08:00
/**
* 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';
}
2026-02-26 13:35:09 +08:00
/**
* .md .csv
*/
function scanDirectory(dir: string): ContentIndex {
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) {
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;
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);
} else if (entry.endsWith('.md') || entry.endsWith('.csv')) {
try {
const content = readFileSync(fullPath, 'utf-8');
index[normalizedRelPath] = content;
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
}
}
}
2026-02-26 13:37:10 +08:00
2026-02-26 13:35:09 +08:00
scan(dir, '');
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, {
'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);
2026-02-26 13:35:09 +08:00
};
}
/**
*
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
const distDir = resolve(process.cwd(), 'dist/web');
let contentIndex: ContentIndex = {};
2026-02-26 13:37:10 +08:00
2026-02-26 13:35:09 +08:00
// 扫描内容目录生成索引
console.log('扫描内容目录...');
contentIndex = scanDirectory(contentDir);
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
2026-02-26 13:37:10 +08:00
2026-02-26 13:35:09 +08:00
// 监听文件变化
console.log('监听文件变化...');
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\../,
persistent: true,
ignoreInitial: true,
});
2026-02-26 13:37:10 +08:00
2026-02-26 13:35:09 +08:00
watcher
.on('add', (path) => {
if (path.endsWith('.md') || path.endsWith('.csv')) {
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') || path.endsWith('.csv')) {
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') || path.endsWith('.csv')) {
const relPath = '/' + relative(contentDir, path).split(sep).join('/');
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
}
});
2026-02-26 13:37:10 +08:00
// 创建请求处理器
const handleRequest = createRequestHandler(
contentDir,
distDir,
() => contentIndex,
);
2026-02-26 13:35:09 +08:00
// 创建 HTTP 服务器
2026-02-26 13:37:10 +08:00
const server = createServer(handleRequest);
2026-02-26 13:35:09 +08:00
const port = parseInt(options.port, 10);
2026-02-26 13:37:10 +08:00
2026-02-26 13:35:09 +08:00
server.listen(port, () => {
console.log(`\n开发服务器已启动http://localhost:${port}`);
console.log(`内容目录:${contentDir}`);
console.log(`索引文件http://localhost:${port}/__CONTENT_INDEX.json\n`);
});
2026-02-26 00:17:23 +08:00
};