diff --git a/apps/preview-server/server.d.mts b/apps/preview-server/server.d.mts index 95e4651..5a95ffd 100644 --- a/apps/preview-server/server.d.mts +++ b/apps/preview-server/server.d.mts @@ -29,6 +29,8 @@ export interface PreviewServerOptions { host?: string /** 监听端口(缺省 4173;0 = 随机可用端口) */ port?: number + /** 静默逐请求访问日志(缺省 false——逐请求一行 stdout) */ + quiet?: boolean } export interface PreviewServerHandle { diff --git a/apps/preview-server/server.mjs b/apps/preview-server/server.mjs index c6e5835..f4bc88c 100644 --- a/apps/preview-server/server.mjs +++ b/apps/preview-server/server.mjs @@ -3,17 +3,21 @@ // 不写盘、零新依赖(node:http)。行为面: // - 匹配器:URL 解析 pathname + search;query_policy 处理——exact 行(query_key 非空) // 以 `pathname+search` 全串匹配且优先于 ignore 行;ignore 行剥 query 按 path 匹配。 -// html/asset → target_file 文件服务(manifest content_type 优先,缺席走扩展名 MIME -// 表,未知扩展 application/octet-stream);redirect → status+Location;gone → 410; -// dynamic/unresolved → 404(静态产物无对应文件);无匹配 → 404(dist/404.html 自定义页)。 +// html/asset → target_file 文件服务(**流式**:createReadStream 直pipe,Content-Length +// 取自先行 stat——大文件不驻留内存;HEAD 只发头零读盘;manifest content_type 优先, +// 缺席走扩展名 MIME 表,未知扩展 application/octet-stream);redirect → status+Location; +// gone → 410;dynamic/unresolved → 404(静态产物无对应文件);无匹配 → 404(dist/ +// 404.html 自定义页)。 // - 安全:回环绑定(host 仅 127.0.0.1/localhost,库级即拒);路径穿越预拒(反斜杠/ // 编码形态/.. 段)+ target_file resolve 后必须在 dist 内(lexical + realpath 双检); // 目录即 404(禁目录列表);仅 GET/HEAD(其余 405)。 // - 头纪律:全响应 X-Robots-Tag: noindex, nofollow;text/html 响应附基础 CSP -// (default-src 'self')。请求日志一行(method/path/status/耗时)到 stdout。 +// (default-src 'self')。请求日志一行(method/path/status/耗时)到 stdout +// (quiet: true 静默)。 // // 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成 // 测试)的类型面由相邻 server.d.mts 声明供给(NodeNext 解析自动拾取)。 +import { createReadStream } from 'node:fs' import { createServer } from 'node:http' import { readFile, realpath, stat } from 'node:fs/promises' import { extname, join, resolve, sep } from 'node:path' @@ -72,8 +76,13 @@ function contentTypeForExt(file) { return MIME_BY_EXT.get(extname(file).toLowerCase()) ?? 'application/octet-stream' } -/** 统一响应出口:全响应 noindex 头;text/html 附 CSP;HEAD 不写 body(Content-Length 仍按实体设置) */ -function send(res, status, contentType, body, isHead, extraHeaders = undefined) { +/** HTML 文本转义(redirect 迁移页内嵌 location 用——Location 头本身保持原样) */ +function escapeHtml(s) { + return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') +} + +/** 头纪律唯一出口:全响应 noindex;text/html 附 CSP;扩展头(Location/Allow 等) */ +function applyHeaders(res, status, contentType, extraHeaders = undefined) { res.statusCode = status res.setHeader('X-Robots-Tag', X_ROBOTS_TAG) if (contentType.startsWith('text/html')) res.setHeader('Content-Security-Policy', CSP) @@ -81,6 +90,11 @@ function send(res, status, contentType, body, isHead, extraHeaders = undefined) for (const [name, value] of Object.entries(extraHeaders)) res.setHeader(name, value) } res.setHeader('Content-Type', contentType) +} + +/** 固定小体响应出口(404/410/405/redirect 页):HEAD 不写 body(Content-Length 仍按实体设置) */ +function send(res, status, contentType, body, isHead, extraHeaders = undefined) { + applyHeaders(res, status, contentType, extraHeaders) res.setHeader('Content-Length', String(Buffer.byteLength(body))) res.end(isHead ? undefined : body) } @@ -95,8 +109,9 @@ const GONE_HTML = htmlDocument('410 Gone', '此内容已被永久移除(route- * 创建预览服务器(不监听——start() 才绑定)。返回 {server, start, stop}: * start() → Promise(port=0 时返回实际端口);stop() 关连接再关实例 * (closeAllConnections 防 keep-alive 空闲套接字吊住 close)。 + * quiet: true 静默逐请求访问日志(缺省逐请求一行 stdout)。 */ -export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1', port = 4173 }) { +export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1', port = 4173, quiet = false }) { if (!LOOPBACK_HOSTS.has(host)) { throw new Error(`预览服务器只允许回环绑定(127.0.0.1 / localhost),拒绝 host=${host}`) } @@ -172,14 +187,27 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1' await sendNotFound(res, isHead) return 404 } - const body = await readFile(realAbs) const contentType = typeof row.content_type === 'string' && row.content_type !== '' ? row.content_type.startsWith('text/html') ? 'text/html; charset=utf-8' : row.content_type : contentTypeForExt(rel) - send(res, row.status || 200, contentType, body, isHead) + // 流式服务:Content-Length 取自先行 stat(body 不经内存驻留——大媒体文件 RSS 平坦); + // HEAD 只发头零读盘(Content-Length 仍按实体设置,符合语义) + applyHeaders(res, row.status || 200, contentType) + res.setHeader('Content-Length', String(st.size)) + if (isHead) { + res.end() + return row.status || 200 + } + createReadStream(realAbs) + .on('error', (err) => { + // 头已发出无法改码——掐断连接(客户端见截断传输);进程不因未处理 error 崩 + console.error(`[preview] 文件流失败 ${rel}:`, err) + res.destroy() + }) + .pipe(res) return row.status || 200 } @@ -215,7 +243,9 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1' if (row.kind === 'redirect') { const location = typeof row.location === 'string' ? row.location : '/' const status = row.status >= 300 && row.status <= 399 ? row.status : 301 - send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:${location}`), isHead, { + // Location 头原样;HTML 迁移页内嵌时转义(${safeLocation}`), isHead, { Location: location, }) return status @@ -233,7 +263,9 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1' const startedAt = performance.now() dispatch(req, res) .then((status) => { - console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`) + if (!quiet) { + console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`) + } }) .catch((err) => { // 兜底:文件读取中途失败等未预期错误——500(若头未发出) @@ -275,9 +307,16 @@ function parseCliArgs(argv) { for (let i = 0; i < argv.length; i++) { const a = argv[i] if (a === '--dist' || a === '--manifest' || a === '--port' || a === '--host') { - out[a.slice(2)] = argv[++i] + const value = argv[i + 1] + if (value === undefined) { + throw new Error(`参数 ${a} 缺值(可用:--dist --manifest [--port 4173] [--host 127.0.0.1] [--quiet])`) + } + out[a.slice(2)] = value + i++ + } else if (a === '--quiet') { + out.quiet = true } else { - throw new Error(`未知参数:${a}(可用:--dist --manifest [--port 4173] [--host 127.0.0.1])`) + throw new Error(`未知参数:${a}(可用:--dist --manifest [--port 4173] [--host 127.0.0.1] [--quiet])`) } } return out @@ -286,7 +325,7 @@ function parseCliArgs(argv) { async function main() { const args = parseCliArgs(process.argv.slice(2)) if (typeof args.dist !== 'string' || typeof args.manifest !== 'string') { - throw new Error('用法:node server.mjs --dist --manifest [--port 4173] [--host 127.0.0.1]') + throw new Error('用法:node server.mjs --dist --manifest [--port 4173] [--host 127.0.0.1] [--quiet]') } const port = args.port === undefined ? 4173 : Number(args.port) if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`--port 非法(0-65535 整数):${args.port}`) @@ -296,6 +335,7 @@ async function main() { routeManifest: manifest, host: args.host ?? '127.0.0.1', port, + quiet: args.quiet === true, }) const addr = await preview.start() console.log(`[preview] dist=${resolve(args.dist)} routes=${manifest.routes.length}`) diff --git a/scripts/sf.ts b/scripts/sf.ts index 8411765..1731011 100644 --- a/scripts/sf.ts +++ b/scripts/sf.ts @@ -358,7 +358,8 @@ program .requiredOption('--build ', '构建 job id(须 succeeded——dist 产物只在成功终态存在)') .option('--port ', '监听端口(0 = 随机可用端口)', '4173') .option('--host ', '绑定地址——仅允许 127.0.0.1 / localhost(回环纪律,外值拒绝)', '127.0.0.1') - .action(async (opts: { build: string; port: string; host: string }) => { + .option('--quiet', '静默逐请求访问日志(缺省逐请求一行 stdout)') + .action(async (opts: { build: string; port: string; host: string; quiet?: boolean }) => { // 回环校验先于一切(含 DB 访问)——预览永不对外,外值在触碰任何资源前拒绝 if (opts.host !== '127.0.0.1' && opts.host !== 'localhost') { throw new Error(`--host 仅允许 127.0.0.1 / localhost(回环绑定纪律,外值拒绝):${opts.host}`) @@ -404,7 +405,13 @@ program } // server.mjs 是纯 JS 包(零依赖、不进 TS 链)——动态导入直连源文件,类型面由 server.d.mts 供给 const { createPreviewServer } = await import('../apps/preview-server/server.mjs') - const preview = createPreviewServer({ distDir: distPath, routeManifest: manifestRaw, host: opts.host, port }) + const preview = createPreviewServer({ + distDir: distPath, + routeManifest: manifestRaw, + host: opts.host, + port, + quiet: opts.quiet === true, + }) const addr = await preview.start() console.log(`[preview:start] job=${opts.build}`) console.log(` dist: ${distPath}`) diff --git a/tests/integration/preview-server.test.ts b/tests/integration/preview-server.test.ts index 89ed5dc..5626a96 100644 --- a/tests/integration/preview-server.test.ts +++ b/tests/integration/preview-server.test.ts @@ -15,7 +15,9 @@ // 9. 目录列表禁用(dist 子目录请求 → 404,不出文件清单) // 10. HEAD 方法支持(200 同头无 body) // 11. CLI:--host 外值拒绝(校验先于 DB 访问——tsx 直跑 sf.ts) -// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停) +// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停; +// --quiet 透传——子进程 stdout 无逐请求访问日志) +// 13. quiet 开关:quiet:true 静默逐请求日志;缺省(verbose)逐请求一行 stdout import { execFile, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -23,7 +25,7 @@ import { request } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import type { RouteManifest, RouteManifestRoute } from '@sf/contracts' import { createBuildJob } from '../../apps/api/src/modules/build/build.service.js' import { createImportJob } from '../../apps/api/src/modules/import/import.service.js' @@ -353,7 +355,7 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () const jobId = manifest!.build_job_id const child = spawn( process.execPath, - [TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0'], + [TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0', '--quiet'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }, ) let stdout = '' @@ -379,6 +381,9 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () expect(res.status).toBe(200) expect(res.headers.get('x-robots-tag')).toBe('noindex, nofollow') expect(await res.text()).toStrictEqual(await readFile(join(distDir, 'index.html'), 'utf8')) + // --quiet 透传:服务过请求后子进程 stdout 仍无逐请求访问日志行 + await new Promise((r) => setTimeout(r, 500)) + expect(stdout).not.toMatch(/\[preview\] (GET|HEAD)/) } finally { child.kill() await new Promise((resolveP) => { @@ -390,4 +395,26 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () }) } }) + + it('13. quiet 开关:quiet:true 静默逐请求日志;缺省 verbose 逐请求一行', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + const previewLines = (): number => logSpy.mock.calls.filter((c) => String(c[0]).includes('[preview] ')).length + // quiet 实例:请求零访问日志 + const quietServer = createPreviewServer({ distDir, routeManifest: manifest!, port: 0, quiet: true }) + const addr = await quietServer.start() + try { + expect((await fetch(`http://127.0.0.1:${addr.port}/`)).status).toBe(200) + expect(previewLines()).toBe(0) + } finally { + await quietServer.stop() + } + // 缺省 verbose 对照(beforeAll 主服务):请求恰一行访问日志 + const before = previewLines() + await get('/') + expect(previewLines()).toBe(before + 1) + } finally { + logSpy.mockRestore() + } + }) })