Skip to content

Commit bf9bd9c

Browse files
authored
fix(cloudflare): keep Undici out of Worker bundles (#860)
1 parent e6cd4c3 commit bf9bd9c

7 files changed

Lines changed: 127 additions & 100 deletions

File tree

‎packages/script/src/module.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { NuxtScriptBundleTransformer } from './plugins/transform'
4141
import{aliasProxyValue,buildDomainAliasMap,invertAliasMap,isSafeAliasSegment}from'./proxy-alias'
4242
import{buildProxyConfigsFromRegistry,generatePartytownResolveUrl,getPartytownForwards,registry,resolveCapabilities}from'./registry'
4343
import{ensureNuxtScriptsCacheStorage}from'./runtime/server/utils/cache-config'
44-
import{isPublicNetworkHostname}from'./runtime/server/utils/network-host'
44+
import{isPublicNetworkHostname}from'./runtime/server/utils/network-hostname'
4545
import{registerTypeTemplates,templatePlugin,templateTriggerResolver}from'./templates'
4646
import{validateScriptsEnvVars}from'./validate-env'
4747

@@ -321,6 +321,15 @@ export default defineNuxtModule<ModuleOptions>({
321321
logger.debug('The module is disabled, skipping setup.')
322322
return
323323
}
324+
constnetworkDispatcherAlias='#nuxt-scripts/network-dispatcher'
325+
constnodeNetworkDispatcherPath=awaitresolvePath('./runtime/server/utils/network-dispatcher.node')
326+
constplatformNetworkDispatcherPath=awaitresolvePath('./runtime/server/utils/network-dispatcher.platform')
327+
nuxt.options.alias[networkDispatcherAlias]=nodeNetworkDispatcherPath
328+
nuxt.hook('nitro:init',(nitro)=>{
329+
nitro.options.alias[networkDispatcherAlias]=nitro.options.node
330+
? nodeNetworkDispatcherPath
331+
: platformNetworkDispatcherPath
332+
})
324333
awaitsetupNitroRuntimeCompatibility(nuxt)
325334
if(nuxt.options.dev){
326335
setupDevtools(nuxt,{standalone: config._standaloneDevtools})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
importtype{CreateNetworkDispatcher,NetworkAddress}from'./network-host'
2+
import{lookup}from'node:dns'
3+
import{Agent,fetch}from'undici'
4+
5+
exportconstcreateNetworkDispatcher: CreateNetworkDispatcher=async(createLookup,resolveHostnameOverride)=>{
6+
constresolveHostname=resolveHostnameOverride||((hostname,callback)=>{
7+
lookup(hostname,{all: true,verbatim: true},(error,addresses)=>{
8+
callback(error,addressesasNetworkAddress[])
9+
})
10+
})
11+
constdispatcher=newAgent({
12+
connect: {
13+
lookup: createLookup(resolveHostname),
14+
},
15+
})
16+
return{
17+
fetch: ((input,init)=>fetch(inputasstring|URL,{
18+
...(initasunknownasNonNullable<Parameters<typeoffetch>[1]>),
19+
dispatcher,
20+
})asunknownasPromise<Response>)astypeofglobalThis.fetch,
21+
close: ()=>dispatcher.close(),
22+
}
23+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
importtype{CreateNetworkDispatcher}from'./network-host'
2+
3+
exportconstcreateNetworkDispatcher: CreateNetworkDispatcher=async()=>({
4+
fetch: globalThis.fetch,
5+
close: async()=>{},
6+
})

‎packages/script/src/runtime/server/utils/network-host.ts‎

Lines changed: 12 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
importtype{LookupFunction}from'node:net'
2-
import{runtime}from'std-env'
2+
import{createNetworkDispatcher}from'#nuxt-scripts/network-dispatcher'
3+
import{isPublicNetworkHostname}from'./network-hostname'
34

4-
constLOCAL_HOST_SUFFIXES=[
5-
'home',
6-
'internal',
7-
'lan',
8-
'local',
9-
'localdomain',
10-
'localhost',
11-
]
5+
export{isPublicNetworkHostname}from'./network-hostname'
126

13-
interfaceNetworkAddress{
7+
exportinterfaceNetworkAddress{
148
address: string
159
family: 4|6
1610
}
1711

18-
typeResolveNetworkHostname=(
12+
exporttypeResolveNetworkHostname=(
1913
hostname: string,
2014
callback: (error: Error|null,addresses: NetworkAddress[])=>void,
2115
)=>void
@@ -31,6 +25,11 @@ export interface PublicNetworkDispatcher {
3125
close: ()=>Promise<void>
3226
}
3327

28+
exporttypeCreateNetworkDispatcher=(
29+
createLookup: (resolveHostname: ResolveNetworkHostname)=>LookupFunction,
30+
resolveHostnameOverride?: ResolveNetworkHostname,
31+
)=>Promise<PublicNetworkDispatcher>
32+
3433
/** Close a dispatcher without replacing the request or stream error already in flight. */
3534
exportasyncfunctionclosePublicNetworkDispatcher(
3635
dispatcher: PublicNetworkDispatcher|undefined,
@@ -48,68 +47,6 @@ export async function closePublicNetworkDispatcher(
4847
})
4948
}
5049

51-
functionparseIPv4(hostname: string): [number,number,number,number]|undefined{
52-
constparts=hostname.split('.')
53-
if(parts.length!==4||parts.some(part=>!/^\d{1,3}$/.test(part)))
54-
return
55-
constoctets=parts.map(Number)
56-
returnoctets.every(octet=>octet>=0&&octet<=255)
57-
? octetsas[number,number,number,number]
58-
: undefined
59-
}
60-
61-
functionisPublicIPv4([a,b,c]: [number,number,number,number]): boolean{
62-
returna!==0
63-
&&a!==10
64-
&&a!==127
65-
&&!(a===100&&b>=64&&b<=127)
66-
&&!(a===169&&b===254)
67-
&&!(a===172&&b>=16&&b<=31)
68-
&&!(a===192&&b===0&&c===0)
69-
&&!(a===192&&b===0&&c===2)
70-
&&!(a===192&&b===88&&c===99)
71-
&&!(a===192&&b===168)
72-
&&!(a===198&&(b===18||b===19))
73-
&&!(a===198&&b===51&&c===100)
74-
&&!(a===203&&b===0&&c===113)
75-
&&a<224
76-
}
77-
78-
functionisPublicIPv6(hostname: string): boolean{
79-
constgroups=hostname.split(':')
80-
constfirstGroup=Number.parseInt(groups[0]||'0',16)
81-
if(!Number.isInteger(firstGroup)||firstGroup<0x2000||firstGroup>0x3FFF)
82-
returnfalse
83-
// Documentation, Teredo, and 6to4 ranges can encode or route to non-public targets.
84-
constsecondGroup=Number.parseInt(groups[1]||'0',16)
85-
return!(firstGroup===0x2001&&(secondGroup===0||secondGroup===0xDB8))
86-
&&firstGroup!==0x2002
87-
}
88-
89-
/** Reject hostnames that directly address local, private, link-local, or reserved networks. */
90-
exportfunctionisPublicNetworkHostname(input: string): boolean{
91-
consthostname=input
92-
.trim()
93-
.toLowerCase()
94-
.replace(/^\[|\]$/g,'')
95-
.split('%',1)[0]!
96-
.replace(/\.$/,'')
97-
if(!hostname)
98-
returnfalse
99-
100-
constipv4=parseIPv4(hostname)
101-
if(ipv4)
102-
returnisPublicIPv4(ipv4)
103-
if(hostname.includes(':'))
104-
returnisPublicIPv6(hostname)
105-
106-
constlabels=hostname.split('.')
107-
if(labels.length<2)
108-
returnfalse
109-
constsuffix=labels.at(-1)!
110-
return!LOCAL_HOST_SUFFIXES.includes(suffix)
111-
}
112-
11350
/** Resolve once inside the socket connection, reject mixed/private answers, then pin the selected address. */
11451
exportfunctioncreatePublicNetworkLookup(resolveHostname: ResolveNetworkHostname): LookupFunction{
11552
constnetworkLookup: NetworkLookup=(hostname,options,callback)=>{
@@ -142,33 +79,9 @@ export function createPublicNetworkLookup(resolveHostname: ResolveNetworkHostnam
14279
returnnetworkLookupasLookupFunction
14380
}
14481

145-
/** Create a Node fetch dispatcher whose socket lookup validates and pins every DNS answer. */
82+
/** Create a host fetch implementation that validates and pins every DNS answer when Node permits it. */
14683
exportasyncfunctioncreatePublicNetworkDispatcher(resolveHostnameOverride?: ResolveNetworkHostname): Promise<PublicNetworkDispatcher>{
147-
if(runtime!=='node')
148-
return{fetch: globalThis.fetch,close: async()=>{}}
149-
150-
// Loaded only on Node. Other runtimes keep their platform fetch behavior;
151-
// call sites still reject direct non-public hostnames before fetching.
152-
const{ Agent, fetch }=awaitimport('undici')
153-
letresolveHostname=resolveHostnameOverride
154-
if(!resolveHostname){
155-
const{ lookup }=awaitimport('node:dns')
156-
resolveHostname=(hostname,callback)=>lookup(hostname,{all: true,verbatim: true},(error,addresses)=>{
157-
callback(error,addressesasNetworkAddress[])
158-
})
159-
}
160-
constdispatcher=newAgent({
161-
connect: {
162-
lookup: createPublicNetworkLookup(resolveHostname),
163-
},
164-
})
165-
return{
166-
fetch: ((input,init)=>fetch(inputasstring|URL,{
167-
...(initasunknownasNonNullable<Parameters<typeoffetch>[1]>),
168-
dispatcher,
169-
})asunknownasPromise<Response>)astypeofglobalThis.fetch,
170-
close: ()=>dispatcher.close(),
171-
}
84+
returncreateNetworkDispatcher(createPublicNetworkLookup,resolveHostnameOverride)
17285
}
17386

17487
/** Detect the tagged DNS policy error through fetch/ofetch cause wrappers. */
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
constLOCAL_HOST_SUFFIXES=[
2+
'home',
3+
'internal',
4+
'lan',
5+
'local',
6+
'localdomain',
7+
'localhost',
8+
]
9+
functionparseIPv4(hostname: string): [number,number,number,number]|undefined{
10+
constparts=hostname.split('.')
11+
if(parts.length!==4||parts.some(part=>!/^\d{1,3}$/.test(part)))
12+
return
13+
constoctets=parts.map(Number)
14+
returnoctets.every(octet=>octet>=0&&octet<=255)
15+
? octetsas[number,number,number,number]
16+
: undefined
17+
}
18+
functionisPublicIPv4([a,b,c]: [number,number,number,number]): boolean{
19+
returna!==0
20+
&&a!==10
21+
&&a!==127
22+
&&!(a===100&&b>=64&&b<=127)
23+
&&!(a===169&&b===254)
24+
&&!(a===172&&b>=16&&b<=31)
25+
&&!(a===192&&b===0&&c===0)
26+
&&!(a===192&&b===0&&c===2)
27+
&&!(a===192&&b===88&&c===99)
28+
&&!(a===192&&b===168)
29+
&&!(a===198&&(b===18||b===19))
30+
&&!(a===198&&b===51&&c===100)
31+
&&!(a===203&&b===0&&c===113)
32+
&&a<224
33+
}
34+
functionisPublicIPv6(hostname: string): boolean{
35+
constgroups=hostname.split(':')
36+
constfirstGroup=Number.parseInt(groups[0]||'0',16)
37+
if(!Number.isInteger(firstGroup)||firstGroup<0x2000||firstGroup>0x3FFF)
38+
returnfalse
39+
// Documentation, Teredo, and 6to4 ranges can encode or route to non-public targets.
40+
constsecondGroup=Number.parseInt(groups[1]||'0',16)
41+
return!(firstGroup===0x2001&&(secondGroup===0||secondGroup===0xDB8))
42+
&&firstGroup!==0x2002
43+
}
44+
45+
/** Reject hostnames that directly address local, private, link-local, or reserved networks. */
46+
exportfunctionisPublicNetworkHostname(input: string): boolean{
47+
consthostname=input
48+
.trim()
49+
.toLowerCase()
50+
.replace(/^\[|\]$/g,'')
51+
.split('%',1)[0]!
52+
.replace(/\.$/,'')
53+
if(!hostname)
54+
returnfalse
55+
56+
constipv4=parseIPv4(hostname)
57+
if(ipv4)
58+
returnisPublicIPv4(ipv4)
59+
if(hostname.includes(':'))
60+
returnisPublicIPv6(hostname)
61+
62+
constlabels=hostname.split('.')
63+
if(labels.length<2)
64+
returnfalse
65+
constsuffix=labels.at(-1)!
66+
return!LOCAL_HOST_SUFFIXES.includes(suffix)
67+
}

‎test/unit/network-host.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import{describe,expect,it,vi}from'vitest'
2+
import{createNetworkDispatcherascreatePlatformNetworkDispatcher}from'../../packages/script/src/runtime/server/utils/network-dispatcher.platform'
23
import{createPublicNetworkDispatcher,createPublicNetworkLookup,isPrivateNetworkResolutionError,isPublicNetworkHostname}from'../../packages/script/src/runtime/server/utils/network-host'
34

45
describe('public network hostname boundary',()=>{
@@ -65,6 +66,13 @@ describe('public network hostname boundary', () => {
6566
expect(error).toMatchObject({code: 'ERR_NUXT_SCRIPTS_PRIVATE_ADDRESS'})
6667
})
6768

69+
it('uses the runtime fetch on platforms without Node dispatchers',async()=>{
70+
constnetwork=awaitcreatePlatformNetworkDispatcher()
71+
72+
expect(network.fetch).toBe(globalThis.fetch)
73+
awaitexpect(network.close()).resolves.toBeUndefined()
74+
})
75+
6876
it('uses the validated lookup for the actual Node fetch connection',async()=>{
6977
constnetwork=awaitcreatePublicNetworkDispatcher((_hostname,callback)=>callback(null,[
7078
{address: '127.0.0.1',family: 4},

‎vitest.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export default defineConfig({
2828
'unhead/scripts': newURL('./packages/script/node_modules/unhead/dist/scripts.mjs',import.meta.url).pathname,
2929
'#nuxt-scripts/h3': 'h3',
3030
'#nuxt-scripts/nitro': newURL('./test/unit/__mocks__/empty.ts',import.meta.url).pathname,
31+
'#nuxt-scripts/network-dispatcher': newURL('./packages/script/src/runtime/server/utils/network-dispatcher.node.ts',import.meta.url).pathname,
3132
// Virtual emitted by the Nuxt module at build time; unit tests
3233
// mock it via `vi.mock('#build/nuxt-scripts-snippets')`, but the
3334
// import must first resolve to *something* the bundler accepts.

0 commit comments

Comments
 (0)