The dev proxy buffered the entire gateway response via arrayBuffer() before forwarding, which defeats streamObject — no partials arrive until the full completion. Now event-stream responses are streamed chunk-by-chunk via the upstream ReadableStream, so the UI's "LLM streaming…" toast and console partials update live. Non-streaming JSON is still buffered as before. Co-Authored-By: Claude <noreply@anthropic.com>
309 lines
14 KiB
JavaScript
309 lines
14 KiB
JavaScript
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) {
|
|
// Don't log if content-type is missing or body is binary
|
|
if (!ct || !ct.includes('json')) {
|
|
if (raw && raw.length > 4096) return `[${ct || 'unknown'} body ${raw.length} bytes, not logged]`;
|
|
try { return raw ? raw.toString('utf8').slice(0, 4096) : ''; } catch { return '[unprintable]'; }
|
|
}
|
|
try {
|
|
const text = raw ? raw.toString('utf8') : '';
|
|
const parsed = JSON.parse(text);
|
|
// Redact sensitive keys if they somehow appear in JSON body
|
|
const redacted = redactJsonForLog(parsed);
|
|
return JSON.stringify(redacted).slice(0, 16384);
|
|
} catch {
|
|
return raw ? raw.toString('utf8').slice(0, 4096) : '';
|
|
}
|
|
}
|
|
|
|
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'),
|
|
},
|
|
},
|
|
},
|
|
});
|