Files
deepseek-harness/packages/client/connection/src/http-bridge.ts
T

112 lines
4.3 KiB
TypeScript

/**
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
* web carrier; the fetch-shaped handler itself is transport-agnostic).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { Readable } from 'node:stream'
import type { ConnectionFetchHandler } from './rpc.ts'
/** Default carrier cap for all HTTP RPC bodies: sized for the default
* aggregate image limit (200 MiB) after base64 expansion plus envelope
* headroom (~267.7 MiB required), rounded up for slack. The bridge buffers
* each body in memory, so this cap is also the per-request resident bound. */
export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; response bodies stream out chunk by chunk).
* @param req - incoming node:http request.
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
* @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
*/
export async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: ConnectionFetchHandler,
maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
// fully consumed (immediately for a bodyless GET), which would abort a
// streaming response right after open. ServerResponse 'close' fires on connection teardown;
// writableEnded distinguishes a normal end() from the client going away.
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
/* v8 ignore next 2 -- node:http always sets url/method on server requests. */
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const method = req.method ?? 'GET'
const headers = Object.fromEntries(
Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][],
)
const bodyMode = apiHandler.requestBodyMode({ method, url })
let request: Request
if (bodyMode === 'buffered') {
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
const chunks: Buffer[] = []
let received = 0
for await (const chunk of req) {
const buffer = chunk as Buffer
received += buffer.byteLength
if (received > maxRequestBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
chunks.push(buffer)
}
request = new Request(url, {
method,
headers,
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
} else {
request = new Request(url, {
method,
headers,
body: Readable.toWeb(req) as ReadableStream<Uint8Array>,
signal: abort.signal,
duplex: 'half',
} as RequestInit & { duplex: 'half' })
}
const response = await apiHandler.fetch(request)
const requestUnread = bodyMode === 'streaming' && !req.readableEnded
const responseHeaders = Object.fromEntries(response.headers.entries())
res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders)
if (response.body === null) {
res.end()
if (requestUnread) req.destroy()
return
}
for await (const chunk of response.body) {
// Backpressure: a false return means the socket buffer is full — wait for drain
// instead of buffering unboundedly (slow or suspended consumers). 'close' also
// resolves so a mid-wait disconnect can't park this loop forever; the close
// handler above aborts the handler stream, which then ends the iteration.
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
if (requestUnread) req.destroy()
}