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: treat pseudo ESM as ESM with custom loader, disable custom Node Loader by default #1778

Merged
merged 11 commits into from Aug 5, 2022
2 changes: 1 addition & 1 deletion docs/config/index.md
Expand Up @@ -105,7 +105,7 @@ This might potentially cause some misalignment if a package has different logic
#### deps.registerNodeLoader

- **Type:** `boolean`
- **Default:** `true`
- **Default:** `false`

Use [experimental Node loader](https://nodejs.org/api/esm.html#loaders) to resolve imports inside `node_modules`, using Vite resolve algorithm.

Expand Down
3 changes: 3 additions & 0 deletions examples/solid/vite.config.mjs
Expand Up @@ -10,6 +10,9 @@ export default defineConfig({
transformMode: {
web: [/.[jt]sx?/],
},
deps: {
registerNodeLoader: true,
},
threads: false,
isolate: false,
},
Expand Down
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -22,8 +22,8 @@
"test": "vitest --api -r test/core",
"test:run": "vitest run -r test/core",
"test:all": "cross-env CI=true pnpm -r --stream run test --allowOnly",
"test:ci": "cross-env CI=true pnpm -r --stream --filter !test-fails --filter !test-browser run test --allowOnly",
"test:ci:single-thread": "cross-env CI=true pnpm -r --stream --filter !test-fails run test --allowOnly --no-threads",
"test:ci": "cross-env CI=true pnpm -r --stream --filter !test-fails --filter !test-browser --filter !test-esm run test --allowOnly",
"test:ci:single-thread": "cross-env CI=true pnpm -r --stream --filter !test-fails --filter !test-esm run test --allowOnly --no-threads",
"typecheck": "tsc --noEmit",
"ui:build": "vite build packages/ui",
"ui:dev": "vite packages/ui",
Expand Down
4 changes: 2 additions & 2 deletions packages/vite-node/src/server.ts
@@ -1,4 +1,4 @@
import { join } from 'pathe'
import { resolve } from 'pathe'
import type { TransformResult, ViteDevServer } from 'vite'
import createDebug from 'debug'
import type { DebuggerOptions, FetchResult, RawSourceMap, ViteNodeResolveId, ViteNodeServerOptions } from './types'
Expand Down Expand Up @@ -66,7 +66,7 @@ export class ViteNodeServer {

async resolveId(id: string, importer?: string): Promise<ViteNodeResolveId | null> {
if (importer && !importer.startsWith(this.server.config.root))
importer = join(this.server.config.root, importer)
importer = resolve(this.server.config.root, importer)
const mode = (importer && this.getTransformMode(importer)) || 'ssr'
return this.server.pluginContainer.resolveId(id, importer, { ssr: mode === 'ssr' })
}
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/config.ts
Expand Up @@ -131,7 +131,7 @@ export function resolveConfig(

// disable loader for Yarn PnP until Node implements chain loader
// https://github.com/nodejs/node/pull/43772
resolved.deps.registerNodeLoader ??= typeof process.versions.pnp === 'undefined'
resolved.deps.registerNodeLoader ??= false

resolved.testNamePattern = resolved.testNamePattern
? resolved.testNamePattern instanceof RegExp
Expand Down
92 changes: 77 additions & 15 deletions packages/vitest/src/runtime/loader.ts
@@ -1,32 +1,94 @@
import { pathToFileURL } from 'url'
import { isNodeBuiltin } from 'mlly'
import { readFile } from 'fs/promises'
import { hasCJSSyntax, isNodeBuiltin } from 'mlly'
import { normalizeModuleId } from 'vite-node/utils'
import { getWorkerState } from '../utils'
import type { Loader, Resolver } from '../types/loader'
import type { Loader, ResolveResult, Resolver } from '../types/loader'
import { ModuleFormat } from '../types/loader'

// TODO fix in mlly (add "}" as a possible first character: "}export default")
const ESM_RE = /([\s;}]|^)(import[\w,{}\s*]*from|import\s*['"*{]|export\b\s*(?:[*{]|default|class|type|function|const|var|let|async function)|import\.meta\b)/m
function hasESMSyntax(code: string) {
return ESM_RE.test(code)
}

interface ContextCache {
isPseudoESM: boolean
source: string
}

const cache = new Map<string, ContextCache>()

const getPotentialSource = async (filepath: string, result: ResolveResult) => {
if (!result.url.startsWith('file://') || result.format === 'module')
return null
let source = cache.get(result.url)?.source
if (source == null)
source = await readFile(filepath, 'utf8')
return source
}

const detectESM = (url: string, source: string | null) => {
const cached = cache.get(url)
if (cached)
return cached.isPseudoESM
if (!source)
return false
return (hasESMSyntax(source) && !hasCJSSyntax(source))
}

// apply transformations only to libraries
// inline code preccessed by vite-node
// make Node pseudo ESM
export const resolve: Resolver = async (url, context, next) => {
const { parentURL } = context
if (!parentURL || !parentURL.includes('node_modules') || isNodeBuiltin(url))
const state = getWorkerState()
const resolver = state?.rpc.resolveId

if (!parentURL || isNodeBuiltin(url) || !resolver)
return next(url, context, next)

const id = normalizeModuleId(url)
const importer = normalizeModuleId(parentURL)
const state = getWorkerState()
const resolver = state?.rpc.resolveId
if (resolver) {
const resolved = await resolver(id, importer)
if (resolved) {
return {
url: pathToFileURL(resolved.id).toString(),
shortCircuit: true,
}
const resolved = await resolver(id, importer)

let result: ResolveResult
let filepath: string
if (resolved) {
const resolvedUrl = pathToFileURL(resolved.id).toString()
filepath = resolved.id
result = {
url: resolvedUrl,
shortCircuit: true,
}
}
return next(url, context, next)
else {
const { url: resolvedUrl, format } = await next(url, context, next)
filepath = new URL(resolvedUrl).pathname
result = {
url: resolvedUrl,
format,
shortCircuit: true,
}
}

const source = await getPotentialSource(filepath, result)
const isPseudoESM = detectESM(result.url, source)
if (typeof source === 'string')
cache.set(result.url, { isPseudoESM, source })
if (isPseudoESM)
result.format = ModuleFormat.Module
return result
}

export const load: Loader = (url, context, next) => {
return next(url, context, next)
export const load: Loader = async (url, context, next) => {
const result = await next(url, context, next)
const cached = cache.get(url)
if (cached?.isPseudoESM && result.format !== 'module') {
return {
source: cached.source,
format: ModuleFormat.Module,
}
}
return result
}
2 changes: 1 addition & 1 deletion packages/vitest/src/types/config.ts
Expand Up @@ -83,7 +83,7 @@ export interface InlineConfig {

/**
* Use experimental Node loader to resolve imports inside node_modules using Vite resolve algorithm.
* @default true
* @default false
*/
registerNodeLoader?: boolean
}
Expand Down
10 changes: 6 additions & 4 deletions packages/vitest/src/types/loader.ts
@@ -1,34 +1,36 @@
import type { Awaitable } from './general'

interface ModuleContext {
interface ModuleContext extends Record<string, unknown> {
conditions: string[]
parentURL?: string
}

enum ModuleFormat {
export enum ModuleFormat {
Builtin = 'builtin',
Commonjs = 'commonjs',
Json = 'json',
Module = 'module',
Wasm = 'wasm',
}

interface ResolveResult {
export interface ResolveResult {
url: string
shortCircuit?: boolean
format?: ModuleFormat
}

export interface Resolver {
(url: string, context: ModuleContext, next: Resolver): Awaitable<ResolveResult>
}

interface LoaderContext {
interface LoaderContext extends Record<string, any> {
format: ModuleFormat
importAssertions: Record<string, string>
}

interface LoaderResult {
format: ModuleFormat
shortCircuit?: boolean
source: string | ArrayBuffer | SharedArrayBuffer | Uint8Array
}

Expand Down
12 changes: 11 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions test/esm/package.json
@@ -0,0 +1,15 @@
{
"name": "@vitest/test-esm",
"private": true,
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"css-what": "6.1.0",
"tslib": "2.4.0"
},
"devDependencies": {
"vitest": "workspace:*"
}
}
9 changes: 9 additions & 0 deletions test/esm/test/executes.spec.ts
@@ -0,0 +1,9 @@
import { __assign } from 'tslib'
import { parse } from 'css-what'
import { expect, test } from 'vitest'

// TODO check on Linux Node 14
test.skip('imported libs have incorrect ESM, but still work', () => {
expect(__assign({}, { a: 1 })).toEqual({ a: 1 })
expect(parse('a')).toBeDefined()
})
10 changes: 10 additions & 0 deletions test/esm/vite.config.ts
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
deps: {
external: [/tslib/, /css-what/],
registerNodeLoader: true,
},
},
})