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
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
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: isPseudoESM || false, 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
}
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:*"
}
}
8 changes: 8 additions & 0 deletions test/esm/test/executes.spec.ts
@@ -0,0 +1,8 @@
import { __assign } from 'tslib'
import { parse } from 'css-what'
import { expect, test } from 'vitest'

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

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