import type { IncomingMessage, ServerResponse } from 'node:http' export type ForwardOptions = { /** * When set, replaces the request body sent to the origin. * Used to strip `_meta` fields before forwarding. */ bodyOverride?: string /** * When set, replaces the request pathname (not search) sent to the origin. * Used by multi-origin service routing to strip the service prefix. */ pathOverride?: string } /** * Forward an incoming Web Request to the origin server and return * the origin's Web Response unchanged. */ export async function forwardToOrigin( req: Request, originUrl: string, opts: ForwardOptions = {}, ): Promise { const incomingUrl = new URL(req.url) const path = (opts.pathOverride !== undefined ? opts.pathOverride : incomingUrl.pathname) + incomingUrl.search const target = new URL(path, originUrl) // Build a new Request aimed at the origin; strip hop-by-hop headers that // must not be forwarded (host changes because the target host differs). const forwardHeaders = new Headers(req.headers) forwardHeaders.delete('host') const hasBody = !['GET', 'HEAD'].includes(req.method.toUpperCase()) let body: string | ReadableStream | null = null if (hasBody) { if (opts.bodyOverride !== undefined) { body = opts.bodyOverride forwardHeaders.set('content-length', String(Buffer.byteLength(opts.bodyOverride, 'utf8'))) if (!forwardHeaders.has('content-type')) forwardHeaders.set('content-type', 'application/json') } else { body = req.body } } return fetch(target.toString(), { method: req.method, headers: forwardHeaders, body, // Required by the Fetch spec when sending a streaming body // @ts-ignore – Node.js 22 fetch supports this but types lag duplex: 'half', }) } /** * Convert a Node.js IncomingMessage into a Web-standard Request so that * mppx (which operates on the Fetch API Request type) can inspect it. */ export async function nodeToWebRequest(nodeReq: IncomingMessage): Promise { const host = nodeReq.headers.host ?? 'localhost' const url = `http://${host}${nodeReq.url ?? '/'}` const headers = new Headers() for (const [key, value] of Object.entries(nodeReq.headers)) { if (value === undefined) continue if (Array.isArray(value)) { for (const v of value) headers.append(key, v) } else { headers.set(key, value) } } const method = (nodeReq.method ?? 'GET').toUpperCase() const hasBody = !['GET', 'HEAD'].includes(method) let body: ReadableStream | null = null if (hasBody) { body = new ReadableStream({ start(controller) { nodeReq.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))) nodeReq.on('end', () => controller.close()) nodeReq.on('error', (err: Error) => controller.error(err)) }, }) } return new Request(url, { method, headers, body, // @ts-ignore duplex: 'half', }) } /** * Write a Web-standard Response back onto a Node.js ServerResponse. * Streams the body chunk-by-chunk so SSE and LLM streaming responses * are not buffered in memory. */ export async function webToNodeResponse( webRes: Response, nodeRes: ServerResponse, ): Promise { nodeRes.statusCode = webRes.status nodeRes.statusMessage = webRes.statusText for (const [key, value] of webRes.headers.entries()) { // Node's setHeader handles multi-value headers when given an array; // here we append to keep duplicate Set-Cookie headers intact. const existing = nodeRes.getHeader(key) if (existing !== undefined) { const prev = Array.isArray(existing) ? existing : [String(existing)] nodeRes.setHeader(key, [...prev, value]) } else { nodeRes.setHeader(key, value) } } if (!webRes.body) { nodeRes.end() return } const reader = webRes.body.getReader() try { while (true) { const { done, value } = await reader.read() if (done) break nodeRes.write(value) } } finally { reader.releaseLock() if (!nodeRes.writableEnded) nodeRes.end() } }