Repository Public
1#!/usr/bin/env node2/**3 * DEV/TEST-ONLY static server over apps/web/out with the PRODUCTION Content-Security-Policy4 * (script hashes computed from each HTML file) and a proxy of /api (HTTP + WebSocket) to the5 * dev mock. It proves the export is CSP-clean; apps/server does the real serving.6 * Env: PORT (3621) · MOCK_PORT (8621) · MOCK_AGENT=codex serves /manifest.webmanifest and /icons/*7 * from out/brand/codex/ (what apps/server does when CR_AGENT=codex)8 */9import http from 'node:http';10import net from 'node:net';11import fs from 'node:fs';12import path from 'node:path';13import crypto from 'node:crypto';14import { fileURLToPath } from 'node:url';1516const out = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../out');17const PORT = Number(process.env.PORT ?? 3621);18const MOCK = Number(process.env.MOCK_PORT ?? 8621);19const BRAND_DIR = process.env.MOCK_AGENT === 'codex' ? '/brand/codex' : null;20/** Per-product static files: same public URL, other file (the default product's files are untouched). */21export const brandPath = (rel, dir = BRAND_DIR) => (dir && (rel === '/manifest.webmanifest' || rel.startsWith('/icons/')) ? dir + rel : rel);22const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json', '.ico': 'image/x-icon', '.wasm': 'application/wasm' };2324export function cspFor(html) {25 const hashes = new Set();26 for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)) {27 if (m[1]) hashes.add(`'sha256-${crypto.createHash('sha256').update(m[1]).digest('base64')}'`);28 }29 // Mirrors buildCsp() in apps/server/src/static.ts (frame-src: same-origin / blob: PDF preview only).30 return [31 "default-src 'self'",32 `script-src 'self' 'wasm-unsafe-eval' ${[...hashes].join(' ')}`.trim(),33 "style-src 'self' 'unsafe-inline'",34 "img-src 'self' data: blob:",35 "font-src 'self' data:",36 `connect-src 'self' ws://127.0.0.1:${PORT}`,37 "worker-src 'self' blob:",38 "frame-src 'self' blob:",39 "frame-ancestors 'none'",40 "base-uri 'self'",41 "form-action 'self'",42 "object-src 'none'",43 ].join('; ');44}4546const server = http.createServer((req, res) => {47 const url = new URL(req.url, 'http://x');48 if (url.pathname.startsWith('/api/')) {49 const up = http.request({ host: '127.0.0.1', port: MOCK, path: req.url, method: req.method, headers: req.headers }, (r) => {50 res.writeHead(r.statusCode ?? 502, r.headers);51 r.pipe(res);52 });53 up.on('error', () => { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end('{"error":{"code":"bad_gateway","message":"mock API unreachable"}}'); });54 req.pipe(up);55 return;56 }57 let rel = decodeURIComponent(url.pathname);58 if (rel.includes('..')) { res.writeHead(400); return res.end(); }59 if (rel.endsWith('/')) rel += 'index.html';60 let file = path.join(out, brandPath(rel));61 if (BRAND_DIR && !fs.existsSync(file)) file = path.join(out, rel); // no branded variant: the default file62 if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {63 if (fs.existsSync(file + '/index.html')) { res.writeHead(308, { Location: url.pathname + '/' + url.search }); return res.end(); }64 file = path.join(out, '404.html');65 res.statusCode = 404;66 }67 const ext = path.extname(file);68 const headers = { 'Content-Type': TYPES[ext] ?? 'application/octet-stream', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };69 const buf = fs.readFileSync(file);70 if (ext === '.html') {71 headers['Content-Security-Policy'] = cspFor(buf.toString('utf8'));72 headers['Cache-Control'] = 'no-cache';73 } else if (rel.startsWith('/_next/static/')) headers['Cache-Control'] = 'public, max-age=31536000, immutable';74 else headers['Cache-Control'] = 'no-cache';75 res.writeHead(res.statusCode, headers);76 res.end(buf);77});7879server.on('upgrade', (req, socket, head) => {80 const up = net.connect(MOCK, '127.0.0.1', () => {81 const lines = [`${req.method} ${req.url} HTTP/1.1`];82 for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);83 up.write(lines.join('\r\n') + '\r\n\r\n');84 if (head.length) up.write(head);85 socket.pipe(up).pipe(socket);86 });87 const kill = () => { socket.destroy(); up.destroy(); };88 up.on('error', kill);89 socket.on('error', kill);90});9192if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {93 server.listen(PORT, '127.0.0.1', () => console.log(`[serve-out] http://127.0.0.1:${PORT} (out/ + production CSP, /api → mock :${MOCK})`));94}95