diff --git a/frontend/apps/artcraft-webapp/vite.config.ts b/frontend/apps/artcraft-webapp/vite.config.ts index c19153dc8c..e653514dcd 100644 --- a/frontend/apps/artcraft-webapp/vite.config.ts +++ b/frontend/apps/artcraft-webapp/vite.config.ts @@ -7,6 +7,70 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import { execSync } from 'child_process'; import path from 'path'; +// Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched with +// fetch(), but the CDNs only send CORS headers for the production origin — +// not localhost. The web adapter's fetchAsset rewrites absolute CDN URLs to +// `/__cdn//` in DEV; this middleware forwards them server-side +// (no browser CORS) to whatever host is embedded in the path. +// +// NB: This must be a middleware, not a `server.proxy` entry. The previous +// proxy config used a `router` option to pick the target host from the path, +// but `router` is an http-proxy-middleware feature — Vite's http-proxy +// silently ignores it, so every request went to the hardcoded default target +// and any asset on a different host (e.g. the dev R2 bucket) came back 403. +function cdnProxyPlugin() { + return { + name: 'cdn-proxy', + configureServer(server: { + middlewares: { + use: ( + route: string, + handler: ( + req: import('http').IncomingMessage, + res: import('http').ServerResponse, + ) => void, + ) => void; + }; + }) { + server.middlewares.use('/__cdn', async (req, res) => { + try { + // Connect strips the `/__cdn` mount prefix from req.url. + const match = (req.url ?? '').match(/^\/([^/?]+)(\/[^?]*)?(\?.*)?$/); + if (!match) { + res.statusCode = 400; + res.end('Bad /__cdn path; expected /__cdn//'); + return; + } + const [, host, assetPath = '/', query = ''] = match; + const upstream = await fetch(`https://${host}${assetPath}${query}`, { + method: req.method === 'HEAD' ? 'HEAD' : 'GET', + headers: req.headers.range + ? { range: String(req.headers.range) } + : undefined, + }); + res.statusCode = upstream.status; + for (const header of [ + 'content-type', + 'content-length', + 'accept-ranges', + 'content-range', + 'etag', + 'cache-control', + ]) { + const value = upstream.headers.get(header); + if (value) res.setHeader(header, value); + } + const body = Buffer.from(await upstream.arrayBuffer()); + res.end(body); + } catch (e) { + res.statusCode = 502; + res.end(`CDN proxy error: ${e}`); + } + }); + }, + }; +} + // Custom plugin to generate news.json on dev server start function generateNewsPlugin() { return { @@ -38,29 +102,15 @@ export default defineConfig(() => ({ Origin: 'https://api.storyteller.ai', }, }, - // Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched - // from the CDN with fetch(), but the CDN only sends CORS headers for the - // production origin — not localhost. The web adapter's fetchAsset rewrites - // absolute CDN URLs to `/__cdn//` in DEV; this forwards them - // server-side (no browser CORS). The target host is taken from the path - // so any cdn-N.fakeyou.com / Cloudflare host works. - '/__cdn': { - target: 'https://cdn-2.fakeyou.com', - changeOrigin: true, - secure: true, - router: (req) => { - const match = req.url?.match(/^\/__cdn\/([^/]+)/); - return match ? `https://${match[1]}` : 'https://cdn-2.fakeyou.com'; - }, - rewrite: (path) => path.replace(/^\/__cdn\/[^/]+/, ''), - }, + // NB: `/__cdn` asset proxying lives in `cdnProxyPlugin` above — it + // needs per-request host routing, which `server.proxy` can't do. }, }, preview:{ port: 4301, host: 'localhost', }, - plugins: [generateNewsPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()], + plugins: [cdnProxyPlugin(), generateNewsPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()], // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ],