Skip to content

Commit 103a600

Browse files
authored
fix(vm): support network imports (#5610)
1 parent cc8f058 commit 103a600

File tree

5 files changed

+88
-29
lines changed

5 files changed

+88
-29
lines changed

packages/vitest/src/runtime/external-executor.ts

+35-15
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface ExternalModulesExecutorOptions {
2727
}
2828

2929
interface ModuleInformation {
30-
type: 'data' | 'builtin' | 'vite' | 'wasm' | 'module' | 'commonjs'
30+
type: 'data' | 'builtin' | 'vite' | 'wasm' | 'module' | 'commonjs' | 'network'
3131
url: string
3232
path: string
3333
}
@@ -41,6 +41,8 @@ export class ExternalModulesExecutor {
4141
private fs: FileMap
4242
private resolvers: ((id: string, parent: string) => string | undefined)[] = []
4343

44+
#networkSupported: boolean | null = null
45+
4446
constructor(private options: ExternalModulesExecutorOptions) {
4547
this.context = options.context
4648

@@ -62,6 +64,20 @@ export class ExternalModulesExecutor {
6264
this.resolvers = [this.vite.resolve]
6365
}
6466

67+
async import(identifier: string) {
68+
const module = await this.createModule(identifier)
69+
await this.esm.evaluateModule(module)
70+
return module.namespace
71+
}
72+
73+
require(identifier: string) {
74+
return this.cjs.require(identifier)
75+
}
76+
77+
createRequire(identifier: string) {
78+
return this.cjs.createRequire(identifier)
79+
}
80+
6581
// dynamic import can be used in both ESM and CJS, so we have it in the executor
6682
public importModuleDynamically = async (specifier: string, referencer: VMModule) => {
6783
const module = await this.resolveModule(specifier, referencer.identifier)
@@ -161,6 +177,9 @@ export class ExternalModulesExecutor {
161177
if (extension === '.node' || isNodeBuiltin(identifier))
162178
return { type: 'builtin', url: identifier, path: identifier }
163179

180+
if (this.isNetworkSupported && (identifier.startsWith('http:') || identifier.startsWith('https:')))
181+
return { type: 'network', url: identifier, path: identifier }
182+
164183
const isFileUrl = identifier.startsWith('file://')
165184
const pathUrl = isFileUrl ? fileURLToPath(identifier.split('?')[0]) : identifier
166185
const fileUrl = isFileUrl ? identifier : pathToFileURL(pathUrl).toString()
@@ -209,31 +228,32 @@ export class ExternalModulesExecutor {
209228
case 'vite':
210229
return await this.vite.createViteModule(url)
211230
case 'wasm':
212-
return await this.esm.createWebAssemblyModule(url, this.fs.readBuffer(path))
231+
return await this.esm.createWebAssemblyModule(url, () => this.fs.readBuffer(path))
213232
case 'module':
214-
return await this.esm.createEsModule(url, this.fs.readFile(path))
233+
return await this.esm.createEsModule(url, () => this.fs.readFile(path))
215234
case 'commonjs': {
216235
const exports = this.require(path)
217236
return this.wrapCommonJsSynteticModule(identifier, exports)
218237
}
238+
case 'network': {
239+
return this.esm.createNetworkModule(url)
240+
}
219241
default: {
220242
const _deadend: never = type
221243
return _deadend
222244
}
223245
}
224246
}
225247

226-
async import(identifier: string) {
227-
const module = await this.createModule(identifier)
228-
await this.esm.evaluateModule(module)
229-
return module.namespace
230-
}
231-
232-
require(identifier: string) {
233-
return this.cjs.require(identifier)
234-
}
235-
236-
createRequire(identifier: string) {
237-
return this.cjs.createRequire(identifier)
248+
private get isNetworkSupported() {
249+
if (this.#networkSupported == null) {
250+
if (process.execArgv.includes('--experimental-network-imports'))
251+
this.#networkSupported = true
252+
else if (process.env.NODE_OPTIONS?.includes('--experimental-network-imports'))
253+
this.#networkSupported = true
254+
else
255+
this.#networkSupported = false
256+
}
257+
return this.#networkSupported
238258
}
239259
}

packages/vitest/src/runtime/vm/esm-executor.ts

+39-4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export class EsmExecutor {
1818
private esmLinkMap = new WeakMap<VMModule, Promise<void>>()
1919
private context: vm.Context
2020

21+
#httpIp = IPnumber('127.0.0.0')
22+
2123
constructor(private executor: ExternalModulesExecutor, options: EsmExecutorOptions) {
2224
this.context = options.context
2325
}
@@ -38,10 +40,11 @@ export class EsmExecutor {
3840
return m
3941
}
4042

41-
public async createEsModule(fileUrl: string, code: string) {
43+
public async createEsModule(fileUrl: string, getCode: () => Promise<string> | string) {
4244
const cached = this.moduleCache.get(fileUrl)
4345
if (cached)
4446
return cached
47+
const code = await getCode()
4548
// TODO: should not be allowed in strict mode, implement in #2854
4649
if (fileUrl.endsWith('.json')) {
4750
const m = new SyntheticModule(
@@ -77,15 +80,35 @@ export class EsmExecutor {
7780
return m
7881
}
7982

80-
public async createWebAssemblyModule(fileUrl: string, code: Buffer) {
83+
public async createWebAssemblyModule(fileUrl: string, getCode: () => Buffer) {
8184
const cached = this.moduleCache.get(fileUrl)
8285
if (cached)
8386
return cached
84-
const m = this.loadWebAssemblyModule(code, fileUrl)
87+
const m = this.loadWebAssemblyModule(getCode(), fileUrl)
8588
this.moduleCache.set(fileUrl, m)
8689
return m
8790
}
8891

92+
public async createNetworkModule(fileUrl: string) {
93+
// https://nodejs.org/api/esm.html#https-and-http-imports
94+
if (fileUrl.startsWith('http:')) {
95+
const url = new URL(fileUrl)
96+
if (
97+
url.hostname !== 'localhost'
98+
&& url.hostname !== '::1'
99+
&& (IPnumber(url.hostname) & IPmask(8)) !== this.#httpIp
100+
) {
101+
throw new Error(
102+
// we don't know the importer, so it's undefined (the same happens in --pool=threads)
103+
`import of '${fileUrl}' by undefined is not supported: `
104+
+ 'http can only be used to load local resources (use https instead).',
105+
)
106+
}
107+
}
108+
109+
return this.createEsModule(fileUrl, () => fetch(fileUrl).then(r => r.text()))
110+
}
111+
89112
public async loadWebAssemblyModule(source: Buffer, identifier: string) {
90113
const cached = this.moduleCache.get(identifier)
91114
if (cached)
@@ -187,6 +210,18 @@ export class EsmExecutor {
187210
return module
188211
}
189212

190-
return this.createEsModule(identifier, code)
213+
return this.createEsModule(identifier, () => code)
191214
}
192215
}
216+
217+
function IPnumber(address: string) {
218+
const ip = address.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
219+
if (ip)
220+
return (+ip[1] << 24) + (+ip[2] << 16) + (+ip[3] << 8) + (+ip[4])
221+
222+
throw new Error(`Expected IP address, received ${address}`)
223+
}
224+
225+
function IPmask(maskSize: number) {
226+
return -1 << (32 - maskSize)
227+
}

packages/vitest/src/runtime/vm/vite-executor.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ export class ViteExecutor {
5656
const cached = this.esm.resolveCachedModule(fileUrl)
5757
if (cached)
5858
return cached
59-
const result = await this.options.transform(fileUrl, 'web')
60-
if (!result.code)
61-
throw new Error(`[vitest] Failed to transform ${fileUrl}. Does the file exist?`)
62-
return this.esm.createEsModule(fileUrl, result.code)
59+
return this.esm.createEsModule(fileUrl, async () => {
60+
const result = await this.options.transform(fileUrl, 'web')
61+
if (!result.code)
62+
throw new Error(`[vitest] Failed to transform ${fileUrl}. Does the file exist?`)
63+
return result.code
64+
})
6365
}
6466

6567
private createViteClientModule() {

test/cli/fixtures/network-imports/basic.test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ import slash from 'http://localhost:9602/[email protected]'
99
test('network imports', () => {
1010
expect(slash('foo\\bar')).toBe('foo/bar')
1111
})
12+
13+
test('doesn\'t work for http outside localhost', async () => {
14+
// @ts-expect-error network imports
15+
await expect(() => import('http://100.0.0.0/')).rejects.toThrowError(
16+
'import of \'http://100.0.0.0/\' by undefined is not supported: http can only be used to load local resources (use https instead).',
17+
)
18+
})

test/cli/test/network-imports.test.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ const config = {
99
forks: {
1010
execArgv: ['--experimental-network-imports'],
1111
},
12-
// not supported?
13-
// FAIL test/basic.test.ts [ test/basic.test.ts ]
14-
// Error: ENOENT: no such file or directory, open 'http://localhost:9602/[email protected]'
15-
// ❯ Object.openSync node:fs:596:3
16-
// ❯ readFileSync node:fs:464:35
1712
vmThreads: {
1813
execArgv: ['--experimental-network-imports'],
1914
},
@@ -25,7 +20,7 @@ const config = {
2520
it.each([
2621
'threads',
2722
'forks',
28-
// 'vmThreads',
23+
'vmThreads',
2924
])('importing from network in %s', async (pool) => {
3025
const { stderr, exitCode } = await runVitest({
3126
...config,

0 commit comments

Comments
 (0)