Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: rewrite how vite-node resolves id #2463

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/mocks/test/axios-not-mocked.test.ts
@@ -1,7 +1,7 @@
import axios from 'axios'

test('mocked axios', async () => {
const { default: ax } = await vi.importMock('axios')
const { default: ax } = await vi.importMock<any>('axios')

await ax.get('string')

Expand Down
2 changes: 1 addition & 1 deletion packages/coverage-c8/src/provider.ts
Expand Up @@ -85,7 +85,7 @@ export class C8CoverageProvider implements CoverageProvider {
// This is a magic number. It corresponds to the amount of code
// that we add in packages/vite-node/src/client.ts:114 (vm.runInThisContext)
// TODO: Include our transformations in sourcemaps
const offset = 224
const offset = 203

report._getSourceMap = (coverage: Profiler.ScriptCoverage) => {
const path = _url.pathToFileURL(coverage.url.split('?')[0]).href
Expand Down
2 changes: 1 addition & 1 deletion packages/vite-node/package.json
Expand Up @@ -70,7 +70,7 @@
},
"scripts": {
"build": "rimraf dist && rollup -c",
"dev": "rollup -c --watch --watch.include=src -m inline",
"dev": "rollup -c --watch --watch.include 'src/**' -m inline",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit"
},
Expand Down
144 changes: 66 additions & 78 deletions packages/vite-node/src/client.ts
@@ -1,10 +1,13 @@
import { createRequire } from 'module'
// we need native dirname, because windows __dirname has \\
// eslint-disable-next-line no-restricted-imports
import { dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import vm from 'vm'
import { dirname, extname, isAbsolute, resolve } from 'pathe'
import { extname, isAbsolute, resolve } from 'pathe'
import { isNodeBuiltin } from 'mlly'
import createDebug from 'debug'
import { cleanUrl, isPrimitive, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils'
import { VALID_ID_PREFIX, cleanUrl, isInternalRequest, isPrimitive, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils'
import type { HotContext, ModuleCache, ViteNodeRunnerOptions } from './types'
import { extractSourceMap } from './source-map'

Expand Down Expand Up @@ -145,24 +148,25 @@ export class ViteNodeRunner {
}

async executeFile(file: string) {
return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, [])
const id = slash(resolve(file))
const url = `/@fs/${slash(resolve(file))}`
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
return await this.cachedRequest(id, url, [])
}

async executeId(id: string) {
return await this.cachedRequest(id, [])
const url = await this.resolveUrl(id)
return await this.cachedRequest(id, url, [])
}

getSourceMap(id: string) {
return this.moduleCache.getSourceMap(id)
}

/** @internal */
async cachedRequest(rawId: string, callstack: string[]) {
const id = normalizeRequestId(rawId, this.options.base)
const fsPath = toFilePath(id, this.root)
async cachedRequest(rawId: string, fsPath: string, callstack: string[]) {
const importee = callstack[callstack.length - 1]

const mod = this.moduleCache.get(fsPath)
const importee = callstack[callstack.length - 1]

if (!mod.importers)
mod.importers = new Set()
Expand All @@ -177,7 +181,7 @@ export class ViteNodeRunner {
if (mod.promise)
return mod.promise

const promise = this.directRequest(id, fsPath, callstack)
const promise = this.directRequest(rawId, fsPath, callstack)
Object.assign(mod, { promise, evaluated: false })

try {
Expand All @@ -188,79 +192,65 @@ export class ViteNodeRunner {
}
}

async resolveUrl(id: string, importee?: string): Promise<string> {
if (isInternalRequest(id))
return id
// we don't pass down importee here, because otherwise Vite doesn't resolve it correctly
if (importee && id.startsWith(VALID_ID_PREFIX))
importee = undefined
id = normalizeRequestId(id, this.options.base)
if (!this.options.resolveId)
return toFilePath(id, this.root)
const resolved = await this.options.resolveId(id, importee)
return resolved
? normalizeRequestId(resolved.id, this.options.base)
: id
}

/** @internal */
async directRequest(id: string, fsPath: string, _callstack: string[]) {
const callstack = [..._callstack, fsPath]
async dependencyRequest(rawId: string, fsPath: string, callstack: string[]) {
const getStack = () => {
return `stack:\n${[...callstack, fsPath].reverse().map(p => `- ${p}`).join('\n')}`
}

let mod = this.moduleCache.get(fsPath)
let debugTimer: any
if (this.debug)
debugTimer = setTimeout(() => console.warn(() => `module ${fsPath} takes over 2s to load.\n${getStack()}`), 2000)

const request = async (dep: string) => {
const depFsPath = toFilePath(normalizeRequestId(dep, this.options.base), this.root)
const getStack = () => {
return `stack:\n${[...callstack, depFsPath].reverse().map(p => `- ${p}`).join('\n')}`
try {
if (callstack.includes(fsPath)) {
const depExports = this.moduleCache.get(fsPath)?.exports
if (depExports)
return depExports
throw new Error(`[vite-node] Failed to resolve circular dependency, ${getStack()}`)
}

let debugTimer: any
if (this.debug)
debugTimer = setTimeout(() => console.warn(() => `module ${depFsPath} takes over 2s to load.\n${getStack()}`), 2000)

try {
if (callstack.includes(depFsPath)) {
const depExports = this.moduleCache.get(depFsPath)?.exports
if (depExports)
return depExports
throw new Error(`[vite-node] Failed to resolve circular dependency, ${getStack()}`)
}

return await this.cachedRequest(dep, callstack)
}
finally {
if (debugTimer)
clearTimeout(debugTimer)
}
return await this.cachedRequest(rawId, fsPath, callstack)
}
finally {
if (debugTimer)
clearTimeout(debugTimer)
}
}

Object.defineProperty(request, 'callstack', { get: () => callstack })

const resolveId = async (dep: string, callstackPosition = 1): Promise<[dep: string, id: string | undefined]> => {
if (this.options.resolveId && this.shouldResolveId(dep)) {
let importer: string | undefined = callstack[callstack.length - callstackPosition]
if (importer && !dep.startsWith('.'))
importer = undefined
if (importer && importer.startsWith('mock:'))
importer = importer.slice(5)
const resolved = await this.options.resolveId(normalizeRequestId(dep), importer)
return [dep, resolved?.id]
}
/** @internal */
async directRequest(rawId: string, url: string, _callstack: string[]) {
const moduleId = normalizeModuleId(url)
const callstack = [..._callstack, moduleId]

return [dep, undefined]
}
const mod = this.moduleCache.get(url)

const [dep, resolvedId] = await resolveId(id, 2)
const request = async (dep: string) => {
const depFsPath = await this.resolveUrl(dep, url)
return this.dependencyRequest(dep, depFsPath, callstack)
}

const requestStubs = this.options.requestStubs || DEFAULT_REQUEST_STUBS
if (id in requestStubs)
return requestStubs[id]
if (rawId in requestStubs)
return requestStubs[rawId]

// eslint-disable-next-line prefer-const
let { code: transformed, externalize } = await this.options.fetchModule(resolvedId || dep)

// in case we resolved fsPath incorrectly, Vite will return the correct file path
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
// in that case we need to update cache, so we don't have the same module as different exports
// but we ignore fsPath that has custom query, because it might need to be different
if (resolvedId && !fsPath.includes('?') && fsPath !== resolvedId) {
if (this.moduleCache.has(resolvedId)) {
mod = this.moduleCache.get(resolvedId)
this.moduleCache.set(fsPath, mod)
if (mod.promise)
return mod.promise
if (mod.exports)
return mod.exports
}
else {
this.moduleCache.set(resolvedId, mod)
}
}
let { code: transformed, externalize } = await this.options.fetchModule(url)

if (externalize) {
debugNative(externalize)
Expand All @@ -270,13 +260,12 @@ export class ViteNodeRunner {
}

if (transformed == null)
throw new Error(`[vite-node] Failed to load ${id}`)

const file = cleanUrl(resolvedId || fsPath)
throw new Error(`[vite-node] Failed to load "${rawId}" imported from ${callstack[callstack.length - 2]}`)

const modulePath = cleanUrl(moduleId)
// disambiguate the `<UNIT>:/` on windows: see nodejs/node#31710
const url = pathToFileURL(file).href
const meta = { url }
const href = pathToFileURL(modulePath).href
const meta = { url: href }
const exports = Object.create(null)
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module',
Expand Down Expand Up @@ -305,7 +294,7 @@ export class ViteNodeRunner {
})

Object.assign(mod, { code: transformed, exports })
const __filename = fileURLToPath(url)
const __filename = fileURLToPath(href)
const moduleProxy = {
set exports(value) {
exportAll(cjsExports, value)
Expand All @@ -322,7 +311,7 @@ export class ViteNodeRunner {
Object.defineProperty(meta, 'hot', {
enumerable: true,
get: () => {
hotContext ||= this.options.createHotContext?.(this, `/@fs/${fsPath}`)
hotContext ||= this.options.createHotContext?.(this, `/@fs/${url}`)
return hotContext
},
})
Expand All @@ -339,10 +328,9 @@ export class ViteNodeRunner {
__vite_ssr_exports__: exports,
__vite_ssr_exportAll__: (obj: any) => exportAll(exports, obj),
__vite_ssr_import_meta__: meta,
__vitest_resolve_id__: resolveId,

// cjs compact
require: createRequire(url),
require: createRequire(href),
exports: cjsExports,
module: moduleProxy,
__filename,
Expand Down
29 changes: 8 additions & 21 deletions packages/vite-node/src/utils.ts
@@ -1,7 +1,6 @@
import { fileURLToPath, pathToFileURL } from 'url'
import { existsSync } from 'fs'
import { relative, resolve } from 'pathe'
import { isNodeBuiltin } from 'mlly'
import { resolve } from 'pathe'
import type { Arrayable, Nullable } from './types'

export const isWindows = process.platform === 'win32'
Expand All @@ -14,6 +13,8 @@ export function mergeSlashes(str: string) {
return str.replace(/\/\//g, '/')
}

export const VALID_ID_PREFIX = '/@id/'

export function normalizeRequestId(id: string, base?: string): string {
if (base && id.startsWith(base))
id = `/${id.slice(base.length)}`
Expand All @@ -40,10 +41,14 @@ export const hashRE = /#.*$/s
export const cleanUrl = (url: string): string =>
url.replace(hashRE, '').replace(queryRE, '')

export const isInternalRequest = (id: string): boolean => {
return id.startsWith('/@vite/')
}

export function normalizeModuleId(id: string) {
return id
.replace(/\\/g, '/')
.replace(/^\/@fs\//, '/')
.replace(/^\/@fs\//, isWindows ? '' : '/')
.replace(/^file:\//, '/')
.replace(/^\/+/, '/')
}
Expand All @@ -52,24 +57,6 @@ export function isPrimitive(v: any) {
return v !== Object(v)
}

export function pathFromRoot(root: string, filename: string) {
if (isNodeBuiltin(filename))
return filename

// don't replace with "/" on windows, "/C:/foo" is not a valid path
filename = filename.replace(/^\/@fs\//, isWindows ? '' : '/')

if (!filename.startsWith(root))
return filename

const relativePath = relative(root, filename)

const segments = relativePath.split('/')
const startIndex = segments.findIndex(segment => segment !== '..' && segment !== '.')

return `/${segments.slice(startIndex).join('/')}`
}

export function toFilePath(id: string, root: string): string {
let absolute = (() => {
if (id.startsWith('/@fs/'))
Expand Down
24 changes: 5 additions & 19 deletions packages/vitest/src/integrations/vi.ts
Expand Up @@ -2,7 +2,7 @@ import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
import { parseStacktrace } from '../utils/source-map'
import type { VitestMocker } from '../runtime/mocker'
import type { ResolvedConfig, RuntimeConfig } from '../types'
import { getWorkerState, resetModules, setTimeout } from '../utils'
import { getWorkerState, resetModules, waitForImportsToResolve } from '../utils'
import { FakeTimers } from './mock/timers'
import type { EnhancedSpy, MaybeMocked, MaybeMockedDeep, MaybePartiallyMocked, MaybePartiallyMockedDeep } from './spy'
import { fn, isMockFunction, spies, spyOn } from './spy'
Expand Down Expand Up @@ -245,26 +245,12 @@ class VitestUtils {
}

/**
* Wait for all imports to load.
* Useful, if you have a synchronous call that starts
* importing a module that you cannot wait otherwise.
* Wait for all imports to load. Useful, if you have a synchronous call that starts
* importing a module that you cannot await otherwise.
* Will also wait for new imports, started during the wait.
*/
public async dynamicImportSettled() {
const state = getWorkerState()
const promises: Promise<unknown>[] = []
for (const mod of state.moduleCache.values()) {
if (mod.promise && !mod.evaluated)
promises.push(mod.promise)
}
if (!promises.length)
return
await Promise.allSettled(promises)
// wait until the end of the loop, so `.then` on modules is called,
// like in import('./example').then(...)
// also call dynamicImportSettled again in case new imports were added
await new Promise(resolve => setTimeout(resolve, 1))
.then(() => Promise.resolve())
.then(() => this.dynamicImportSettled())
return waitForImportsToResolve()
}

private _config: null | ResolvedConfig = null
Expand Down