Skip to content

Commit 27af14c

Browse files
authored
perf: drop undici from the Node network dispatcher (#870)
1 parent 7dd5880 commit 27af14c

5 files changed

Lines changed: 331 additions & 25 deletions

File tree

‎packages/script/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@
142142
"std-env": "catalog:",
143143
"ufo": "catalog:",
144144
"ultrahtml": "catalog:",
145-
"undici": "catalog:",
146145
"unplugin": "catalog:",
147146
"unstorage": "catalog:",
148147
"valibot": "catalog:"
Lines changed: 177 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,189 @@
1+
importtype{IncomingMessage,RequestOptions}from'node:http'
12
importtype{CreateNetworkDispatcher,NetworkAddress}from'./network-host'
23
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+
}
4166

5167
exportconstcreateNetworkDispatcher: CreateNetworkDispatcher=async(createLookup,resolveHostnameOverride)=>{
6168
constresolveHostname=resolveHostnameOverride||((hostname,callback)=>{
7169
lookup(hostname,{all: true,verbatim: true},(error,addresses)=>{
8170
callback(error,addressesasNetworkAddress[])
9171
})
10172
})
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+
}
16178
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+
},
22188
}
23189
}

‎pnpm-lock.yaml‎

Lines changed: 0 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pnpm-workspace.yaml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ catalog:
100100
ufo: ^1.6.4
101101
ultrahtml: ^1.7.0
102102
unbuild: ^3.6.1
103-
undici: ^8.10.0
104103
unhead: ^3.3.1
105104
unimport: ^6.4.0
106105
unplugin: ^3.3.0

0 commit comments

Comments
 (0)