import { defineConfig } from 'vite'; import { resolve } from 'path'; import { writeFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'fs'; /** * Let the measuring pages write their results back into the source tree. * * The measurements that describe the scene library — how much frame each one * paints, how much it changes between songs, what it looks like structurally — * can only be taken by rendering, and rendering needs WebGL, which means a * browser. So the numbers are produced in a page and have to get from there * into a git-tracked file. This is that hole in the wall. * * Dev only, by construction: `apply: 'serve'` means it does not exist in a * build, so a deployed gallery cannot write anything. The path it may write is * a fixed allow-list rather than a parameter — a request body decides the * CONTENTS of a known file and never which file. */ function measurementWriter() { const WRITABLE = { '/__metadata': 'src/scenes/metadata.json', }; return { name: 'flow-state-measurement-writer', apply: 'serve', configureServer(server) { server.middlewares.use((req, res, next) => { const target = WRITABLE[req.url]; if (!target || req.method !== 'POST') return next(); let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { try { writeFileSync(resolve(process.cwd(), target), body); res.statusCode = 200; res.end(`wrote ${target} (${body.length} bytes)`); } catch (err) { res.statusCode = 500; res.end(String(err && err.message)); } }); }); }, }; } /** * Proxy LLM calls through the dev server so the browser stays same-origin. * * The Vercel AI Gateway does not send CORS headers, so a direct * `fetch('https://ai-gateway.vercel.sh/v1/...')` from * `http://localhost:5180` is blocked. The AI SDK (`ai` + `@ai-sdk/openai`) * runs in the browser and would hit the gateway directly. * * In dev the app instead calls `http://localhost:5180/__llm/v1/...` and this * middleware forwards the request server-side (Node `fetch`) to the real * gateway — no CORS involved. The target gateway is taken from the * `x-llm-gateway` request header (client's configured gatewayUrl) so custom * gateways keep working; absent, it falls back to Vercel. * * Production builds are static; there this route does not exist and the * client falls through to a direct fetch. Deployments that need CORS-free * production should add an equivalent `/api/llm` serverless proxy. * * Also logs sanitized traffic (no Authorization) to `logs/llm-proxy.log` so * the coding agent can inspect prompts/responses without re-calling the LLM. */ function llmGatewayProxy() { const LOG_DIR = resolve(process.cwd(), 'logs'); const LOG_FILE = resolve(LOG_DIR, 'llm-proxy.log'); function safeAppend(entry) { try { if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true }); // Rotate: truncate if >1MB to avoid unbounded growth try { if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > 1024 * 1024) { writeFileSync(LOG_FILE, ''); } } catch { /* ignore stat/truncate errors */ } appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n'); } catch { /* ignore */ } } function sanitizePathForLog(path) { try { const u = new URL(path, 'http://localhost'); for (const key of [...u.searchParams.keys()]) { const lower = key.toLowerCase(); if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') { u.searchParams.delete(key); } } return u.pathname + u.search; } catch { // Fallback: strip sensitive query params via regex return path.replace(/([?&])(apiKey|api_key|authorization)=[^&]*/gi, '').replace(/[?&]$/, '').replace(/\?&/, '?') || '/'; } } function redactJsonForLog(obj) { if (Array.isArray(obj)) return obj.map(redactJsonForLog); if (obj && typeof obj === 'object') { const out = {}; for (const [k, v] of Object.entries(obj)) { const lower = k.toLowerCase(); if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') { out[k] = '[REDACTED]'; } else { out[k] = redactJsonForLog(v); } } return out; } return obj; } function sanitizeBodyForLog(ct, raw) { // Allow __llm JSON (which includes images) to be larger — spec asked // for full logging. 64k keeps 8× images plus schema without truncation. const limit = 65536; if (!ct || !ct.includes('json')) { if (raw && raw.length > limit) return `[${ct || 'unknown'} body ${raw.length} bytes, not logged]`; try { return raw ? raw.toString('utf8').slice(0, limit) : ''; } catch { return '[unprintable]'; } } try { const text = raw ? raw.toString('utf8') : ''; const parsed = JSON.parse(text); const redacted = redactJsonForLog(parsed); return JSON.stringify(redacted).slice(0, limit); } catch { return raw ? raw.toString('utf8').slice(0, limit) : ''; } } return { name: 'flow-state-llm-gateway-proxy', apply: 'serve', configureServer(server) { server.middlewares.use(async (req, res, next) => { if (!req.url.startsWith('/__llm/')) return next(); // CORS preflight from the browser's fetch if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-llm-gateway', 'Access-Control-Max-Age': '86400', }); res.end(); return; } const gatewayBase = (req.headers['x-llm-gateway'] && String(req.headers['x-llm-gateway'])) || 'https://ai-gateway.vercel.sh'; const pathAfter = req.url.replace(/^\/__llm/, '') || '/'; let origin; try { origin = new URL(gatewayBase).origin; } catch { origin = 'https://ai-gateway.vercel.sh'; } const finalUrl = origin + pathAfter; let bodyChunks = []; req.on('data', (c) => bodyChunks.push(c)); req.on('end', async () => { const started = Date.now(); const reqCt = req.headers['content-type'] || ''; const rawReq = bodyChunks.length ? Buffer.concat(bodyChunks) : null; const reqLog = sanitizeBodyForLog(reqCt, rawReq); // Always log the request (sanitized — no Authorization/apiKey) safeAppend({ ts: new Date().toISOString(), dir: '→', method: req.method, path: sanitizePathForLog(pathAfter), gateway: origin, contentType: reqCt, // Authorization header and apiKey query param never logged body: reqLog, }); try { const body = rawReq && rawReq.length ? rawReq : undefined; const headers = {}; if (req.headers['content-type']) headers['content-type'] = req.headers['content-type']; if (req.headers['authorization']) headers['authorization'] = req.headers['authorization']; else if (req.headers['x-api-key']) headers['authorization'] = `Bearer ${req.headers['x-api-key']}`; for (const [k, v] of Object.entries(req.headers)) { if (k.startsWith('x-') && k !== 'x-llm-gateway') headers[k] = v; } const upstream = await fetch(finalUrl, { method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body, }); const contentType = upstream.headers.get('content-type') || ''; const isStream = contentType.includes('event-stream') || contentType.includes('text/event-stream'); // Stream SSE straight through so streamObject sees incremental chunks. if (isStream && upstream.body) { res.writeHead(upstream.status, { 'content-type': contentType, 'access-control-allow-origin': '*', 'cache-control': 'no-cache', }); const reader = upstream.body.getReader(); let totalBytes = 0; const chunks = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.byteLength; if (chunks.length < 64) chunks.push(Buffer.from(value).toString('utf8')); res.write(value); } } finally { try { await reader.cancel(); } catch { /* ignore */ } } res.end(); const elapsed = Date.now() - started; safeAppend({ ts: new Date().toISOString(), dir: '←', status: upstream.status, elapsedMs: elapsed, contentType, body: chunks.join('').slice(0, 16384) || `[stream ${totalBytes} bytes]`, }); console.info(`[llm-proxy] ← OK (stream) ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${totalBytes} bytes)`); return; } // Non-streaming: buffer then forward const buf = Buffer.from(await upstream.arrayBuffer()); res.writeHead(upstream.status, { ...(contentType ? { 'content-type': contentType } : {}), 'access-control-allow-origin': '*', }); res.end(buf); const elapsed = Date.now() - started; let respLog = ''; try { if (contentType.includes('json') || buf.length < 32768) { const text = buf.toString('utf8'); if (contentType.includes('json')) { try { respLog = JSON.stringify(JSON.parse(text)).slice(0, 16384); } catch { respLog = text.slice(0, 8192); } } else { respLog = text.slice(0, 8192); } } else { respLog = `[${contentType} ${buf.length} bytes]`; } } catch { respLog = `[${buf.length} bytes]`; } safeAppend({ ts: new Date().toISOString(), dir: '←', status: upstream.status, elapsedMs: elapsed, contentType: contentType || '', body: respLog, }); const tag = upstream.status >= 400 ? '← ERR' : '← OK'; console.info(`[llm-proxy] ${tag} ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${buf.length} bytes)`); } catch (err) { const elapsed = Date.now() - started; safeAppend({ ts: new Date().toISOString(), dir: '←', status: 502, elapsedMs: elapsed, error: String(err && err.message ? err.message : err).slice(0, 2000), }); console.error(`[llm-proxy] ← 502 ${req.method} ${pathAfter}`, err); res.writeHead(502, { 'access-control-allow-origin': '*' }); res.end(String(err && err.message ? err.message : err)); } }); }); }, }; } export default defineConfig({ plugins: [measurementWriter(), llmGatewayProxy()], server: { port: 5180, host: '127.0.0.1', }, build: { rollupOptions: { input: { main: resolve(process.cwd(), 'index.html'), checks: resolve(process.cwd(), 'checks.html'), debug: resolve(process.cwd(), 'debug.html'), gallery: resolve(process.cwd(), 'gallery.html'), filmstrip: resolve(process.cwd(), 'filmstrip.html'), }, }, }, });