From df2a806daba8a0567cae5dea4e9ce59b41b89c0a Mon Sep 17 00:00:00 2001 From: Alaister Young <10985857+alaister@users.noreply.github.com> Date: Mon, 11 May 2026 15:07:21 +0800 Subject: [PATCH] fix(studio): plumb Request.signal through toWebHandler as req.on('close'|'aborted') Pages-router handlers that stream (e.g. /api/ai/sql/generate-v4) wire client-abort by subscribing to Node IncomingMessage events: const abortController = new AbortController() req.on('close', () => abortController.abort()) req.on('aborted', () => abortController.abort()) The toWebHandler shim built a plain NextApiRequest-shaped object with no EventEmitter surface, so those calls crashed at runtime with 'req.on is not a function' on every request. Surface a minimal EventEmitter (on/off/once/emit/removeListener/ removeAllListeners) that only honours the two events studio actually uses ('close', 'aborted'), both wired to request.signal's abort event. Other event names are accepted but never fire - good enough for the handlers we forward through this shim. --- apps/studio/compat/next/api.ts | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/studio/compat/next/api.ts b/apps/studio/compat/next/api.ts index 42636beb201..57516c440de 100644 --- a/apps/studio/compat/next/api.ts +++ b/apps/studio/compat/next/api.ts @@ -93,6 +93,32 @@ async function buildRequest( } } + // Surface a tiny subset of Node's `IncomingMessage` EventEmitter API so + // handlers that wire client-abort via `req.on('close', …)` / + // `req.on('aborted', …)` (e.g. AI streaming routes) can still hook into + // the Web `Request.signal`. We don't model arbitrary event types — only + // `close` and `aborted`, both of which fire when the signal aborts. + const listeners: Record void>> = {} + const fireAbort = () => { + for (const event of ['close', 'aborted'] as const) { + const set = listeners[event] + if (!set) continue + for (const fn of set) { + try { + fn() + } catch { + // Swallow — Node would emit `error` on the request, but we + // have nothing meaningful to do with it here. + } + } + } + } + if (request.signal.aborted) { + queueMicrotask(fireAbort) + } else { + request.signal.addEventListener('abort', fireAbort, { once: true }) + } + return { method, url: url.pathname + url.search, @@ -100,6 +126,37 @@ async function buildRequest( query, body, cookies, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= new Set()).add(fn) + return this + }, + off(event: string, fn: (...args: unknown[]) => void) { + listeners[event]?.delete(fn) + return this + }, + once(event: string, fn: (...args: unknown[]) => void) { + const wrapper = (...args: unknown[]) => { + listeners[event]?.delete(wrapper) + fn(...args) + } + ;(listeners[event] ??= new Set()).add(wrapper) + return this + }, + removeListener(event: string, fn: (...args: unknown[]) => void) { + listeners[event]?.delete(fn) + return this + }, + removeAllListeners(event?: string) { + if (event) listeners[event]?.clear() + else for (const set of Object.values(listeners)) set.clear() + return this + }, + emit(event: string, ...args: unknown[]) { + const set = listeners[event] + if (!set) return false + for (const fn of set) fn(...args) + return true + }, } as unknown as NextApiRequest }