2026-10-22 13:29:30 +08:00
|
|
|
|
// 本地预览服务器(Plan 4 Task 7,V2 spec §12.2):路由驱动的静态 dist 服务——
|
|
|
|
|
|
// 只读 <artifactsRoot>/jobs/<jobId>/dist + route-manifest.json 两个输入,不查库、
|
|
|
|
|
|
// 不写盘、零新依赖(node:http)。行为面:
|
|
|
|
|
|
// - 匹配器:URL 解析 pathname + search;query_policy 处理——exact 行(query_key 非空)
|
|
|
|
|
|
// 以 `pathname+search` 全串匹配且优先于 ignore 行;ignore 行剥 query 按 path 匹配。
|
2026-10-22 13:58:15 +08:00
|
|
|
|
// html/asset → target_file 文件服务(**流式**:createReadStream 直pipe,Content-Length
|
|
|
|
|
|
// 取自先行 stat——大文件不驻留内存;HEAD 只发头零读盘;manifest content_type 优先,
|
2026-10-22 14:33:55 +08:00
|
|
|
|
// 缺席走扩展名 MIME 表,未知扩展 application/octet-stream);redirect → status+Location
|
|
|
|
|
|
// (**头域 ASCII 化**:manifest location 含非 ASCII 码位(如全角括号/间隔号)时按
|
|
|
|
|
|
// UTF-8 百分号编码后发头——RFC 9110 头域是 ASCII,原样 setHeader 会 ERR_INVALID_CHAR
|
|
|
|
|
|
// 崩成 500 或被对端按 latin1 误解码;manifest 原串保持权威,线上形态是派生面);
|
2026-10-22 13:58:15 +08:00
|
|
|
|
// gone → 410;dynamic/unresolved → 404(静态产物无对应文件);无匹配 → 404(dist/
|
|
|
|
|
|
// 404.html 自定义页)。
|
2026-10-22 13:29:30 +08:00
|
|
|
|
// - 安全:回环绑定(host 仅 127.0.0.1/localhost,库级即拒);路径穿越预拒(反斜杠/
|
|
|
|
|
|
// 编码形态/.. 段)+ target_file resolve 后必须在 dist 内(lexical + realpath 双检);
|
|
|
|
|
|
// 目录即 404(禁目录列表);仅 GET/HEAD(其余 405)。
|
|
|
|
|
|
// - 头纪律:全响应 X-Robots-Tag: noindex, nofollow;text/html 响应附基础 CSP
|
2026-10-22 14:33:55 +08:00
|
|
|
|
// (default-src 'self'; style-src 'self' 'unsafe-inline'——Astro 内联 scoped 样式放行,
|
|
|
|
|
|
// 见常量处注记)。请求日志一行(method/path/status/耗时)到 stdout
|
2026-10-22 13:58:15 +08:00
|
|
|
|
// (quiet: true 静默)。
|
2026-10-22 13:29:30 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成
|
|
|
|
|
|
// 测试)的类型面由相邻 server.d.mts 声明供给(NodeNext 解析自动拾取)。
|
2026-10-22 13:58:15 +08:00
|
|
|
|
import { createReadStream } from 'node:fs'
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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'
|
2026-10-22 14:33:55 +08:00
|
|
|
|
// CSP(Task 8 浏览器验收实证修补):Astro 5 静态产物把 scoped 样式内联为 <style> 元素
|
|
|
|
|
|
// (无外部样式表)——default-src 'self' 会拦掉全部内联样式,页面在浏览器里裸奔(HTTP
|
|
|
|
|
|
// 级测试看不到,浏览器时刻才显影)。style-src 放开 'unsafe-inline'(仅样式面;脚本/
|
|
|
|
|
|
// 连接/图片等仍走 default-src 'self')。
|
|
|
|
|
|
const CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'"
|
2026-10-22 13:29:30 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 请求路径安全预检(**先于 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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 14:33:55 +08:00
|
|
|
|
/** HTML 文本转义(redirect 迁移页内嵌 location 用——页面体内嵌原文,头走 asciiLocation) */
|
2026-10-22 13:58:15 +08:00
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|
|
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 14:33:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Location 头 ASCII 化(Task 8 全量 1746 redirect 校验实证修补):非 ASCII 码位按
|
|
|
|
|
|
* UTF-8 百分号编码(encodeURIComponent 单码位即该码位的 UTF-8 八位组,hex 大写——
|
|
|
|
|
|
* 与 WP/nginx 的百分号编码形态一致);可打印 ASCII(含既有 %xx 序列的字面字符)原样。
|
|
|
|
|
|
* 控制字符/CR/LF 同路编码——头注入面封死(值内裸 % 后非 hex 也只是保留字面,不解码)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function asciiLocation(value) {
|
|
|
|
|
|
let out = ''
|
|
|
|
|
|
for (const ch of value) {
|
|
|
|
|
|
const code = ch.codePointAt(0)
|
|
|
|
|
|
out += code >= 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 13:58:15 +08:00
|
|
|
|
/** 头纪律唯一出口:全响应 noindex;text/html 附 CSP;扩展头(Location/Allow 等) */
|
|
|
|
|
|
function applyHeaders(res, status, contentType, extraHeaders = undefined) {
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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)
|
2026-10-22 13:58:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 固定小体响应出口(404/410/405/redirect 页):HEAD 不写 body(Content-Length 仍按实体设置) */
|
|
|
|
|
|
function send(res, status, contentType, body, isHead, extraHeaders = undefined) {
|
|
|
|
|
|
applyHeaders(res, status, contentType, extraHeaders)
|
2026-10-22 13:29:30 +08:00
|
|
|
|
res.setHeader('Content-Length', String(Buffer.byteLength(body)))
|
|
|
|
|
|
res.end(isHead ? undefined : body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function htmlDocument(title, bodyText) {
|
|
|
|
|
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/><title>${title}</title></head><body><h1>${title}</h1><p>${bodyText}</p></body></html>\n`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const GONE_HTML = htmlDocument('410 Gone', '此内容已被永久移除(route-manifest kind=gone)。')
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建预览服务器(不监听——start() 才绑定)。返回 {server, start, stop}:
|
|
|
|
|
|
* start() → Promise<AddressInfo>(port=0 时返回实际端口);stop() 关连接再关实例
|
|
|
|
|
|
* (closeAllConnections 防 keep-alive 空闲套接字吊住 close)。
|
2026-10-22 13:58:15 +08:00
|
|
|
|
* quiet: true 静默逐请求访问日志(缺省逐请求一行 stdout)。
|
2026-10-22 13:29:30 +08:00
|
|
|
|
*/
|
2026-10-22 13:58:15 +08:00
|
|
|
|
export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1', port = 4173, quiet = false }) {
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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 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)
|
2026-10-22 13:58:15 +08:00
|
|
|
|
// 流式服务: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)
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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
|
2026-10-22 14:33:55 +08:00
|
|
|
|
// Location 头 ASCII 化后发(manifest 原串含全角括号/间隔号等非 ASCII 时原样发头会
|
|
|
|
|
|
// ERR_INVALID_CHAR 崩 500——Task 8 全量 redirect 校验实证);HTML 迁移页内嵌原文转义
|
2026-10-22 13:58:15 +08:00
|
|
|
|
const safeLocation = escapeHtml(location)
|
|
|
|
|
|
send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:<a href="${safeLocation}">${safeLocation}</a>`), isHead, {
|
2026-10-22 14:33:55 +08:00
|
|
|
|
Location: asciiLocation(location),
|
2026-10-22 13:29:30 +08:00
|
|
|
|
})
|
|
|
|
|
|
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) => {
|
2026-10-22 13:58:15 +08:00
|
|
|
|
if (!quiet) {
|
|
|
|
|
|
console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`)
|
|
|
|
|
|
}
|
2026-10-22 13:29:30 +08:00
|
|
|
|
})
|
|
|
|
|
|
.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') {
|
2026-10-22 13:58:15 +08:00
|
|
|
|
const value = argv[i + 1]
|
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
|
throw new Error(`参数 ${a} 缺值(可用:--dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1] [--quiet])`)
|
|
|
|
|
|
}
|
|
|
|
|
|
out[a.slice(2)] = value
|
|
|
|
|
|
i++
|
|
|
|
|
|
} else if (a === '--quiet') {
|
|
|
|
|
|
out.quiet = true
|
2026-10-22 13:29:30 +08:00
|
|
|
|
} else {
|
2026-10-22 13:58:15 +08:00
|
|
|
|
throw new Error(`未知参数:${a}(可用:--dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1] [--quiet])`)
|
2026-10-22 13:29:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
|
const args = parseCliArgs(process.argv.slice(2))
|
|
|
|
|
|
if (typeof args.dist !== 'string' || typeof args.manifest !== 'string') {
|
2026-10-22 13:58:15 +08:00
|
|
|
|
throw new Error('用法:node server.mjs --dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1] [--quiet]')
|
2026-10-22 13:29:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-10-22 13:58:15 +08:00
|
|
|
|
quiet: args.quiet === true,
|
2026-10-22 13:29:30 +08:00
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|