/** * 本地静态开发服务器(用于跑**生产构建**做真实环境回归) * * ## 与 `npm start` 的分工 * * | 命令 | 用途 | * |---|---| * | `npm start` | `ng serve` + `proxy.conf.js`,日常开发,热更新 | * | `npm run serve:dist` | 托管 `dist/` 里的构建产物,**接近线上**的运行形态 | * | `npm run dev:logged-in` | 同上 + 自动注入登录态 → 打开即已登录,可自动化 | * * ## 为什么要单独一个服务器 * * `scripts/smoke-imhub.mjs` 里内联的静态服务只够冒烟用(无 SPA 回退以外的细节)。 * 手工回归时需要一个**能长期开着**的服务器,并且要能在不改源文件的前提下注入 * 登录态 —— 这就是本脚本的第二个职责(见 `scripts/dev-login.mjs`)。 * * ## 用法 * * ```bash * npm run build # 先构建 * npm run serve:dist # http://127.0.0.1:4300/?mode=desktop * npm run dev:logged-in # 同上,且自动登录 * PORT=4400 npm run serve:dist # 换端口 * ``` */ import { createServer } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, extname, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadDevLogin, injectIntoHtml } from './dev-login.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const distDir = resolve(root, 'dist/fmode-app-v2/browser'); const port = Number(process.env.PORT ?? 4300); const wantLogin = process.argv.includes('--login') || process.env.DEV_LOGIN === '1'; const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.ico': 'image/x-icon', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.map': 'application/json; charset=utf-8', }; if (!existsSync(distDir)) { console.error(`❌ 未找到构建产物:${distDir}\n 先执行 npm run build`); process.exit(2); } let login = { enabled: false, reason: '未启用(加 --login 或 DEV_LOGIN=1 才注入)' }; if (wantLogin) login = loadDevLogin(); if (wantLogin && !login.enabled) { console.error(`❌ 开发态登录未生效:${login.reason}`); console.error(` 按 docs/05-operations/开发态登录.md 填好 .claude/dev-login.json 后重试。`); process.exit(2); } /** 只在内存里改 HTML,不碰磁盘上的源文件 */ async function readIndex() { const html = await readFile(join(distDir, 'index.html'), 'utf8'); return login.enabled ? injectIntoHtml(html, login.payload) : html; } const server = createServer(async (req, res) => { try { const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]); const file = join(distDir, urlPath); // 目录穿越防护:解析后必须仍在 dist 内 if (!resolve(file).startsWith(distDir)) { res.writeHead(403).end('forbidden'); return; } let onDisk = false; try { onDisk = (await stat(file)).isFile(); } catch { onDisk = false; } if (onDisk && extname(file) !== '.html') { const body = await readFile(file); res.writeHead(200, { 'Content-Type': MIME[extname(file)] ?? 'application/octet-stream', // 本地调试时不缓存,避免改了构建产物还看到旧的 'Cache-Control': 'no-store', }); res.end(body); return; } // SPA 回退:所有路由都返回 index.html res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store' }); res.end(await readIndex()); } catch (e) { res.writeHead(500).end(`server error: ${e.message}`); } }); server.listen(port, '127.0.0.1', () => { const base = `http://127.0.0.1:${port}`; console.log(`静态服务:${base}`); console.log(`产物目录:${distDir}`); console.log( login.enabled ? `🔓 开发态登录已启用(用户:${login.payload.me.name || login.payload.me.id})—— 打开即已登录` : `🔒 未注入登录态:${login.reason}`, ); console.log(`\n 桌面端 ${base}/desktop/chat`); console.log(` 移动端 ${base}/mobile/messages\n`); });