SPB Git

Repository Public

serve-out.mjs 4.8 KB 95 lines javascript

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
Files main
Menu

Theme