import http from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Expose only built assets and the application API, with a temporary password.
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../apps/web/dist');
export function createPreviewServer({password=process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url')}={}) {
const sessions = new Map();
let attempts = [];
const equal = (a, b) => { const x=Buffer.from(a), y=Buffer.from(b); return x.length===y.length && timingSafeEqual(x,y); };
const loginPage = (failed=false) => `
Beryl Agent · 测试登录Beryl Agent
受密码保护的临时测试入口
${failed?'密码不正确,请重试。
':''}仅供受邀测试者使用。登录有效期 8 小时。
`;
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
return http.createServer(async (req, res) => {
res.setHeader('Cache-Control','no-store');
res.setHeader('X-Content-Type-Options','nosniff');
res.setHeader('Referrer-Policy','no-referrer');
res.setHeader('X-Frame-Options','DENY');
const externalLoginNavigation=req.method==='GET' && ['/', '/__preview/login'].includes(req.url) && req.headers['sec-fetch-site']==='cross-site' && req.headers['sec-fetch-dest']==='document';
if(externalLoginNavigation) {res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end(loginPage());}
if ((req.headers.origin && req.headers.origin !== `https://${req.headers.host}`) || req.headers['sec-fetch-site']==='cross-site' || (!['GET','HEAD'].includes(req.method) && req.headers.origin !== `https://${req.headers.host}`)) {
res.writeHead(403); return res.end('Origin denied');
}
let pathname;
try { pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); }
catch { res.writeHead(400); return res.end(); }
if(pathname==='/__preview/login' && req.method==='POST') {
attempts=attempts.filter(t=>Date.now()-t<60000);
if(attempts.length>=10) {res.writeHead(429,{'Retry-After':'60'});return res.end('Too many attempts; retry in one minute');}
attempts.push(Date.now());
if(!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) {res.writeHead(415);return res.end();}
let body='';
try {for await(const chunk of req) {body+=chunk;if(Buffer.byteLength(body)>2048){res.writeHead(413);res.end();return;}}} catch {res.writeHead(400);return res.end();}
if(!equal(new URLSearchParams(body).get('password')??'',password)) {res.writeHead(401,{'Content-Type':'text/html; charset=utf-8'});return res.end(loginPage(true));}
for(const [key,expiry] of sessions) if(expiry=100) {res.writeHead(503);return res.end('Session limit reached');}
const token=randomBytes(32).toString('base64url'); sessions.set(token,Date.now()+8*3600000);
res.writeHead(303,{'Location':'/','Set-Cookie':`__Host-beryl_preview=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800`});return res.end();
}
const token=(req.headers.cookie??'').split(';').map(x=>x.trim()).find(x=>x.startsWith('__Host-beryl_preview='))?.slice('__Host-beryl_preview='.length);
if(!token || (sessions.get(token)??0)<=Date.now()) {
if((pathname==='/'||pathname==='/__preview/login')&&['GET','HEAD'].includes(req.method)) {res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end(req.method==='HEAD'?undefined:loginPage());}
res.writeHead(401,{'Content-Type':'application/json'});return res.end(JSON.stringify({error:'PREVIEW_LOGIN_REQUIRED',message:'请打开网站首页输入访问密码'}));
}
if(pathname==='/__preview/logout' && req.method==='POST') {
sessions.delete(token);
res.writeHead(303,{'Location':'/','Set-Cookie':'__Host-beryl_preview=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0'});return res.end();
}
if(pathname==='/__preview/login' && req.method==='GET') {
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end('测试访问管理已登录 Beryl Agent
进入 Agent
');
}
if (pathname.startsWith('/api/') || pathname === '/health') {
const headers = { ...req.headers, host: 'localhost:3001' };
delete headers.authorization;
delete headers.cookie;
const upstream = http.request({ hostname: '127.0.0.1', port: 3001, path: req.url, method: req.method, headers }, response => {
const safeHeaders = { ...response.headers, 'cache-control': 'no-store' };
delete safeHeaders['access-control-allow-origin'];
res.writeHead(response.statusCode, safeHeaders);
response.pipe(res);
});
upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('Backend unavailable'); });
res.on('close', () => upstream.destroy());
req.pipe(upstream);
return;
}
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); return res.end(); }
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
const file = path.resolve(root, relative);
if (!file.startsWith(root + path.sep) || relative.split('/').some(p => p.startsWith('.'))) {
res.writeHead(404); return res.end();
}
try {
const data = await readFile(file);
res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' });
res.end(req.method === 'HEAD' ? undefined : data);
} catch { res.writeHead(404); res.end('Not found'); }
});
}
if(process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
const password=process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url');
createPreviewServer({password}).listen(4173, '127.0.0.1', () => {
console.log('Preview listening on 127.0.0.1:4173');
console.log('Login: password form (no username required)');
console.log('Password: ' + password);
});
}