import { Request, Response } from "express"; import http from "http"; const VITE_HOST = "127.0.0.1"; const VITE_PORT = 5199; // 这些 header 需要从代理响应中剔除(hop-by-hop) const HOP_BY_HOP = new Set([ "transfer-encoding", "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "te", "trailers", "upgrade", ]); /** * 将 /preview/* 请求代理到 Vite dev server。 * 对 HTML 响应自动重写路径,给所有根绝对路径加上 /ads/preview/ 前缀, * 确保浏览器通过 nginx 正确加载资源。 */ export function previewProxy(req: Request, res: Response): void { // Express mount 会剥离 /preview 前缀,req.url 即 Vite 需要的路径 const targetPath = req.url || "/"; console.log(`[preview:proxy] ${req.method} ${targetPath}`); // 过滤请求 headers(去掉 hop-by-hop、条件缓存头,设置正确的 host) const reqHeaders: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if (value === undefined) continue; const lk = key.toLowerCase(); if (lk === "host") continue; if (lk === "connection" || lk === "keep-alive" || lk === "upgrade") continue; // 强制完整响应,避免 304 缓存导致 HTML 无法被重写 if (lk === "if-none-match" || lk === "if-modified-since" || lk === "cache-control") continue; reqHeaders[key] = Array.isArray(value) ? value.join(", ") : value; } reqHeaders["host"] = `${VITE_HOST}:${VITE_PORT}`; reqHeaders["cache-control"] = "no-cache"; const proxyReq = http.request( { hostname: VITE_HOST, port: VITE_PORT, path: targetPath, method: req.method, headers: reqHeaders, }, (proxyRes) => { const contentType = (proxyRes.headers["content-type"] || "") as string; const isText = contentType.includes("text/html") || contentType.includes("text/javascript") || contentType.includes("application/javascript"); console.log(`[preview:proxy] response ${proxyRes.statusCode} content-type=${contentType} isText=${isText}`); // 复制响应 headers(过滤 hop-by-hop) const resHeaders: Record = {}; for (const [key, value] of Object.entries(proxyRes.headers)) { if (HOP_BY_HOP.has(key.toLowerCase())) continue; if (value !== undefined && value !== null) { resHeaders[key] = Array.isArray(value) ? value.join(", ") : value; } } res.status(proxyRes.statusCode || 200); if (isText) { const chunks: Buffer[] = []; proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk)); proxyRes.on("end", () => { let body = Buffer.concat(chunks).toString("utf-8"); // 重写 HTML (src="/...", href="/...") 和 JS (from "/...", import "/...") // 为 /ads/preview/...(跳过已有前缀的,支持单双引号) body = body.replace( /((?:src|href)=["']|(?:from|import)\s*["'])\/(?!ads\/preview\/)([^"']+)(["'])/g, '$1/ads/preview/$2$3', ); // 日志 if (contentType.includes("text/html")) { const viteClientMatch = body.match(/src="[^"]*@vite\/client[^"]*"/); console.log(`[preview:proxy] HTML vite/client: ${viteClientMatch?.[0] || "NOT FOUND"}`); } resHeaders["content-type"] = contentType.includes("text/html") ? "text/html; charset=utf-8" : contentType; resHeaders["content-length"] = String(Buffer.byteLength(body, "utf-8")); res.set(resHeaders); res.send(body); }); } else { res.set(resHeaders); proxyRes.pipe(res); } }, ); proxyReq.on("error", (err) => { console.error(`[preview:proxy] ${err.message}`); if (!res.headersSent) { res.status(502).json({ error: "Preview server not available" }); } }); // POST/PUT/PATCH 请求需要转发 body if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") { req.pipe(proxyReq); } else { proxyReq.end(); } }