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.
This commit is contained in:
Alaister Young committed 2026-05-11 15:07:21 +08:00
1 parent 8c51b7f2fa
commit df2a806dab
1 file changed
+57
+57
View File
@@ -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<string, Set<(...args: unknown[]) => 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
}