From 55d714e614edc047c9c9464392dd76ab29acc74e Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 5 Sep 2026 16:37:57 +0200 Subject: [PATCH] refactor(dev): drop the Vite-under-Bun proxy workarounds Bun 1.4.1 made obsolete Three pieces of this repo existed only because the Vite dev server runs inside Bun and Bun's node:http client had gaps: it never emitted 'upgrade', its socket lacked destroySoon(), and its proxy could stop draining large bodies under backpressure. Bun 1.4.1 fixed the socket lifecycle (ws handleUpgrade after await, paused sockets never emitting end, WebSocket backpressure stalls), and on 1.4.2 the workarounds are dead weight: - vite.config.ts forwards WebSocket upgrades on /admin/api (ws: true). The VITE_CMS_DEV_PORT define and the large-body proxy plugin are gone. - The collab socket is same-origin in dev exactly as in production: window.location.host + SITE_SOCKET_PATH. socketUrl.ts and its test are deleted along with the import.meta.env.DEV special case. - scripts/lib/largeBodyDevProxy.ts and its test are deleted. - devWorkflow.test.ts now gates the other way: ws forwarding must stay on and no CMS port may be dialled directly. - docs/features/site-shell.md and docs/e2e/README.md describe the same-origin dev socket and no longer document the plugin. A throwaway workflow, .github/workflows/vite-dev-smoke.yml, boots the CMS and the Vite dev server on a Linux runner and asserts that Vite comes up and that a WebSocket upgrade through the proxy returns a status line instead of hanging; that is the failure the E2E workflow first died on. The file is removed before merge. Verification (Bun 1.4.2): bun run build tsc + vite clean bun run lint clean bun test 6827 pass, 0 fail bun run dev, browser on the Vite port collab socket opens through the proxy in 6 ms, then Vite survives its close media upload through the proxy 1.48 MiB PNG, HTTP 201 in 0.16 s, all bytes received vite-dev-smoke on ubuntu-latest see the PR (runs on push) --- .github/workflows/vite-dev-smoke.yml | 62 ++++++++++ docs/e2e/README.md | 7 -- docs/features/site-shell.md | 21 ++-- scripts/dev.ts | 7 +- scripts/lib/largeBodyDevProxy.ts | 116 ------------------ src/__tests__/collab/socketUrl.test.ts | 78 ------------ src/__tests__/devWorkflow.test.ts | 20 ++- src/__tests__/largeBodyDevProxy.test.ts | 50 -------- src/admin/pages/site/collab/collabProvider.ts | 12 +- src/admin/pages/site/collab/socketUrl.ts | 47 ------- vite.config.ts | 60 ++------- 11 files changed, 94 insertions(+), 386 deletions(-) create mode 100644 .github/workflows/vite-dev-smoke.yml delete mode 100644 scripts/lib/largeBodyDevProxy.ts delete mode 100644 src/__tests__/collab/socketUrl.test.ts delete mode 100644 src/__tests__/largeBodyDevProxy.test.ts delete mode 100644 src/admin/pages/site/collab/socketUrl.ts 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,