diff --git a/.github/workflows/vite-dev-smoke.yml b/.github/workflows/vite-dev-smoke.yml new file mode 100644 index 000000000..8008d517d --- /dev/null +++ b/.github/workflows/vite-dev-smoke.yml @@ -0,0 +1,62 @@ +# Throwaway experiment for the refactor/vite-dev-proxy branch; deleted before +# merge. It answers one question a macOS laptop cannot: on a Linux runner under +# the pinned Bun, does the Vite dev server (running inside Bun) come up, and does +# it forward a WebSocket upgrade to the CMS instead of hanging? The earlier +# E2E workflow died on exactly this before a single test ran. +name: Vite dev smoke (experiment) + +on: + push: + branches: + - refactor/vite-dev-proxy + +permissions: + contents: read + +jobs: + vite-dev: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + # Reads "packageManager" from package.json. + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - name: Boot the CMS and the Vite dev server + run: | + bun --version + PORT=3001 DATABASE_URL=sqlite:./.tmp/smoke.db UPLOADS_DIR=./.tmp/smoke-uploads bun server/index.ts > cms.log 2>&1 & + PORT=3001 bun scripts/vite.ts --host 127.0.0.1 --port 5173 --strictPort > vite.log 2>&1 & + for i in $(seq 1 60); do + if curl -fsS -o /dev/null http://127.0.0.1:5173/admin; then + echo "vite ready after ${i}s"; break + fi + sleep 1 + done + curl -fsS -o /dev/null http://127.0.0.1:5173/admin || { echo "VITE NEVER CAME UP"; cat vite.log; exit 1; } + + # An unauthenticated upgrade is refused by the CMS, but the point is that + # the status line comes back through the proxy at all: before the fix the + # request hung and the proxy died on socket.destroySoon(). + - name: WebSocket upgrade through the Vite proxy returns a status line + run: | + set +e + out=$(curl -s -i -m 10 \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + http://127.0.0.1:5173/admin/api/cms/site-socket 2>&1 | head -1) + echo "upgrade response: ${out:-}" + [ -n "$out" ] || exit 1 + + - name: Vite process survived the upgrade + run: | + curl -fsS -o /dev/null http://127.0.0.1:5173/admin && echo "vite still serving" + + - name: Logs + if: always() + run: | + echo "--- vite.log ---"; cat vite.log || true + echo "--- cms.log ---"; tail -20 cms.log || true diff --git a/docs/e2e/README.md b/docs/e2e/README.md index 21c512ca0..61e00fba6 100644 --- a/docs/e2e/README.md +++ b/docs/e2e/README.md @@ -57,13 +57,6 @@ so publishing never reloads the admin app mid-test. The Vite dev proxy follows the configured CMS `PORT`, keeping the Playwright admin UI pointed at the disposable CMS instead of any regular dev server on port 3001. -When Vite itself runs on Bun, its Node-compatible native proxy can stop -draining multi-megabyte request bodies after socket backpressure fills both -sides. `largeBodyDevProxyPlugin` intercepts only known-length CMS API bodies of -at least 1 MiB, buffers them with the same 128 MiB ceiling as `Bun.serve`, and -forwards them with an explicit `Content-Length`. Small requests, non-CMS -traffic, and streaming AI responses remain on Vite's native proxy. - For debugging against a server you started yourself, set `E2E_REUSE_SERVER=1` and override `E2E_ADMIN_BASE_URL` / `E2E_PUBLIC_BASE_URL` as needed. Do not use reuse mode for CI or for diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 512b056ca..29e5879f3 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -590,18 +590,15 @@ they commit: the `ColorInput` primitive throttles picker-drag change events so a color drag cannot fill the socket backlog past the provider's send gate. -In production the socket is same-origin. Under `vite dev` it is NOT: the -socket dials the CMS port directly, bypassing the Vite proxy -(`src/admin/pages/site/collab/socketUrl.ts`). `scripts/vite.ts` runs Vite -inside Bun, and Bun's `node:http` ClientRequest never emits `'upgrade'`, so a -proxied 101 takes the non-upgrade fallback: the browser socket hangs in -`readyState 0` forever — never opening, never closing, so the provider's -reconnect path is never even reached — and when that connection later ends, -the proxy's `socket.destroySoon()` call (an API Bun's socket lacks) throws -uncaught and kills the whole dev process. Only the PORT is swapped; the -hostname is preserved, because the session cookie is `SameSite=Lax` and -`localhost` ↔ `127.0.0.1` is a cross-site handshake that would drop it. -`devWorkflow.test.ts` gates the proxy against re-enabling `ws` forwarding. +The socket is same-origin in production and under `vite dev` alike: the +Vite proxy forwards the upgrade to the CMS (`ws: true` on the `/admin/api` +entry in `vite.config.ts`), so the provider simply dials +`window.location.host` + `SITE_SOCKET_PATH`. Before Bun 1.4.1 the +`node:http` client Vite runs on inside Bun never emitted `'upgrade'`, so the +socket had to dial the CMS port directly and the dev process could be killed +by a `socket.destroySoon()` call Bun lacked; 1.4.1 fixed both, and +`devWorkflow.test.ts` now gates the proxy the other way, requiring `ws` +forwarding to stay on. **Presence** (`src/admin/pages/site/collab/awarenessState.ts`; per-frame publishers in `collab/framePresencePublishers.ts`, rendering in diff --git a/scripts/dev.ts b/scripts/dev.ts index 14a627057..0acbb8adf 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -258,10 +258,9 @@ const processes: DevProcess[] = [ { name: 'vite', command: viteCommand('--host', '127.0.0.1', '--port', String(VITE_PORT), '--strictPort'), - // vite.config.ts reads PORT for both the proxy target and the collab - // socket's dev port. Inheriting it from the developer's shell happened to - // work only because CMS_PORT's default matches the config's — pass it - // explicitly so the two can't drift. `scripts/e2e-dev.ts` already does. + // vite.config.ts reads PORT for the proxy target. Inheriting it from the + // developer's shell happened to work only because CMS_PORT's default + // matches the config's; pass it explicitly so the two can't drift. env: { PORT: String(CMS_PORT) }, }, ] diff --git a/scripts/lib/largeBodyDevProxy.ts b/scripts/lib/largeBodyDevProxy.ts deleted file mode 100644 index 292aad7cd..000000000 --- a/scripts/lib/largeBodyDevProxy.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http' - -/** - * Vite's Node-compatible proxy can stop draining large browser request bodies - * when Vite itself is running on Bun. Once both socket buffers fill, neither - * side makes progress. Buffering only the large requests gives Bun's fetch - * client a known Content-Length and avoids that backpressure deadlock while - * leaving ordinary requests and streaming AI responses on Vite's native proxy. - */ -export const LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES = 1024 * 1024 -export const MAX_DEV_PROXY_BODY_BYTES = 128 * 1024 * 1024 - -const LARGE_BODY_PROXY_PREFIXES = ['/admin/api/cms/'] as const -const HOP_BY_HOP_HEADERS = new Set([ - 'connection', - 'content-length', - 'host', - 'transfer-encoding', -]) - -interface ProxyRequestLike { - method?: string - url?: string - headers: IncomingHttpHeaders -} - -export class DevProxyBodyTooLargeError extends Error { - readonly maxBytes: number - - constructor(maxBytes: number) { - super(`Development proxy request body exceeds ${maxBytes} bytes`) - this.name = 'DevProxyBodyTooLargeError' - this.maxBytes = maxBytes - } -} - -function declaredBodyBytes(headers: IncomingHttpHeaders): number | null { - const raw = headers['content-length'] - const value = Array.isArray(raw) ? raw[0] : raw - if (!value) return null - const parsed = Number(value) - return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null -} - -export function shouldBufferLargeDevProxyRequest(req: ProxyRequestLike): boolean { - if (req.method === 'GET' || req.method === 'HEAD') return false - const pathname = new URL(req.url ?? '/', 'http://localhost').pathname - if (!LARGE_BODY_PROXY_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false - const bodyBytes = declaredBodyBytes(req.headers) - return bodyBytes !== null && bodyBytes >= LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES -} - -async function readBoundedRequestBody(req: IncomingMessage): Promise { - const declaredBytes = declaredBodyBytes(req.headers) - if (declaredBytes !== null && declaredBytes > MAX_DEV_PROXY_BODY_BYTES) { - throw new DevProxyBodyTooLargeError(MAX_DEV_PROXY_BODY_BYTES) - } - - const chunks: Buffer[] = [] - let receivedBytes = 0 - for await (const chunk of req) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - receivedBytes += bytes.byteLength - if (receivedBytes > MAX_DEV_PROXY_BODY_BYTES) { - throw new DevProxyBodyTooLargeError(MAX_DEV_PROXY_BODY_BYTES) - } - chunks.push(bytes) - } - return Buffer.concat(chunks, receivedBytes) -} - -function forwardedRequestHeaders(headers: IncomingHttpHeaders, bodyBytes: number): Headers { - const forwarded = new Headers() - for (const [name, rawValue] of Object.entries(headers)) { - if (HOP_BY_HOP_HEADERS.has(name.toLowerCase()) || rawValue === undefined) continue - forwarded.set(name, Array.isArray(rawValue) ? rawValue.join(', ') : rawValue) - } - forwarded.set('content-length', String(bodyBytes)) - return forwarded -} - -async function sendBufferedResponse(upstream: Response, res: ServerResponse): Promise { - const body = Buffer.from(await upstream.arrayBuffer()) - const headers: Record = {} - upstream.headers.forEach((value, name) => { - if (HOP_BY_HOP_HEADERS.has(name.toLowerCase())) return - headers[name] = value - }) - headers['content-length'] = String(body.byteLength) - res.writeHead(upstream.status, headers) - res.end(body) -} - -export async function proxyLargeDevRequest( - req: IncomingMessage, - res: ServerResponse, - targetOrigin: string, -): Promise { - try { - const body = await readBoundedRequestBody(req) - const upstream = await fetch(new URL(req.url ?? '/', targetOrigin), { - method: req.method, - headers: forwardedRequestHeaders(req.headers, body.byteLength), - body: Uint8Array.from(body), - redirect: 'manual', - }) - await sendBufferedResponse(upstream, res) - } catch (err) { - if (err instanceof DevProxyBodyTooLargeError) { - res.writeHead(413, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: err.message })) - return - } - throw err - } -} diff --git a/src/__tests__/collab/socketUrl.test.ts b/src/__tests__/collab/socketUrl.test.ts deleted file mode 100644 index bfcd0463e..000000000 --- a/src/__tests__/collab/socketUrl.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Where the collab WebSocket dials. - * - * The dev branch exists because Vite (running inside Bun) cannot forward - * WebSocket upgrades; the socket skips the proxy and dials the CMS port. - * The invariant that matters most is that only the PORT is swapped — the - * session cookie is `SameSite=Lax`, so rewriting `127.0.0.1` to `localhost` - * (or the reverse) would make the handshake cross-site, drop the cookie, and - * 401 into an endless reconnect. - */ -import { describe, expect, it } from 'bun:test' -import { collabSocketUrl } from '@site/collab/socketUrl' - -const PATH = '/admin/api/cms/site-socket' - -describe('collabSocketUrl', () => { - describe('production (same origin)', () => { - it('uses the page host verbatim', () => { - const url = collabSocketUrl( - { protocol: 'https:', host: 'cms.example.com', hostname: 'cms.example.com' }, - null, - ) - expect(url).toBe(`wss://cms.example.com${PATH}`) - }) - - it('keeps a non-default port that is part of the origin', () => { - const url = collabSocketUrl( - { protocol: 'http:', host: 'box.internal:8080', hostname: 'box.internal' }, - null, - ) - expect(url).toBe(`ws://box.internal:8080${PATH}`) - }) - - it('downgrades to ws on a plain-http origin', () => { - const url = collabSocketUrl( - { protocol: 'http:', host: 'cms.example.com', hostname: 'cms.example.com' }, - null, - ) - expect(url).toBe(`ws://cms.example.com${PATH}`) - }) - }) - - describe('vite dev (CMS port dialled directly)', () => { - it('swaps only the port, preserving a localhost page', () => { - const url = collabSocketUrl( - { protocol: 'http:', host: 'localhost:5173', hostname: 'localhost' }, - '3001', - ) - expect(url).toBe(`ws://localhost:3001${PATH}`) - }) - - // `bun run dev` serves Vite on 127.0.0.1; rewriting to a `localhost` - // literal here would make the handshake cross-site and drop the cookie. - it('swaps only the port, preserving a 127.0.0.1 page', () => { - const url = collabSocketUrl( - { protocol: 'http:', host: '127.0.0.1:5173', hostname: '127.0.0.1' }, - '3001', - ) - expect(url).toBe(`ws://127.0.0.1:3001${PATH}`) - }) - - it('honours a non-default CMS port (e2e harness)', () => { - const url = collabSocketUrl( - { protocol: 'http:', host: '127.0.0.1:5174', hostname: '127.0.0.1' }, - '3002', - ) - expect(url).toBe(`ws://127.0.0.1:3002${PATH}`) - }) - - it('never carries the Vite port through', () => { - const url = collabSocketUrl( - { protocol: 'http:', host: 'localhost:5173', hostname: 'localhost' }, - '3001', - ) - expect(url).not.toContain('5173') - }) - }) -}) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 7c2327fc0..789f32e24 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -89,24 +89,20 @@ describe('development workflow', () => { expect(viteConfig).toContain("const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}`") expect(viteConfig).toContain('target: CMS_DEV_SERVER_ORIGIN') expect(viteConfig).toContain('changeOrigin: true') - expect(viteConfig).toContain('largeBodyDevProxyPlugin()') - expect(viteConfig).toContain('shouldBufferLargeDevProxyRequest(req)') }) - it('Vite never forwards WebSocket upgrades, and the collab socket gets the CMS port instead', () => { + it('Vite forwards WebSocket upgrades, so the collab socket is same-origin in dev', () => { const viteConfig = readSiteFile('vite.config.ts') const devScript = readSiteFile('scripts/dev.ts') - // Vite runs inside Bun (scripts/vite.ts), and Bun's node:http client never - // emits 'upgrade'. Enabling `ws` forwarding makes the browser socket hang - // and then kills the dev process via `socket.destroySoon()`. The collab - // socket dials the CMS port directly instead — never re-enable this. - expect(viteConfig).not.toMatch(/^\s*ws:\s*true/m) + // Since Bun 1.4.1 the node:http client emits 'upgrade', so the collab + // socket rides the same proxy as every other /admin/api request and the + // dev server is same-origin like production. No port is dialled directly. + expect(viteConfig).toMatch(/^\s*ws:\s*true/m) + expect(viteConfig).not.toContain('VITE_CMS_DEV_PORT') - // The dialled port must come from the same source as the proxy target so - // they cannot drift, and dev.ts must actually hand PORT to the Vite child - // (it previously relied on both defaults happening to be 3001). - expect(viteConfig).toContain("'import.meta.env.VITE_CMS_DEV_PORT': JSON.stringify(process.env.PORT ?? '3001')") + // dev.ts must hand PORT to the Vite child so the proxy target cannot + // drift from the CMS it started (both defaults happen to be 3001). expect(devScript).toContain('env: { PORT: String(CMS_PORT) }') }) diff --git a/src/__tests__/largeBodyDevProxy.test.ts b/src/__tests__/largeBodyDevProxy.test.ts deleted file mode 100644 index 28b01f6c1..000000000 --- a/src/__tests__/largeBodyDevProxy.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { - LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES, - MAX_DEV_PROXY_BODY_BYTES, - shouldBufferLargeDevProxyRequest, -} from '../../scripts/lib/largeBodyDevProxy' - -function request(input: { - method?: string - url?: string - contentLength?: number -}) { - return { - method: input.method ?? 'PUT', - url: input.url ?? '/admin/api/cms/site-document', - headers: input.contentLength === undefined - ? {} - : { 'content-length': String(input.contentLength) }, - } -} - -describe('large-body development proxy routing', () => { - it('buffers large CMS request bodies before forwarding them from Bun-hosted Vite', () => { - expect(shouldBufferLargeDevProxyRequest(request({ - contentLength: LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES, - }))).toBe(true) - expect(shouldBufferLargeDevProxyRequest(request({ - contentLength: MAX_DEV_PROXY_BODY_BYTES, - }))).toBe(true) - }) - - it('leaves small, read-only, and unrelated traffic on Vite native handling', () => { - expect(shouldBufferLargeDevProxyRequest(request({ - contentLength: LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES - 1, - }))).toBe(false) - expect(shouldBufferLargeDevProxyRequest(request({ - method: 'GET', - contentLength: LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES, - }))).toBe(false) - expect(shouldBufferLargeDevProxyRequest(request({ - url: '/src/main.tsx', - contentLength: LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES, - }))).toBe(false) - expect(shouldBufferLargeDevProxyRequest(request({ - url: '/admin/api/ai/chat/site', - contentLength: LARGE_DEV_PROXY_BODY_THRESHOLD_BYTES, - }))).toBe(false) - expect(shouldBufferLargeDevProxyRequest(request({}))).toBe(false) - }) -}) diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index 7d639aac1..f0abf01e7 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -43,10 +43,10 @@ import { FRAME_SYNC, PRESENCE_DOC_ID, REMOTE_ORIGIN, + SITE_SOCKET_PATH, decodeResetReason, type ResetReason, } from '@core/collab' -import { collabSocketUrl } from './socketUrl' const RECONNECT_BASE_DELAY_MS = 1_000 const RECONNECT_MAX_DELAY_MS = 30_000 @@ -127,13 +127,11 @@ export function createCollabProvider( const createSocket = opts.createSocket ?? ((): CollabSocketLike => { - // Under `vite dev` the socket bypasses the proxy and dials the CMS port - // directly — see socketUrl.ts for why, and why the hostname is kept. - const devCmsPort = import.meta.env.DEV - ? String(import.meta.env.VITE_CMS_DEV_PORT ?? '3001') - : null + // Same origin in dev and production: under `vite dev` the proxy + // forwards the upgrade to the CMS (`ws: true` in vite.config.ts). + const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws' return new WebSocket( - collabSocketUrl(window.location, devCmsPort), + `${scheme}://${window.location.host}${SITE_SOCKET_PATH}`, ) as unknown as CollabSocketLike }) diff --git a/src/admin/pages/site/collab/socketUrl.ts b/src/admin/pages/site/collab/socketUrl.ts deleted file mode 100644 index 000ac2e3d..000000000 --- a/src/admin/pages/site/collab/socketUrl.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Where the collab WebSocket dials. - * - * PRODUCTION is same-origin: the Bun server serves the admin bundle and the - * socket, so `window.location.host` is already correct. - * - * VITE DEV cannot be same-origin. `scripts/vite.ts` runs Vite inside Bun, and - * Bun's `node:http` ClientRequest never emits 'upgrade' — a 101 from the CMS - * reaches Vite's proxy as an ordinary response, takes the non-upgrade - * fallback, and the browser socket never completes (it hangs in - * `readyState 0` forever, so the provider never even reaches its reconnect - * path). When that connection later ends, the proxy calls - * `socket.destroySoon()`, which Bun's socket does not implement, and the - * uncaught TypeError kills the whole `bun run dev` process. So the socket - * skips Vite entirely and dials the CMS port directly. - * - * The HOSTNAME is deliberately preserved and only the PORT is swapped. The - * session cookie is `Path=/admin; HttpOnly; SameSite=Lax` and a WebSocket - * handshake is not a top-level navigation, so a handshake the browser judges - * to be third-party would drop the cookie and the upgrade would 401 into an - * endless reconnect. The port is not part of that judgement, but the host is - * — `localhost` and `127.0.0.1` count as different hosts, and `bun run dev` - * serves Vite on `127.0.0.1` while developers routinely type `localhost`. - * Rewriting the host to a literal would break exactly one of those two, - * intermittently. Keeping `location.hostname` is what makes both work. - */ -import { SITE_SOCKET_PATH } from '@core/collab' - -/** The subset of `window.location` the URL depends on. */ -export interface CollabSocketLocation { - protocol: string - host: string - hostname: string -} - -/** - * @param devCmsPort The CMS server's port when running under the Vite dev - * server, or `null` for the same-origin production case. - */ -export function collabSocketUrl( - location: CollabSocketLocation, - devCmsPort: string | null, -): string { - const scheme = location.protocol === 'https:' ? 'wss' : 'ws' - const authority = devCmsPort === null ? location.host : `${location.hostname}:${devCmsPort}` - return `${scheme}://${authority}${SITE_SOCKET_PATH}` -} diff --git a/vite.config.ts b/vite.config.ts index fff274486..4d9f589c4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,10 +3,6 @@ import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' import path from 'path' import type { IncomingMessage, ServerResponse } from 'node:http' -import { - proxyLargeDevRequest, - shouldBufferLargeDevProxyRequest, -} from './scripts/lib/largeBodyDevProxy' const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ @@ -105,32 +101,6 @@ function publicSiteDevProxyPlugin(): Plugin { } } -/** - * Work around a Bun-hosted Vite proxy backpressure deadlock for large request - * CMS bodies. Small and non-CMS requests keep using Vite's native proxy, - * including streaming AI responses; only known-length CMS bodies of at least - * 1 MiB take the bounded buffer-and-forward path. - */ -function largeBodyDevProxyPlugin(): Plugin { - return { - name: 'instatic-large-body-dev-proxy', - apply: 'serve', - - configureServer(server) { - server.middlewares.use((req, res, next) => { - if (!shouldBufferLargeDevProxyRequest(req)) { - next() - return - } - - void proxyLargeDevRequest(req, res, CMS_DEV_SERVER_ORIGIN).catch((err) => { - next(err) - }) - }) - }, - } -} - // Stable vendor chunk groups for long-term browser caching. Vendor code // rarely changes, so isolating it from the app code means returning users // re-download only the (small) app chunks when we ship a new build. @@ -196,7 +166,6 @@ function vendorChunkName(moduleId: string): string | null { // https://vite.dev/config/ export default defineConfig({ plugins: [ - largeBodyDevProxyPlugin(), publicSiteDevProxyPlugin(), react(), babel({ presets: [reactCompilerPreset()] }), @@ -252,14 +221,6 @@ export default defineConfig({ }, }, }, - define: { - // The collab WebSocket dials the CMS port directly under `vite dev` - // (see the `server.proxy` note and src/admin/pages/site/collab/socketUrl.ts). - // Inlined from the same source as the proxy target so the two can never - // disagree, rather than introducing an operator-facing env var. Production - // builds are same-origin and never read it. - 'import.meta.env.VITE_CMS_DEV_PORT': JSON.stringify(process.env.PORT ?? '3001'), - }, server: { watch: { // Runtime-written paths: the publish pipeline bakes HTML into the uploads @@ -272,23 +233,16 @@ export default defineConfig({ proxy: { // The whole `/admin/api/` prefix (CMS + agent) is forwarded to the // Bun backend. Agent endpoints live under `/admin/api/agent` (and - // `/admin/api/agent/tool-result`) so the admin session cookie — - // scoped to `Path=/admin` to keep it off the public site — actually - // accompanies the request. The agent streams NDJSON over standard - // HTTP responses, so no upgrade forwarding is needed here. - // - // WebSocket forwarding is deliberately OFF. `scripts/vite.ts` runs - // Vite inside Bun, and Bun's `node:http` ClientRequest never emits - // 'upgrade' — so a 101 from the backend reaches Vite's proxy as an - // ordinary response, takes the non-upgrade fallback, and the browser - // socket never completes. When that connection later ends the proxy - // calls `socket.destroySoon()`, which Bun's socket does not - // implement, and the uncaught TypeError kills the whole dev process. - // The collab socket therefore dials the CMS port directly in dev — - // see `src/admin/pages/site/collab/socketUrl.ts`. + // `/admin/api/agent/tool-result`) so the admin session cookie, scoped + // to `Path=/admin` to keep it off the public site, accompanies the + // request. `ws: true` forwards the collab socket's upgrade too, so the + // dev server is same-origin exactly like production. (Before Bun 1.4.1 + // the node:http client never emitted 'upgrade' and the socket had to + // dial the CMS port directly.) '/admin/api': { target: CMS_DEV_SERVER_ORIGIN, changeOrigin: true, + ws: true, }, '/uploads': { target: CMS_DEV_SERVER_ORIGIN,