From 0ab57b55f1491736e001fec70b0f1893433985c6 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:12:13 +0200 Subject: [PATCH 01/10] fix(plugins): clean up disconnected event streams --- .../cms/plugins/__tests__/events.test.ts | 45 +++++++++++++++++ server/handlers/cms/plugins/events.ts | 49 +++++++++++++------ server/plugins/eventBroadcaster.ts | 3 ++ 3 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 server/handlers/cms/plugins/__tests__/events.test.ts diff --git a/server/handlers/cms/plugins/__tests__/events.test.ts b/server/handlers/cms/plugins/__tests__/events.test.ts new file mode 100644 index 000000000..ac1962dfb --- /dev/null +++ b/server/handlers/cms/plugins/__tests__/events.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test' +import { __pluginEventSubscriberCountForTesting } from '../../../../plugins/eventBroadcaster' +import { handlePluginEventsStream } from '../events' + +const decoder = new TextDecoder() + +async function connectPluginEventStream(): Promise<{ + requestController: AbortController + reader: ReadableStreamDefaultReader +}> { + const requestController = new AbortController() + const response = handlePluginEventsStream(new Request( + 'http://localhost/admin/api/cms/plugins/events', + { signal: requestController.signal }, + )) + const reader = response.body!.getReader() + const first = await reader.read() + expect(decoder.decode(first.value)).toContain('event: ping') + return { requestController, reader } +} + +describe('plugin event stream', () => { + it('cleans up when the response consumer cancels without aborting the request', async () => { + const { requestController, reader } = await connectPluginEventStream() + + try { + expect(__pluginEventSubscriberCountForTesting()).toBe(1) + await reader.cancel() + expect(requestController.signal.aborted).toBe(false) + expect(__pluginEventSubscriberCountForTesting()).toBe(0) + } finally { + await reader.cancel().catch(() => {}) + requestController.abort() + } + }) + + it('leaves no subscribers across repeated connect and cancel cycles', async () => { + for (let index = 0; index < 100; index += 1) { + const { requestController, reader } = await connectPluginEventStream() + await reader.cancel() + requestController.abort() + expect(__pluginEventSubscriberCountForTesting()).toBe(0) + } + }) +}) diff --git a/server/handlers/cms/plugins/events.ts b/server/handlers/cms/plugins/events.ts index a45a9a96c..03ecfa99c 100644 --- a/server/handlers/cms/plugins/events.ts +++ b/server/handlers/cms/plugins/events.ts @@ -15,8 +15,10 @@ * - Initial `event: ping` keeps proxies (vite, nginx) from idle-closing * the long-lived connection. Followed by a periodic heartbeat every * 30s for the same reason. - * - On `req.signal` abort (tab closed, EventSource paused), we - * unsubscribe from the broadcaster and stop the heartbeat. No leaks. + * - Request abort and response-stream cancellation both unsubscribe from + * the broadcaster and stop the heartbeat. Bun reports a disconnected + * response consumer through `ReadableStream.cancel()` without necessarily + * aborting the server Request. * - The stream never ends voluntarily — clients reconnect via the * standard EventSource auto-reconnect on transport errors. */ @@ -26,40 +28,55 @@ import { methodNotAllowed } from '../../../http' export function handlePluginEventsStream(req: Request): Response { if (req.method !== 'GET') return methodNotAllowed() const encoder = new TextEncoder() + let closeStream: (() => void) | null = null const stream = new ReadableStream({ start(controller) { + let closed = false + let unsubscribe = (): void => {} + let heartbeat: ReturnType | null = null + + const cleanup = () => { + if (closed) return + closed = true + if (heartbeat) clearInterval(heartbeat) + req.signal.removeEventListener('abort', cleanup) + unsubscribe() + try { controller.close() } catch { /* already closed or cancelled */ } + } + closeStream = cleanup + function send(payload: string): void { + if (closed) return try { controller.enqueue(encoder.encode(payload)) } catch { - // Stream already closed (client gone). Listeners + heartbeat - // are torn down via the abort handler below. + cleanup() } } - // Initial ping so the client sees a successful connection immediately, - // even before the first real event arrives. - send(`event: ping\ndata: connected\n\n`) - // Subscribe to the broadcaster — every event becomes one SSE message. // SSE requires `data:` lines + a terminating blank line. - const unsubscribe = subscribePluginEvents((event) => { + unsubscribe = subscribePluginEvents((event) => { send(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`) }) // Heartbeat keeps proxies + the EventSource itself happy. SSE comments // (`: heartbeat`) are ignored by the client but reset idle timers. - const heartbeat = setInterval(() => { + heartbeat = setInterval(() => { send(`: heartbeat\n\n`) }, 30_000) - // Tear down when the client disconnects. - req.signal.addEventListener('abort', () => { - unsubscribe() - clearInterval(heartbeat) - try { controller.close() } catch { /* already closed */ } - }) + if (req.signal.aborted) cleanup() + else req.signal.addEventListener('abort', cleanup, { once: true }) + + // Initial ping so the client sees a successful connection immediately, + // even before the first real event arrives. + send(`event: ping\ndata: connected\n\n`) + }, + cancel() { + closeStream?.() + closeStream = null }, }) diff --git a/server/plugins/eventBroadcaster.ts b/server/plugins/eventBroadcaster.ts index adf348a18..06c8815d9 100644 --- a/server/plugins/eventBroadcaster.ts +++ b/server/plugins/eventBroadcaster.ts @@ -56,3 +56,6 @@ export function broadcastPluginEvent(event: PluginEvent): void { } } +export function __pluginEventSubscriberCountForTesting(): number { + return listeners.size +} From e91fe5ccdda9c32a565ae5e8ea4cb40225c83a35 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:22:33 +0200 Subject: [PATCH 02/10] fix(dev): close abandoned proxy responses --- scripts/lib/viteProxyLifecycle.test.ts | 58 ++++++++++++++++++++++++++ scripts/lib/viteProxyLifecycle.ts | 23 ++++++++++ src/__tests__/devWorkflow.test.ts | 2 + vite.config.ts | 26 ++++++------ 4 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 scripts/lib/viteProxyLifecycle.test.ts create mode 100644 scripts/lib/viteProxyLifecycle.ts diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts new file mode 100644 index 000000000..05713999b --- /dev/null +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test' +import { EventEmitter } from 'node:events' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { HttpProxy } from 'vite' +import { configureProxyResponseLifecycle } from './viteProxyLifecycle' + +function proxyHarness(): { + proxy: EventEmitter + upstream: EventEmitter + downstream: EventEmitter + destroyed: () => number +} { + const proxy = new EventEmitter() + const upstream = new EventEmitter() + const downstream = new EventEmitter() + let destroyCount = 0 + + Object.defineProperty(downstream, 'writableFinished', { + configurable: true, + value: false, + }) + Object.assign(upstream, { + destroy() { + destroyCount += 1 + upstream.emit('close') + return upstream + }, + }) + + configureProxyResponseLifecycle(proxy as unknown as HttpProxy.ProxyServer) + proxy.emit( + 'proxyRes', + upstream as unknown as IncomingMessage, + {} as IncomingMessage, + downstream as unknown as ServerResponse, + ) + + return { proxy, upstream, downstream, destroyed: () => destroyCount } +} + +describe('Vite backend proxy response lifecycle', () => { + it('destroys a streaming upstream response when the browser disconnects', () => { + const harness = proxyHarness() + + harness.downstream.emit('close') + + expect(harness.destroyed()).toBe(1) + }) + + it('leaves an upstream response alone after it ends normally', () => { + const harness = proxyHarness() + + harness.upstream.emit('end') + harness.downstream.emit('close') + + expect(harness.destroyed()).toBe(0) + }) +}) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts new file mode 100644 index 000000000..96e64b5a4 --- /dev/null +++ b/scripts/lib/viteProxyLifecycle.ts @@ -0,0 +1,23 @@ +import type { HttpProxy } from 'vite' + +/** + * Close an active upstream response when its downstream browser connection + * disappears. http-proxy-3 destroys the ClientRequest on downstream close, + * but after response headers arrive that request is already complete and its + * response socket stays open. Long-lived SSE/NDJSON responses would otherwise + * accumulate across browser reloads until the development proxy stalls. + */ +export function configureProxyResponseLifecycle(proxy: HttpProxy.ProxyServer): void { + proxy.on('proxyRes', (proxyResponse, _req, downstreamResponse) => { + const destroyUpstream = () => { + if (!downstreamResponse.writableFinished) proxyResponse.destroy() + } + const detachDownstream = () => { + downstreamResponse.off('close', destroyUpstream) + } + + downstreamResponse.once('close', destroyUpstream) + proxyResponse.once('end', detachDownstream) + proxyResponse.once('close', detachDownstream) + }) +} diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index d054a1969..6a8c1c5a5 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -89,6 +89,8 @@ 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('configure: configureProxyResponseLifecycle') + expect(viteConfig.match(/backendDevProxyOptions\(\)/g)).toHaveLength(4) }) it('Vite forwards public page routes to the CMS server instead of the admin SPA', () => { diff --git a/vite.config.ts b/vite.config.ts index 24432c221..84cc5ef3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,9 @@ -import { defineConfig, type Plugin } from 'vite' +import { defineConfig, type Plugin, type ProxyOptions } from 'vite' 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 { configureProxyResponseLifecycle } from './scripts/lib/viteProxyLifecycle' const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ @@ -101,6 +102,14 @@ function publicSiteDevProxyPlugin(): Plugin { } } +function backendDevProxyOptions(): ProxyOptions { + return { + target: CMS_DEV_SERVER_ORIGIN, + changeOrigin: true, + configure: configureProxyResponseLifecycle, + } +} + // 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. @@ -238,23 +247,14 @@ export default defineConfig({ // accompanies the request. The `ws: false` default suffices; we do // not need WebSocket upgrades for the agent (NDJSON streams over a // standard HTTP response). - '/admin/api': { - target: CMS_DEV_SERVER_ORIGIN, - changeOrigin: true, - }, - '/uploads': { - target: CMS_DEV_SERVER_ORIGIN, - changeOrigin: true, - }, + '/admin/api': backendDevProxyOptions(), + '/uploads': backendDevProxyOptions(), // Public-site runtime endpoints — frontend tracker POSTs, loop // pagination GETs, runtime asset / CSS bundles. Must be in this // explicit `proxy:` map (not just the GET-only middleware) because // the tracker uses POST and the GET-only `publicSiteDevProxyPlugin` // would otherwise drop those requests. - '/_instatic': { - target: CMS_DEV_SERVER_ORIGIN, - changeOrigin: true, - }, + '/_instatic': backendDevProxyOptions(), }, }, }) From b55592064aa38b8aec1dd14ea617038ab102570c Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:26:25 +0200 Subject: [PATCH 03/10] fix(dev): proxy backend requests through Bun fetch --- scripts/lib/viteProxyLifecycle.test.ts | 85 +++++++++++++------------- scripts/lib/viteProxyLifecycle.ts | 37 +++++------ src/__tests__/devWorkflow.test.ts | 3 +- vite.config.ts | 7 ++- 4 files changed, 70 insertions(+), 62 deletions(-) diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts index 05713999b..b1f041512 100644 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -1,58 +1,61 @@ import { describe, expect, it } from 'bun:test' import { EventEmitter } from 'node:events' import type { IncomingMessage, ServerResponse } from 'node:http' -import type { HttpProxy } from 'vite' -import { configureProxyResponseLifecycle } from './viteProxyLifecycle' - -function proxyHarness(): { - proxy: EventEmitter - upstream: EventEmitter - downstream: EventEmitter - destroyed: () => number -} { - const proxy = new EventEmitter() - const upstream = new EventEmitter() - const downstream = new EventEmitter() - let destroyCount = 0 +import { linkProxyFetchToDownstream } from './viteProxyLifecycle' - Object.defineProperty(downstream, 'writableFinished', { +function downstreamResponse(writableFinished = false): { + emitter: EventEmitter + response: ServerResponse +} { + const emitter = new EventEmitter() + Object.defineProperty(emitter, 'writableFinished', { configurable: true, - value: false, - }) - Object.assign(upstream, { - destroy() { - destroyCount += 1 - upstream.emit('close') - return upstream - }, + value: writableFinished, }) + return { emitter, response: emitter as unknown as ServerResponse } +} - configureProxyResponseLifecycle(proxy as unknown as HttpProxy.ProxyServer) - proxy.emit( - 'proxyRes', - upstream as unknown as IncomingMessage, - {} as IncomingMessage, - downstream as unknown as ServerResponse, - ) +describe('Vite backend proxy fetch lifecycle', () => { + it('aborts an upstream fetch when the browser disconnects', () => { + const downstream = downstreamResponse() + const requestOptions: RequestInit = {} + linkProxyFetchToDownstream( + requestOptions, + {} as IncomingMessage, + downstream.response, + ) - return { proxy, upstream, downstream, destroyed: () => destroyCount } -} + downstream.emitter.emit('close') + + expect(requestOptions.signal?.aborted).toBe(true) + }) -describe('Vite backend proxy response lifecycle', () => { - it('destroys a streaming upstream response when the browser disconnects', () => { - const harness = proxyHarness() + it('does not abort after the downstream response finishes normally', () => { + const downstream = downstreamResponse(true) + const requestOptions: RequestInit = {} + linkProxyFetchToDownstream( + requestOptions, + {} as IncomingMessage, + downstream.response, + ) - harness.downstream.emit('close') + downstream.emitter.emit('close') - expect(harness.destroyed()).toBe(1) + expect(requestOptions.signal?.aborted).toBe(false) }) - it('leaves an upstream response alone after it ends normally', () => { - const harness = proxyHarness() + it('preserves an existing upstream abort signal', () => { + const downstream = downstreamResponse() + const existingAbort = new AbortController() + const requestOptions: RequestInit = { signal: existingAbort.signal } + linkProxyFetchToDownstream( + requestOptions, + {} as IncomingMessage, + downstream.response, + ) - harness.upstream.emit('end') - harness.downstream.emit('close') + existingAbort.abort() - expect(harness.destroyed()).toBe(0) + expect(requestOptions.signal?.aborted).toBe(true) }) }) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index 96e64b5a4..2c11a7f84 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -1,23 +1,24 @@ -import type { HttpProxy } from 'vite' +import type { IncomingMessage, ServerResponse } from 'node:http' /** - * Close an active upstream response when its downstream browser connection - * disappears. http-proxy-3 destroys the ClientRequest on downstream close, - * but after response headers arrive that request is already complete and its - * response socket stays open. Long-lived SSE/NDJSON responses would otherwise - * accumulate across browser reloads until the development proxy stalls. + * Link a fetch-based Vite proxy request to its downstream browser response. + * Vite runs under Bun in this repository, so the native fetch path avoids the + * Node `http.request` compatibility sockets that otherwise accumulate across + * repeated browser contexts. Aborting also cancels long-lived SSE/NDJSON + * response bodies as soon as their browser consumer disappears. */ -export function configureProxyResponseLifecycle(proxy: HttpProxy.ProxyServer): void { - proxy.on('proxyRes', (proxyResponse, _req, downstreamResponse) => { - const destroyUpstream = () => { - if (!downstreamResponse.writableFinished) proxyResponse.destroy() - } - const detachDownstream = () => { - downstreamResponse.off('close', destroyUpstream) - } +export function linkProxyFetchToDownstream( + requestOptions: RequestInit, + _req: IncomingMessage, + downstreamResponse: ServerResponse, +): void { + const downstreamAbort = new AbortController() + const abortUpstream = () => { + if (!downstreamResponse.writableFinished) downstreamAbort.abort() + } - downstreamResponse.once('close', destroyUpstream) - proxyResponse.once('end', detachDownstream) - proxyResponse.once('close', detachDownstream) - }) + downstreamResponse.once('close', abortUpstream) + requestOptions.signal = requestOptions.signal + ? AbortSignal.any([requestOptions.signal, downstreamAbort.signal]) + : downstreamAbort.signal } diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 6a8c1c5a5..9358623eb 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -89,7 +89,8 @@ 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('configure: configureProxyResponseLifecycle') + expect(viteConfig).toContain('fetch: globalThis.fetch') + expect(viteConfig).toContain('onBeforeRequest: linkProxyFetchToDownstream') expect(viteConfig.match(/backendDevProxyOptions\(\)/g)).toHaveLength(4) }) diff --git a/vite.config.ts b/vite.config.ts index 84cc5ef3e..fa903fc12 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ 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 { configureProxyResponseLifecycle } from './scripts/lib/viteProxyLifecycle' +import { linkProxyFetchToDownstream } from './scripts/lib/viteProxyLifecycle' const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ @@ -106,7 +106,10 @@ function backendDevProxyOptions(): ProxyOptions { return { target: CMS_DEV_SERVER_ORIGIN, changeOrigin: true, - configure: configureProxyResponseLifecycle, + fetch: globalThis.fetch, + fetchOptions: { + onBeforeRequest: linkProxyFetchToDownstream, + }, } } From 571efd04eeb56c0d27eaf877ccb6ab5da741aa6f Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:29:55 +0200 Subject: [PATCH 04/10] fix(dev): reuse native proxy connections --- scripts/lib/viteProxyLifecycle.test.ts | 16 ++++++++++++++++ scripts/lib/viteProxyLifecycle.ts | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts index b1f041512..f4c87154c 100644 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -58,4 +58,20 @@ describe('Vite backend proxy fetch lifecycle', () => { expect(requestOptions.signal?.aborted).toBe(true) }) + + it('removes the Node proxy connection-close header for native fetch pooling', () => { + const downstream = downstreamResponse() + const requestOptions: RequestInit = { + headers: { connection: 'close', 'x-test': 'kept' }, + } + linkProxyFetchToDownstream( + requestOptions, + {} as IncomingMessage, + downstream.response, + ) + + const headers = new Headers(requestOptions.headers) + expect(headers.has('connection')).toBe(false) + expect(headers.get('x-test')).toBe('kept') + }) }) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index 2c11a7f84..c7521e875 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -13,6 +13,14 @@ export function linkProxyFetchToDownstream( downstreamResponse: ServerResponse, ): void { const downstreamAbort = new AbortController() + const headers = new Headers(requestOptions.headers) + // http-proxy-3 prepares fetch requests through its Node HTTP option builder, + // which injects `Connection: close` when no Node Agent is configured. Native + // fetch owns pooling itself; remove that hop-by-hop header so Bun can reuse + // the CMS connection instead of leaving one established socket per request. + headers.delete('connection') + requestOptions.headers = headers + const abortUpstream = () => { if (!downstreamResponse.writableFinished) downstreamAbort.abort() } From 97f8ebae644a6b0d25fed05376e389b52a339990 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:37:38 +0200 Subject: [PATCH 05/10] fix(dev): cancel every closed proxy response --- scripts/lib/viteProxyLifecycle.test.ts | 4 ++-- scripts/lib/viteProxyLifecycle.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts index f4c87154c..e0a88c0b3 100644 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -30,7 +30,7 @@ describe('Vite backend proxy fetch lifecycle', () => { expect(requestOptions.signal?.aborted).toBe(true) }) - it('does not abort after the downstream response finishes normally', () => { + it('treats downstream close as terminal even after a normal response', () => { const downstream = downstreamResponse(true) const requestOptions: RequestInit = {} linkProxyFetchToDownstream( @@ -41,7 +41,7 @@ describe('Vite backend proxy fetch lifecycle', () => { downstream.emitter.emit('close') - expect(requestOptions.signal?.aborted).toBe(false) + expect(requestOptions.signal?.aborted).toBe(true) }) it('preserves an existing upstream abort signal', () => { diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index c7521e875..7a5d79a3d 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -22,7 +22,7 @@ export function linkProxyFetchToDownstream( requestOptions.headers = headers const abortUpstream = () => { - if (!downstreamResponse.writableFinished) downstreamAbort.abort() + downstreamAbort.abort() } downstreamResponse.once('close', abortUpstream) From f0a707a852ede2937bed8e84089e02a89787a655 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:42:34 +0200 Subject: [PATCH 06/10] fix(dev): own proxied response cancellation --- scripts/lib/viteProxyLifecycle.test.ts | 10 ++-- scripts/lib/viteProxyLifecycle.ts | 63 +++++++++++++++++++++++++- src/__tests__/devWorkflow.test.ts | 4 +- vite.config.ts | 9 +++- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts index e0a88c0b3..14a367359 100644 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import { EventEmitter } from 'node:events' import type { IncomingMessage, ServerResponse } from 'node:http' -import { linkProxyFetchToDownstream } from './viteProxyLifecycle' +import { prepareProxyFetch } from './viteProxyLifecycle' function downstreamResponse(writableFinished = false): { emitter: EventEmitter @@ -19,7 +19,7 @@ describe('Vite backend proxy fetch lifecycle', () => { it('aborts an upstream fetch when the browser disconnects', () => { const downstream = downstreamResponse() const requestOptions: RequestInit = {} - linkProxyFetchToDownstream( + prepareProxyFetch( requestOptions, {} as IncomingMessage, downstream.response, @@ -33,7 +33,7 @@ describe('Vite backend proxy fetch lifecycle', () => { it('treats downstream close as terminal even after a normal response', () => { const downstream = downstreamResponse(true) const requestOptions: RequestInit = {} - linkProxyFetchToDownstream( + prepareProxyFetch( requestOptions, {} as IncomingMessage, downstream.response, @@ -48,7 +48,7 @@ describe('Vite backend proxy fetch lifecycle', () => { const downstream = downstreamResponse() const existingAbort = new AbortController() const requestOptions: RequestInit = { signal: existingAbort.signal } - linkProxyFetchToDownstream( + prepareProxyFetch( requestOptions, {} as IncomingMessage, downstream.response, @@ -64,7 +64,7 @@ describe('Vite backend proxy fetch lifecycle', () => { const requestOptions: RequestInit = { headers: { connection: 'close', 'x-test': 'kept' }, } - linkProxyFetchToDownstream( + prepareProxyFetch( requestOptions, {} as IncomingMessage, downstream.response, diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index 7a5d79a3d..77bad4cb6 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -7,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * repeated browser contexts. Aborting also cancels long-lived SSE/NDJSON * response bodies as soon as their browser consumer disappears. */ -export function linkProxyFetchToDownstream( +export function prepareProxyFetch( requestOptions: RequestInit, _req: IncomingMessage, downstreamResponse: ServerResponse, @@ -30,3 +30,64 @@ export function linkProxyFetchToDownstream( ? AbortSignal.any([requestOptions.signal, downstreamAbort.signal]) : downstreamAbort.signal } + +async function waitForDrainOrClose(response: ServerResponse): Promise { + await new Promise((resolve) => { + const done = () => { + response.off('drain', done) + response.off('close', done) + resolve() + } + response.once('drain', done) + response.once('close', done) + }) +} + +/** Stream a native fetch response and cancel its active reader on disconnect. */ +export async function forwardProxyFetchResponse( + upstreamResponse: Response, + req: IncomingMessage, + downstreamResponse: ServerResponse, +): Promise { + if (downstreamResponse.destroyed) { + await upstreamResponse.body?.cancel() + return + } + + const headers: Record = {} + upstreamResponse.headers.forEach((value, key) => { + headers[key] = value + }) + downstreamResponse.writeHead(upstreamResponse.status, headers) + + if (req.method === 'HEAD' || !upstreamResponse.body) { + downstreamResponse.end() + return + } + + const reader = upstreamResponse.body.getReader() + let downstreamClosed = false + const cancelUpstream = () => { + downstreamClosed = true + void reader.cancel().catch(() => {}) + } + downstreamResponse.once('close', cancelUpstream) + + try { + while (!downstreamClosed) { + const { done, value } = await reader.read() + if (done) break + if (!downstreamResponse.write(value)) { + await waitForDrainOrClose(downstreamResponse) + } + } + if (!downstreamClosed && !downstreamResponse.writableEnded) { + downstreamResponse.end() + } + } catch (err) { + if (!downstreamClosed) throw err + } finally { + downstreamResponse.off('close', cancelUpstream) + reader.releaseLock() + } +} diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 9358623eb..279372c50 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -90,7 +90,9 @@ describe('development workflow', () => { expect(viteConfig).toContain('target: CMS_DEV_SERVER_ORIGIN') expect(viteConfig).toContain('changeOrigin: true') expect(viteConfig).toContain('fetch: globalThis.fetch') - expect(viteConfig).toContain('onBeforeRequest: linkProxyFetchToDownstream') + expect(viteConfig).toContain('selfHandleResponse: true') + expect(viteConfig).toContain('onBeforeRequest: prepareProxyFetch') + expect(viteConfig).toContain('onAfterResponse: forwardProxyFetchResponse') expect(viteConfig.match(/backendDevProxyOptions\(\)/g)).toHaveLength(4) }) diff --git a/vite.config.ts b/vite.config.ts index fa903fc12..310e3f15c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,10 @@ 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 { linkProxyFetchToDownstream } from './scripts/lib/viteProxyLifecycle' +import { + forwardProxyFetchResponse, + prepareProxyFetch, +} from './scripts/lib/viteProxyLifecycle' const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ @@ -107,8 +110,10 @@ function backendDevProxyOptions(): ProxyOptions { target: CMS_DEV_SERVER_ORIGIN, changeOrigin: true, fetch: globalThis.fetch, + selfHandleResponse: true, fetchOptions: { - onBeforeRequest: linkProxyFetchToDownstream, + onBeforeRequest: prepareProxyFetch, + onAfterResponse: forwardProxyFetchResponse, }, } } From 02d284efeb595cc98e5bfef8387325314679d14d Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:43:51 +0200 Subject: [PATCH 07/10] fix(dev): destroy closed upstream proxy responses --- scripts/lib/viteProxyLifecycle.test.ts | 100 +++++++++---------------- scripts/lib/viteProxyLifecycle.ts | 100 +++---------------------- src/__tests__/devWorkflow.test.ts | 5 +- vite.config.ts | 12 +-- 4 files changed, 51 insertions(+), 166 deletions(-) diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts index 14a367359..f859de8f8 100644 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -1,77 +1,49 @@ import { describe, expect, it } from 'bun:test' import { EventEmitter } from 'node:events' import type { IncomingMessage, ServerResponse } from 'node:http' -import { prepareProxyFetch } from './viteProxyLifecycle' +import type { HttpProxy } from 'vite' +import { configureProxyResponseLifecycle } from './viteProxyLifecycle' -function downstreamResponse(writableFinished = false): { - emitter: EventEmitter - response: ServerResponse +function proxyHarness(): { + upstream: EventEmitter + downstream: EventEmitter + destroyed: () => number } { - const emitter = new EventEmitter() - Object.defineProperty(emitter, 'writableFinished', { - configurable: true, - value: writableFinished, + const proxy = new EventEmitter() + const upstream = new EventEmitter() + const downstream = new EventEmitter() + let destroyCount = 0 + + Object.assign(upstream, { + destroy() { + destroyCount += 1 + upstream.emit('close') + return upstream + }, }) - return { emitter, response: emitter as unknown as ServerResponse } -} - -describe('Vite backend proxy fetch lifecycle', () => { - it('aborts an upstream fetch when the browser disconnects', () => { - const downstream = downstreamResponse() - const requestOptions: RequestInit = {} - prepareProxyFetch( - requestOptions, - {} as IncomingMessage, - downstream.response, - ) - - downstream.emitter.emit('close') - - expect(requestOptions.signal?.aborted).toBe(true) - }) - - it('treats downstream close as terminal even after a normal response', () => { - const downstream = downstreamResponse(true) - const requestOptions: RequestInit = {} - prepareProxyFetch( - requestOptions, - {} as IncomingMessage, - downstream.response, - ) - downstream.emitter.emit('close') + configureProxyResponseLifecycle(proxy as unknown as HttpProxy.ProxyServer) + proxy.emit( + 'proxyRes', + upstream as unknown as IncomingMessage, + {} as IncomingMessage, + downstream as unknown as ServerResponse, + ) - expect(requestOptions.signal?.aborted).toBe(true) - }) - - it('preserves an existing upstream abort signal', () => { - const downstream = downstreamResponse() - const existingAbort = new AbortController() - const requestOptions: RequestInit = { signal: existingAbort.signal } - prepareProxyFetch( - requestOptions, - {} as IncomingMessage, - downstream.response, - ) - - existingAbort.abort() + return { upstream, downstream, destroyed: () => destroyCount } +} - expect(requestOptions.signal?.aborted).toBe(true) +describe('Vite backend proxy response lifecycle', () => { + it('destroys a streaming upstream response when the browser disconnects', () => { + const harness = proxyHarness() + harness.downstream.emit('close') + expect(harness.destroyed()).toBe(1) }) - it('removes the Node proxy connection-close header for native fetch pooling', () => { - const downstream = downstreamResponse() - const requestOptions: RequestInit = { - headers: { connection: 'close', 'x-test': 'kept' }, - } - prepareProxyFetch( - requestOptions, - {} as IncomingMessage, - downstream.response, - ) - - const headers = new Headers(requestOptions.headers) - expect(headers.has('connection')).toBe(false) - expect(headers.get('x-test')).toBe('kept') + it('detaches cleanup after the upstream response ends normally', () => { + const harness = proxyHarness() + harness.upstream.emit('end') + harness.downstream.emit('close') + expect(harness.destroyed()).toBe(0) }) }) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index 77bad4cb6..90380e889 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -1,93 +1,17 @@ -import type { IncomingMessage, ServerResponse } from 'node:http' +import type { HttpProxy } from 'vite' -/** - * Link a fetch-based Vite proxy request to its downstream browser response. - * Vite runs under Bun in this repository, so the native fetch path avoids the - * Node `http.request` compatibility sockets that otherwise accumulate across - * repeated browser contexts. Aborting also cancels long-lived SSE/NDJSON - * response bodies as soon as their browser consumer disappears. - */ -export function prepareProxyFetch( - requestOptions: RequestInit, - _req: IncomingMessage, - downstreamResponse: ServerResponse, -): void { - const downstreamAbort = new AbortController() - const headers = new Headers(requestOptions.headers) - // http-proxy-3 prepares fetch requests through its Node HTTP option builder, - // which injects `Connection: close` when no Node Agent is configured. Native - // fetch owns pooling itself; remove that hop-by-hop header so Bun can reuse - // the CMS connection instead of leaving one established socket per request. - headers.delete('connection') - requestOptions.headers = headers - - const abortUpstream = () => { - downstreamAbort.abort() - } - - downstreamResponse.once('close', abortUpstream) - requestOptions.signal = requestOptions.signal - ? AbortSignal.any([requestOptions.signal, downstreamAbort.signal]) - : downstreamAbort.signal -} - -async function waitForDrainOrClose(response: ServerResponse): Promise { - await new Promise((resolve) => { - const done = () => { - response.off('drain', done) - response.off('close', done) - resolve() +/** Destroy the active upstream response whenever its browser consumer closes. */ +export function configureProxyResponseLifecycle(proxy: HttpProxy.ProxyServer): void { + proxy.on('proxyRes', (proxyResponse, _req, downstreamResponse) => { + const destroyUpstream = () => { + proxyResponse.destroy() + } + const detachDownstream = () => { + downstreamResponse.off('close', destroyUpstream) } - response.once('drain', done) - response.once('close', done) - }) -} - -/** Stream a native fetch response and cancel its active reader on disconnect. */ -export async function forwardProxyFetchResponse( - upstreamResponse: Response, - req: IncomingMessage, - downstreamResponse: ServerResponse, -): Promise { - if (downstreamResponse.destroyed) { - await upstreamResponse.body?.cancel() - return - } - const headers: Record = {} - upstreamResponse.headers.forEach((value, key) => { - headers[key] = value + downstreamResponse.once('close', destroyUpstream) + proxyResponse.once('end', detachDownstream) + proxyResponse.once('close', detachDownstream) }) - downstreamResponse.writeHead(upstreamResponse.status, headers) - - if (req.method === 'HEAD' || !upstreamResponse.body) { - downstreamResponse.end() - return - } - - const reader = upstreamResponse.body.getReader() - let downstreamClosed = false - const cancelUpstream = () => { - downstreamClosed = true - void reader.cancel().catch(() => {}) - } - downstreamResponse.once('close', cancelUpstream) - - try { - while (!downstreamClosed) { - const { done, value } = await reader.read() - if (done) break - if (!downstreamResponse.write(value)) { - await waitForDrainOrClose(downstreamResponse) - } - } - if (!downstreamClosed && !downstreamResponse.writableEnded) { - downstreamResponse.end() - } - } catch (err) { - if (!downstreamClosed) throw err - } finally { - downstreamResponse.off('close', cancelUpstream) - reader.releaseLock() - } } diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 279372c50..6a8c1c5a5 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -89,10 +89,7 @@ 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('fetch: globalThis.fetch') - expect(viteConfig).toContain('selfHandleResponse: true') - expect(viteConfig).toContain('onBeforeRequest: prepareProxyFetch') - expect(viteConfig).toContain('onAfterResponse: forwardProxyFetchResponse') + expect(viteConfig).toContain('configure: configureProxyResponseLifecycle') expect(viteConfig.match(/backendDevProxyOptions\(\)/g)).toHaveLength(4) }) diff --git a/vite.config.ts b/vite.config.ts index 310e3f15c..84cc5ef3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,10 +3,7 @@ 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 { - forwardProxyFetchResponse, - prepareProxyFetch, -} from './scripts/lib/viteProxyLifecycle' +import { configureProxyResponseLifecycle } from './scripts/lib/viteProxyLifecycle' const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ @@ -109,12 +106,7 @@ function backendDevProxyOptions(): ProxyOptions { return { target: CMS_DEV_SERVER_ORIGIN, changeOrigin: true, - fetch: globalThis.fetch, - selfHandleResponse: true, - fetchOptions: { - onBeforeRequest: prepareProxyFetch, - onAfterResponse: forwardProxyFetchResponse, - }, + configure: configureProxyResponseLifecycle, } } From a5f7d01652e051569e48b79fc79a5dad2b894dde Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:45:03 +0200 Subject: [PATCH 08/10] fix(dev): follow downstream proxy sockets --- scripts/lib/viteProxyLifecycle.test.ts | 8 +++++--- scripts/lib/viteProxyLifecycle.ts | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts index f859de8f8..8efc34232 100644 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ b/scripts/lib/viteProxyLifecycle.test.ts @@ -7,11 +7,13 @@ import { configureProxyResponseLifecycle } from './viteProxyLifecycle' function proxyHarness(): { upstream: EventEmitter downstream: EventEmitter + clientSocket: EventEmitter destroyed: () => number } { const proxy = new EventEmitter() const upstream = new EventEmitter() const downstream = new EventEmitter() + const clientSocket = new EventEmitter() let destroyCount = 0 Object.assign(upstream, { @@ -26,17 +28,17 @@ function proxyHarness(): { proxy.emit( 'proxyRes', upstream as unknown as IncomingMessage, - {} as IncomingMessage, + { socket: clientSocket } as unknown as IncomingMessage, downstream as unknown as ServerResponse, ) - return { upstream, downstream, destroyed: () => destroyCount } + return { upstream, downstream, clientSocket, destroyed: () => destroyCount } } describe('Vite backend proxy response lifecycle', () => { it('destroys a streaming upstream response when the browser disconnects', () => { const harness = proxyHarness() - harness.downstream.emit('close') + harness.clientSocket.emit('close') expect(harness.destroyed()).toBe(1) }) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index 90380e889..2160027c8 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -2,15 +2,17 @@ import type { HttpProxy } from 'vite' /** Destroy the active upstream response whenever its browser consumer closes. */ export function configureProxyResponseLifecycle(proxy: HttpProxy.ProxyServer): void { - proxy.on('proxyRes', (proxyResponse, _req, downstreamResponse) => { + proxy.on('proxyRes', (proxyResponse, req, downstreamResponse) => { const destroyUpstream = () => { proxyResponse.destroy() } const detachDownstream = () => { downstreamResponse.off('close', destroyUpstream) + req.socket.off('close', destroyUpstream) } downstreamResponse.once('close', destroyUpstream) + req.socket.once('close', destroyUpstream) proxyResponse.once('end', detachDownstream) proxyResponse.once('close', detachDownstream) }) From d65af46567b648bacd3ade5057dab78125aec745 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:46:16 +0200 Subject: [PATCH 09/10] fix(dev): keep stream cleanup armed until end --- scripts/lib/viteProxyLifecycle.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts index 2160027c8..37e8c9180 100644 --- a/scripts/lib/viteProxyLifecycle.ts +++ b/scripts/lib/viteProxyLifecycle.ts @@ -3,17 +3,17 @@ import type { HttpProxy } from 'vite' /** Destroy the active upstream response whenever its browser consumer closes. */ export function configureProxyResponseLifecycle(proxy: HttpProxy.ProxyServer): void { proxy.on('proxyRes', (proxyResponse, req, downstreamResponse) => { - const destroyUpstream = () => { - proxyResponse.destroy() - } const detachDownstream = () => { downstreamResponse.off('close', destroyUpstream) req.socket.off('close', destroyUpstream) } + const destroyUpstream = () => { + detachDownstream() + proxyResponse.destroy() + } downstreamResponse.once('close', destroyUpstream) req.socket.once('close', destroyUpstream) proxyResponse.once('end', detachDownstream) - proxyResponse.once('close', detachDownstream) }) } From 00454532ce9866ace017262dd4f99ae6316db55d Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 11 Jul 2026 19:48:04 +0200 Subject: [PATCH 10/10] fix(streams): bound orphaned admin connections --- scripts/lib/viteProxyLifecycle.test.ts | 51 -------------------------- scripts/lib/viteProxyLifecycle.ts | 19 ---------- server/ai/mcp/editorBridge.ts | 6 +++ server/handlers/cms/plugins/events.ts | 7 ++++ src/__tests__/devWorkflow.test.ts | 2 - vite.config.ts | 26 ++++++------- 6 files changed, 26 insertions(+), 85 deletions(-) delete mode 100644 scripts/lib/viteProxyLifecycle.test.ts delete mode 100644 scripts/lib/viteProxyLifecycle.ts diff --git a/scripts/lib/viteProxyLifecycle.test.ts b/scripts/lib/viteProxyLifecycle.test.ts deleted file mode 100644 index 8efc34232..000000000 --- a/scripts/lib/viteProxyLifecycle.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { EventEmitter } from 'node:events' -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { HttpProxy } from 'vite' -import { configureProxyResponseLifecycle } from './viteProxyLifecycle' - -function proxyHarness(): { - upstream: EventEmitter - downstream: EventEmitter - clientSocket: EventEmitter - destroyed: () => number -} { - const proxy = new EventEmitter() - const upstream = new EventEmitter() - const downstream = new EventEmitter() - const clientSocket = new EventEmitter() - let destroyCount = 0 - - Object.assign(upstream, { - destroy() { - destroyCount += 1 - upstream.emit('close') - return upstream - }, - }) - - configureProxyResponseLifecycle(proxy as unknown as HttpProxy.ProxyServer) - proxy.emit( - 'proxyRes', - upstream as unknown as IncomingMessage, - { socket: clientSocket } as unknown as IncomingMessage, - downstream as unknown as ServerResponse, - ) - - return { upstream, downstream, clientSocket, destroyed: () => destroyCount } -} - -describe('Vite backend proxy response lifecycle', () => { - it('destroys a streaming upstream response when the browser disconnects', () => { - const harness = proxyHarness() - harness.clientSocket.emit('close') - expect(harness.destroyed()).toBe(1) - }) - - it('detaches cleanup after the upstream response ends normally', () => { - const harness = proxyHarness() - harness.upstream.emit('end') - harness.downstream.emit('close') - expect(harness.destroyed()).toBe(0) - }) -}) diff --git a/scripts/lib/viteProxyLifecycle.ts b/scripts/lib/viteProxyLifecycle.ts deleted file mode 100644 index 37e8c9180..000000000 --- a/scripts/lib/viteProxyLifecycle.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { HttpProxy } from 'vite' - -/** Destroy the active upstream response whenever its browser consumer closes. */ -export function configureProxyResponseLifecycle(proxy: HttpProxy.ProxyServer): void { - proxy.on('proxyRes', (proxyResponse, req, downstreamResponse) => { - const detachDownstream = () => { - downstreamResponse.off('close', destroyUpstream) - req.socket.off('close', destroyUpstream) - } - const destroyUpstream = () => { - detachDownstream() - proxyResponse.destroy() - } - - downstreamResponse.once('close', destroyUpstream) - req.socket.once('close', destroyUpstream) - proxyResponse.once('end', detachDownstream) - }) -} diff --git a/server/ai/mcp/editorBridge.ts b/server/ai/mcp/editorBridge.ts index e1bc46edf..d6aafbdfa 100644 --- a/server/ai/mcp/editorBridge.ts +++ b/server/ai/mcp/editorBridge.ts @@ -28,6 +28,7 @@ interface EditorBridgeEntry { } export type EditorBridgeScope = 'site' | 'content' +const STREAM_LEASE_MS = 120_000 const byUser = new Map>() @@ -63,11 +64,13 @@ export function createEditorBridgeStream( let bridgeId = '' let destroyBridge = (): void => {} let heartbeat: ReturnType | null = null + let lease: ReturnType | null = null const cleanup = () => { if (closed) return closed = true if (heartbeat) clearInterval(heartbeat) + if (lease) clearTimeout(lease) signal.removeEventListener('abort', cleanup) destroyBridge() @@ -119,6 +122,9 @@ export function createEditorBridgeStream( cleanup() } }, 25_000) + // Bound orphan lifetime when a proxy fails to propagate a closed + // downstream connection. The client reconnect loop restores the bridge. + lease = setTimeout(cleanup, STREAM_LEASE_MS) if (signal.aborted) cleanup() else signal.addEventListener('abort', cleanup, { once: true }) diff --git a/server/handlers/cms/plugins/events.ts b/server/handlers/cms/plugins/events.ts index 03ecfa99c..918c96a84 100644 --- a/server/handlers/cms/plugins/events.ts +++ b/server/handlers/cms/plugins/events.ts @@ -25,6 +25,8 @@ import { subscribePluginEvents } from '../../../plugins/eventBroadcaster' import { methodNotAllowed } from '../../../http' +const STREAM_LEASE_MS = 120_000 + export function handlePluginEventsStream(req: Request): Response { if (req.method !== 'GET') return methodNotAllowed() const encoder = new TextEncoder() @@ -35,11 +37,13 @@ export function handlePluginEventsStream(req: Request): Response { let closed = false let unsubscribe = (): void => {} let heartbeat: ReturnType | null = null + let lease: ReturnType | null = null const cleanup = () => { if (closed) return closed = true if (heartbeat) clearInterval(heartbeat) + if (lease) clearTimeout(lease) req.signal.removeEventListener('abort', cleanup) unsubscribe() try { controller.close() } catch { /* already closed or cancelled */ } @@ -66,6 +70,9 @@ export function handlePluginEventsStream(req: Request): Response { heartbeat = setInterval(() => { send(`: heartbeat\n\n`) }, 30_000) + // Bound orphan lifetime even when an intermediary fails to propagate a + // downstream disconnect. EventSource reconnects automatically. + lease = setTimeout(cleanup, STREAM_LEASE_MS) if (req.signal.aborted) cleanup() else req.signal.addEventListener('abort', cleanup, { once: true }) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 6a8c1c5a5..d054a1969 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -89,8 +89,6 @@ 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('configure: configureProxyResponseLifecycle') - expect(viteConfig.match(/backendDevProxyOptions\(\)/g)).toHaveLength(4) }) it('Vite forwards public page routes to the CMS server instead of the admin SPA', () => { diff --git a/vite.config.ts b/vite.config.ts index 84cc5ef3e..24432c221 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,8 @@ -import { defineConfig, type Plugin, type ProxyOptions } from 'vite' +import { defineConfig, type Plugin } from 'vite' 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 { configureProxyResponseLifecycle } from './scripts/lib/viteProxyLifecycle' const CMS_DEV_SERVER_ORIGIN = `http://localhost:${process.env.PORT ?? '3001'}` const FILE_EXTENSION_RE = /\.[a-zA-Z0-9]+$/ @@ -102,14 +101,6 @@ function publicSiteDevProxyPlugin(): Plugin { } } -function backendDevProxyOptions(): ProxyOptions { - return { - target: CMS_DEV_SERVER_ORIGIN, - changeOrigin: true, - configure: configureProxyResponseLifecycle, - } -} - // 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. @@ -247,14 +238,23 @@ export default defineConfig({ // accompanies the request. The `ws: false` default suffices; we do // not need WebSocket upgrades for the agent (NDJSON streams over a // standard HTTP response). - '/admin/api': backendDevProxyOptions(), - '/uploads': backendDevProxyOptions(), + '/admin/api': { + target: CMS_DEV_SERVER_ORIGIN, + changeOrigin: true, + }, + '/uploads': { + target: CMS_DEV_SERVER_ORIGIN, + changeOrigin: true, + }, // Public-site runtime endpoints — frontend tracker POSTs, loop // pagination GETs, runtime asset / CSS bundles. Must be in this // explicit `proxy:` map (not just the GET-only middleware) because // the tracker uses POST and the GET-only `publicSiteDevProxyPlugin` // would otherwise drop those requests. - '/_instatic': backendDevProxyOptions(), + '/_instatic': { + target: CMS_DEV_SERVER_ORIGIN, + changeOrigin: true, + }, }, }, })