Files
music-video-gen/flow-state/vite.config.js
T
DejvinoandClaude a0eaf2ef95 Fix AI Gateway CORS by proxying through Vite dev server
Direct browser fetch to https://ai-gateway.vercel.sh/v1 is blocked
(No Access-Control-Allow-Origin). In dev the AI SDK now calls
/__llm/v1 via same-origin Vite middleware (llmGatewayProxy), which
forwards server-side to the real gateway — no CORS. Production still
calls the gateway directly (deployments needing CORS-free prod should
add a serverless /api/llm proxy).

- vite.config.js: new llmGatewayProxy middleware for /__llm/* —
  handles OPTIONS preflight, forwards via Node fetch to
  x-llm-gateway (fallback Vercel), mirrors status/headers +
  access-control-allow-origin, preserves query/body.
- src/llm/llmTweak.js: in DEV use baseURL /__llm/v1 + proxyFetch
  that injects x-llm-gateway; prod keeps cfg.gatewayUrl.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 20:25:19 +02:00

164 lines
7.2 KiB
JavaScript

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'),
},
},
},
});