// 本地预览服务器(Plan 4 Task 7,V2 spec §12.2):路由驱动的静态 dist 服务—— // 只读 /jobs//dist + route-manifest.json 两个输入,不查库、 // 不写盘、零新依赖(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 自定义页)。 // - 安全:回环绑定(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。 // // 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成 // 测试)的类型面由相邻 server.d.mts 声明供给(NodeNext 解析自动拾取)。 import { createServer } from 'node:http' import { readFile, realpath, stat } from 'node:fs/promises' import { extname, join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' /** 扩展名 → Content-Type(manifest content_type 缺席时的兜底映射;未列出 → octet-stream) */ const MIME_BY_EXT = new Map([ ['.html', 'text/html'], ['.htm', 'text/html'], ['.css', 'text/css'], ['.js', 'text/javascript'], ['.mjs', 'text/javascript'], ['.json', 'application/json'], ['.xml', 'application/xml'], ['.txt', 'text/plain'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'], ['.webp', 'image/webp'], ['.svg', 'image/svg+xml'], ['.ico', 'image/x-icon'], ['.pdf', 'application/pdf'], ['.woff', 'font/woff'], ['.woff2', 'font/woff2'], ]) /** 回环绑定白名单(§12.2:预览永不对外)——外值在服务构造点即拒(先于监听) */ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost']) const X_ROBOTS_TAG = 'noindex, nofollow' const CSP = "default-src 'self'" /** * 请求路径安全预检(**先于 URL 解析**——new URL 会把 '/../' 规范化到 '/',裸点段 * 必须在规范化前拦截):反斜杠(裸/编码)、NUL、解码失败的百分号串、解码后的 * '..' 段一律拒绝。解码同时把 %2f/%5c 还原为分隔符再切分——'..%5c'、'%2e%2e%2f' * 等编码形态与裸 '..' 同罪。解码只用于**检测**:路由匹配始终用原样(未解码) * pathname,与 route-manifest exact_key 的编码形式(如 %e5%85%b3…)逐字节对齐。 */ function isUnsafeRequestPath(rawUrl) { const pathPart = rawUrl.split('#')[0].split('?')[0] if (pathPart.includes('\\')) return true if (pathPart.includes('\0')) return true let decoded try { decoded = decodeURIComponent(pathPart) } catch { return true // 孤立 % 等非法编码——拒绝(不猜意图) } if (decoded.includes('\\') || decoded.includes('\0')) return true return decoded.split(/[\\/]/).includes('..') } 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) { res.statusCode = status res.setHeader('X-Robots-Tag', X_ROBOTS_TAG) if (contentType.startsWith('text/html')) res.setHeader('Content-Security-Policy', CSP) if (extraHeaders !== undefined) { for (const [name, value] of Object.entries(extraHeaders)) res.setHeader(name, value) } res.setHeader('Content-Type', contentType) res.setHeader('Content-Length', String(Buffer.byteLength(body))) res.end(isHead ? undefined : body) } function htmlDocument(title, bodyText) { return `${title}

${title}

${bodyText}

\n` } const GONE_HTML = htmlDocument('410 Gone', '此内容已被永久移除(route-manifest kind=gone)。') /** * 创建预览服务器(不监听——start() 才绑定)。返回 {server, start, stop}: * start() → Promise(port=0 时返回实际端口);stop() 关连接再关实例 * (closeAllConnections 防 keep-alive 空闲套接字吊住 close)。 */ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1', port = 4173 }) { if (!LOOPBACK_HOSTS.has(host)) { throw new Error(`预览服务器只允许回环绑定(127.0.0.1 / localhost),拒绝 host=${host}`) } if (typeof distDir !== 'string' || distDir === '') { throw new Error('distDir 缺席——预览需要构建产物目录') } if (typeof routeManifest !== 'object' || routeManifest === null || !Array.isArray(routeManifest.routes)) { throw new Error('routeManifest 形状非法(须含 routes 数组)——预览需要 §12.1 route-manifest') } const distRoot = resolve(distDir) // 匹配索引(构造期一次构建;manifest 行序权威——重复键首个胜出) const exactByKey = new Map() // query_key 非空的行:exact_key = `path?query` const byPath = new Map() // query_key 空的行:exact_key 即 path for (const row of routeManifest.routes) { if (typeof row?.exact_key !== 'string' || row.exact_key === '') continue const target = row.query_key != null || row.exact_key.includes('?') ? exactByKey : byPath if (!target.has(row.exact_key)) target.set(row.exact_key, row) } // realpath 根缓存(首个文件请求时解析一次;dist 根须真实存在,否则一切 404) let realDistRootPromise = null function realDistRoot() { if (realDistRootPromise === null) realDistRootPromise = realpath(distRoot) return realDistRootPromise } // 404 自定义页缓存(首个 404 时读取一次;缺席/不可读回退纯文本) let notFoundCache = undefined async function notFoundResponse() { if (notFoundCache === undefined) { try { notFoundCache = { body: await readFile(join(distRoot, '404.html')), contentType: 'text/html; charset=utf-8' } } catch { notFoundCache = { body: '404 Not Found', contentType: 'text/plain; charset=utf-8' } } } return notFoundCache } async function sendNotFound(res, isHead) { const page = await notFoundResponse() send(res, 404, page.contentType, page.body, isHead) } /** html/asset 行文件服务:target_file 必须解析在 dist 内(lexical + realpath 双检)且是文件 */ async function serveFile(res, row, isHead) { const rel = typeof row.target_file === 'string' ? row.target_file.replace(/^\/+/, '') : '' if (rel === '' || rel.includes('\\')) { await sendNotFound(res, isHead) return 404 } const abs = resolve(distRoot, rel) if (abs !== distRoot && !abs.startsWith(distRoot + sep)) { await sendNotFound(res, isHead) return 404 } let realAbs let root try { ;[realAbs, root] = await Promise.all([realpath(abs), realDistRoot()]) } catch { await sendNotFound(res, isHead) return 404 } if (realAbs !== root && !realAbs.startsWith(root + sep)) { await sendNotFound(res, isHead) return 404 } const st = await stat(realAbs) if (!st.isFile()) { // 目录(或任何非文件)一律 404——禁目录列表 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) return row.status || 200 } async function dispatch(req, res) { const isHead = req.method === 'HEAD' if (req.method !== 'GET' && !isHead) { send(res, 405, 'text/plain; charset=utf-8', '405 Method Not Allowed(预览仅支持 GET/HEAD)', isHead, { Allow: 'GET, HEAD', }) return 405 } let url try { // 穿越预检必须先于 URL 解析(见 isUnsafeRequestPath 头注记) if (req.url === undefined || req.url === '' || isUnsafeRequestPath(req.url)) { await sendNotFound(res, isHead) return 404 } url = new URL(req.url, 'http://preview.local') } catch { await sendNotFound(res, isHead) return 404 } // 匹配:带查询时先试 exact 全串(query_key 行),未中再剥查询按 path(ignore 行) let row = null if (url.search !== '') row = exactByKey.get(url.pathname + url.search) ?? null if (row === null) row = byPath.get(url.pathname) ?? null if (row === null) { await sendNotFound(res, isHead) return 404 } if (row.kind === 'html' || row.kind === 'asset') return serveFile(res, row, isHead) 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: location, }) return status } if (row.kind === 'gone') { send(res, 410, 'text/html; charset=utf-8', GONE_HTML, isHead) return 410 } // dynamic/unresolved:静态 dist 无对应产物——按未命中处理 await sendNotFound(res, isHead) return 404 } const server = createServer((req, res) => { const startedAt = performance.now() dispatch(req, res) .then((status) => { console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`) }) .catch((err) => { // 兜底:文件读取中途失败等未预期错误——500(若头未发出) console.error(`[preview] internal error ${req.method} ${req.url}:`, err) if (!res.headersSent) { send(res, 500, 'text/plain; charset=utf-8', '500 Internal Server Error', false) } else { res.destroy() } }) }) function start() { return new Promise((resolveStart, rejectStart) => { const onError = (err) => rejectStart(err) server.once('error', onError) server.listen(port, host, () => { server.off('error', onError) resolveStart(server.address()) }) }) } function stop() { return new Promise((resolveStop) => { // 先destroy连接再close:undici keep-alive 空闲套接字会吊住 close() 回调 if (typeof server.closeAllConnections === 'function') server.closeAllConnections() server.close(() => resolveStop()) }) } return { server, start, stop } } // ---- 直跑入口(pnpm --filter @sf/preview-server start -- --dist … --manifest …) ---- function parseCliArgs(argv) { const out = {} 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] } else { throw new Error(`未知参数:${a}(可用:--dist --manifest [--port 4173] [--host 127.0.0.1])`) } } return out } 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]') } 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}`) const manifest = JSON.parse(await readFile(args.manifest, 'utf8')) const preview = createPreviewServer({ distDir: args.dist, routeManifest: manifest, host: args.host ?? '127.0.0.1', port, }) const addr = await preview.start() console.log(`[preview] dist=${resolve(args.dist)} routes=${manifest.routes.length}`) console.log(`[preview] listening http://${addr.address}:${addr.port}/ (Ctrl-C 停止)`) const shutdown = () => { void preview.stop().then(() => process.exit(0)) } process.once('SIGINT', shutdown) process.once('SIGTERM', shutdown) } const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href if (invokedDirectly) { main().catch((err) => { console.error(`[preview] ${err instanceof Error ? err.message : String(err)}`) process.exitCode = 1 }) }