|
| 1 | +importtype{IncomingMessage,RequestOptions}from'node:http' |
1 | 2 | importtype{CreateNetworkDispatcher,NetworkAddress}from'./network-host' |
2 | 3 | import{lookup}from'node:dns' |
3 | | -import{Agent,fetch}from'undici' |
| 4 | +import{AgentasHttpAgent,requestashttpRequest}from'node:http' |
| 5 | +import{AgentasHttpsAgent,requestashttpsRequest}from'node:https' |
| 6 | +import{Readable}from'node:stream' |
| 7 | +import{createBrotliDecompress,createGunzip,createInflate,constantsaszlibConstants}from'node:zlib' |
| 8 | + |
| 9 | +/** |
| 10 | + * Node has no public API for a custom DNS lookup on `globalThis.fetch`, and the proxy |
| 11 | + * needs one: the address is validated inside the socket connection, so a rebind between |
| 12 | + * the check and the connect cannot slip past. So this is a fetch built on `node:http`, |
| 13 | + * which does accept a lookup, rather than a fetch with an undici `Agent` bolted on. |
| 14 | + * Keeping undici out saves roughly 1.6 MB from a traced Node server build. |
| 15 | + */ |
| 16 | + |
| 17 | +/** Statuses that must carry a null body, per fetch. */ |
| 18 | +constNULL_BODY_STATUS=newSet([101,103,204,205,304]) |
| 19 | + |
| 20 | +constACCEPTED_ENCODINGS='gzip, deflate, br' |
| 21 | + |
| 22 | +functiontoHeaderRecord(headers: HeadersInit|undefined): Record<string,string|string[]>{ |
| 23 | +constrecord: Record<string,string|string[]>={} |
| 24 | +if(!headers) |
| 25 | +returnrecord |
| 26 | +constappend=(name: string,value: string)=>{ |
| 27 | +constkey=name.toLowerCase() |
| 28 | +constexisting=record[key] |
| 29 | +if(existing===undefined) |
| 30 | +record[key]=value |
| 31 | +elseif(Array.isArray(existing)) |
| 32 | +existing.push(value) |
| 33 | +else |
| 34 | +record[key]=[existing,value] |
| 35 | +} |
| 36 | +if(headersinstanceofHeaders){ |
| 37 | +headers.forEach((value,name)=>append(name,value)) |
| 38 | +} |
| 39 | +elseif(Array.isArray(headers)){ |
| 40 | +for(const[name,value]ofheaders)append(name,value) |
| 41 | +} |
| 42 | +else{ |
| 43 | +for(const[name,value]ofObject.entries(headers))append(name,String(value)) |
| 44 | +} |
| 45 | +returnrecord |
| 46 | +} |
| 47 | + |
| 48 | +/** Node exposes duplicate response headers only through `rawHeaders`, and `set-cookie` needs them. */ |
| 49 | +functiontoResponseHeaders(raw: string[]): Headers{ |
| 50 | +constheaders=newHeaders() |
| 51 | +for(leti=0;i<raw.length;i+=2) |
| 52 | +headers.append(raw[i]!,raw[i+1]!) |
| 53 | +returnheaders |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * Decode the body the way fetch does, so callers see plain bytes and can keep treating |
| 58 | + * `content-encoding` as a header to strip rather than a body format to handle. |
| 59 | + */ |
| 60 | +functiondecodeBody(response: IncomingMessage): Readable{ |
| 61 | +constencodings=String(response.headers['content-encoding']||'') |
| 62 | +.split(',') |
| 63 | +.map(encoding=>encoding.trim().toLowerCase()) |
| 64 | +.filter(Boolean) |
| 65 | +.reverse() |
| 66 | + |
| 67 | +// Tolerate an upstream that ends the stream without a proper trailer, the way fetch does. |
| 68 | +constflush={finishFlush: zlibConstants.Z_SYNC_FLUSH} |
| 69 | +letstream: Readable=response |
| 70 | +for(constencodingofencodings){ |
| 71 | +if(encoding==='gzip'||encoding==='x-gzip') |
| 72 | +stream=stream.pipe(createGunzip(flush)) |
| 73 | +elseif(encoding==='deflate'||encoding==='x-deflate') |
| 74 | +stream=stream.pipe(createInflate(flush)) |
| 75 | +elseif(encoding==='br') |
| 76 | +stream=stream.pipe(createBrotliDecompress()) |
| 77 | +else |
| 78 | +break// identity, or an encoding we do not decode; hand the bytes through untouched |
| 79 | +} |
| 80 | +returnstream |
| 81 | +} |
| 82 | + |
| 83 | +/** Byte length of a body fetch would send with a `content-length`, or null when it streams. */ |
| 84 | +functionknownBodyLength(body: BodyInit|null|undefined): number|null{ |
| 85 | +if(body===undefined||body===null) |
| 86 | +returnnull |
| 87 | +if(typeofbody==='string') |
| 88 | +returnBuffer.byteLength(body) |
| 89 | +if(bodyinstanceofURLSearchParams) |
| 90 | +returnBuffer.byteLength(body.toString()) |
| 91 | +if(ArrayBuffer.isView(body)) |
| 92 | +returnbody.byteLength |
| 93 | +if(bodyinstanceofArrayBuffer) |
| 94 | +returnbody.byteLength |
| 95 | +returnnull |
| 96 | +} |
| 97 | + |
| 98 | +functionwriteRequestBody(request: ReturnType<typeofhttpRequest>,body: BodyInit|null|undefined): void{ |
| 99 | +if(body===undefined||body===null){ |
| 100 | +request.end() |
| 101 | +return |
| 102 | +} |
| 103 | +if(typeofbody==='string'){ |
| 104 | +request.end(body) |
| 105 | +return |
| 106 | +} |
| 107 | +if(bodyinstanceofReadableStream){ |
| 108 | +Readable.fromWeb(bodyasParameters<typeofReadable.fromWeb>[0]).pipe(request) |
| 109 | +return |
| 110 | +} |
| 111 | +if(bodyinstanceofURLSearchParams){ |
| 112 | +request.end(body.toString()) |
| 113 | +return |
| 114 | +} |
| 115 | +if(ArrayBuffer.isView(body)){ |
| 116 | +request.end(Buffer.from(body.buffer,body.byteOffset,body.byteLength)) |
| 117 | +return |
| 118 | +} |
| 119 | +if(bodyinstanceofArrayBuffer){ |
| 120 | +request.end(Buffer.from(body)) |
| 121 | +return |
| 122 | +} |
| 123 | +request.destroy(newTypeError(`Unsupported proxy request body of type ${Object.prototype.toString.call(body)}`)) |
| 124 | +} |
| 125 | + |
| 126 | +functionrequestThroughAgent( |
| 127 | +url: URL, |
| 128 | +init: RequestInit, |
| 129 | +agents: {http: HttpAgent,https: HttpsAgent}, |
| 130 | +): Promise<Response>{ |
| 131 | +constisSecure=url.protocol==='https:' |
| 132 | +constheaders=toHeaderRecord(init.headers) |
| 133 | +if(!('accept-encoding'inheaders)) |
| 134 | +headers['accept-encoding']=ACCEPTED_ENCODINGS |
| 135 | + |
| 136 | +constmethod=(init.method||'GET').toUpperCase() |
| 137 | +if(headers['content-length']===undefined){ |
| 138 | +constlength=knownBodyLength(init.body) |
| 139 | +if(length!==null) |
| 140 | +headers['content-length']=String(length) |
| 141 | +} |
| 142 | + |
| 143 | +constoptions: RequestOptions={ |
| 144 | + method, |
| 145 | + headers, |
| 146 | +agent: isSecure ? agents.https : agents.http, |
| 147 | +signal: init.signal??undefined, |
| 148 | +} |
| 149 | + |
| 150 | +returnnewPromise<Response>((resolve,reject)=>{ |
| 151 | +constrequest=(isSecure ? httpsRequest : httpRequest)(url,options,(response)=>{ |
| 152 | +conststatus=response.statusCode??502 |
| 153 | +constbody=NULL_BODY_STATUS.has(status)||method==='HEAD' |
| 154 | + ? null |
| 155 | + : Readable.toWeb(decodeBody(response))asReadableStream<Uint8Array> |
| 156 | +resolve(newResponse(body,{ |
| 157 | + status, |
| 158 | +statusText: response.statusMessage||'', |
| 159 | +headers: toResponseHeaders(response.rawHeaders), |
| 160 | +})) |
| 161 | +}) |
| 162 | +request.on('error',reject) |
| 163 | +writeRequestBody(request,init.body) |
| 164 | +}) |
| 165 | +} |
4 | 166 |
|
5 | 167 | exportconstcreateNetworkDispatcher: CreateNetworkDispatcher=async(createLookup,resolveHostnameOverride)=>{ |
6 | 168 | constresolveHostname=resolveHostnameOverride||((hostname,callback)=>{ |
7 | 169 | lookup(hostname,{all: true,verbatim: true},(error,addresses)=>{ |
8 | 170 | callback(error,addressesasNetworkAddress[]) |
9 | 171 | }) |
10 | 172 | }) |
11 | | -constdispatcher=newAgent({ |
12 | | -connect:{ |
13 | | -lookup: createLookup(resolveHostname), |
14 | | -}, |
15 | | -}) |
| 173 | +constpinnedLookup=createLookup(resolveHostname) |
| 174 | +constagents={ |
| 175 | +http: newHttpAgent({keepAlive: true,lookup: pinnedLookup}), |
| 176 | +https: newHttpsAgent({keepAlive: true,lookup: pinnedLookup}), |
| 177 | +} |
16 | 178 | return{ |
17 | | -fetch: ((input,init)=>fetch(inputasstring|URL,{ |
18 | | - ...(initasunknownasNonNullable<Parameters<typeoffetch>[1]>), |
19 | | - dispatcher, |
20 | | -})asunknownasPromise<Response>)astypeofglobalThis.fetch, |
21 | | -close: ()=>dispatcher.close(), |
| 179 | +fetch: ((input,init)=>requestThroughAgent( |
| 180 | +newURL(inputinstanceofRequest ? input.url : String(input)), |
| 181 | +init??{}, |
| 182 | +agents, |
| 183 | +))astypeofglobalThis.fetch, |
| 184 | +close: async()=>{ |
| 185 | +agents.http.destroy() |
| 186 | +agents.https.destroy() |
| 187 | +}, |
22 | 188 | } |
23 | 189 | } |
0 commit comments