import { defineConfig } from 'vite'; import { resolve } from 'path'; import { writeFileSync } 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. */ function llmGatewayProxy() { 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'; // Strip the proxy prefix; the client sends `/__llm/v1/...`, // so after stripping we have `/v1/...` which joins correctly. const pathAfter = req.url.replace(/^\/__llm/, '') || '/'; // Preserve query string const targetUrl = new URL(pathAfter, gatewayBase.endsWith('/') ? gatewayBase : gatewayBase + '/'); // But `new URL('/v1/x', 'https://host/v1')` drops the base path — // `/v1/x` is absolute. Since `pathAfter` already starts with `/v1`, // joining against the origin is correct; gatewayBase's path is just // the fallback origin. So rebuild against origin only. // Simpler: construct from origin + pathAfter 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 () => { try { const body = bodyChunks.length ? Buffer.concat(bodyChunks) : undefined; const headers = {}; // Forward relevant 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']}`; // Also forward any x- header the gateway might care about 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, }); // Mirror status and content headers const contentType = upstream.headers.get('content-type'); res.writeHead(upstream.status, { ...(contentType ? { 'content-type': contentType } : {}), 'access-control-allow-origin': '*', }); const buf = Buffer.from(await upstream.arrayBuffer()); res.end(buf); } catch (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'), }, }, }, });