// Dev server for the *live* CMS: everything is proxied to CMS_TARGET, and the // page HTML (which the proxy can't rewrite reliably - compression, streaming) // is fetched server-side so the editor's dev entry can be injected into it. // // Auth never reaches the browser: CMS_TOKEN / CMS_API_KEY / CMS_COOKIE are added // to every upstream request here. import type { Plugin, ViteDevServer } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import { injectDevEntry, isPageRequest, isViteOwned, upstreamHeaders } from './shared' export function cmsLivePlugin(env: Record): Plugin { const target = (env.CMS_TARGET || 'https://k6n.net').replace(/\/$/, '') const fixed = { token: env.CMS_TOKEN, apiKey: env.CMS_API_KEY, cookie: env.CMS_COOKIE } // Token the browser got through the in-editor device login (POST /__dev/token). Env credentials win. let runtimeToken: string | undefined const creds = () => ({ ...fixed, token: fixed.token || runtimeToken }) return { name: 'cms-live', config: () => ({ server: { proxy: { // Anything Vite doesn't own goes to the real site (assets, /api, /editor, fonts...). '^/(?!@|src/|node_modules/|__).*': { target, changeOrigin: true, secure: true, cookieDomainRewrite: '', cookiePathRewrite: '/', bypass: (req) => (isViteOwned(req.url ?? '') ? req.url : undefined), configure: (proxy) => { proxy.on('proxyReq', (proxyReq, req) => { const h = upstreamHeaders(req.headers, creds()) for (const [k, v] of Object.entries(h)) proxyReq.setHeader(k, v) }) }, }, }, }, }), configureServer(server: ViteDevServer) { // Registered directly (not in a returned fn) so it runs BEFORE the proxy. server.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next) => { if (req.url === '/__dev/token') { if (req.method === 'DELETE') runtimeToken = undefined else if (req.method === 'POST') { const chunks: Buffer[] = [] for await (const c of req) chunks.push(c as Buffer) try { runtimeToken = (JSON.parse(Buffer.concat(chunks).toString()) as { accessToken?: string }).accessToken } catch { /* ignore malformed */ } } res.statusCode = 204 return void res.end() } if (!isPageRequest(req)) return next() try { const upstream = await fetch(target + req.url, { headers: upstreamHeaders(req.headers, creds(), { identity: true }), redirect: 'manual', }) const type = upstream.headers.get('content-type') ?? '' res.statusCode = upstream.status upstream.headers.forEach((v, k) => { // fetch() already decoded the body; CSP would block our injected module. if (['content-encoding', 'content-length', 'transfer-encoding', 'content-security-policy', 'strict-transport-security'].includes(k)) return if (k === 'location') v = v.replace(target, '') if (k === 'set-cookie') return res.setHeader(k, v) }) for (const c of upstream.headers.getSetCookie()) { res.appendHeader('set-cookie', c.replace(/;\s*domain=[^;]*/i, '').replace(/;\s*secure/i, '')) } let body = await upstream.text() if (type.includes('text/html')) { body = await server.transformIndexHtml(req.url!, injectDevEntry(body)) res.setHeader('x-cms-dev', 'injected') res.setHeader('cache-control', 'no-store') // never let the browser reuse a stale published page } res.end(body) } catch (e) { res.statusCode = 502 res.setHeader('content-type', 'text/plain') res.end(`dev proxy: cannot reach ${target}: ${(e as Error).message}`) } }) }, } }