From 4498ae963a1e27f2bdfa5a1a8e407eeadb7d3e61 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Tue, 11 Jan 2022 02:45:57 +0800 Subject: [PATCH] refactor: seperate vite runner to `vite-node` package (#495) --- packages/vite-node/README.md | 82 ++++++++ packages/vite-node/package.json | 67 +++++++ packages/vite-node/rollup.config.js | 59 ++++++ packages/vite-node/src/cli.ts | 91 +++++++++ packages/vite-node/src/client.ts | 163 ++++++++++++++++ .../utils => vite-node/src}/externalize.ts | 18 +- packages/vite-node/src/index.ts | 1 + packages/vite-node/src/server.ts | 92 +++++++++ packages/vite-node/src/types.ts | 30 +++ packages/vite-node/src/utils.ts | 39 ++++ packages/vite-node/tsconfig.json | 4 + packages/vite-node/vite-node.mjs | 2 + packages/vitest/package.json | 3 +- packages/vitest/src/api/setup.ts | 5 +- packages/vitest/src/node/config.ts | 5 +- packages/vitest/src/node/execute.ts | 180 +----------------- packages/vitest/src/node/index.ts | 17 +- packages/vitest/src/node/pool.ts | 12 +- packages/vitest/src/node/transform.ts | 70 ------- packages/vitest/src/runtime/worker.ts | 7 +- packages/vitest/src/types/config.ts | 5 - packages/vitest/src/utils/index.ts | 23 +-- packages/vitest/tsconfig.json | 1 - pnpm-lock.yaml | 32 +++- tsconfig.json | 6 +- 25 files changed, 696 insertions(+), 318 deletions(-) create mode 100644 packages/vite-node/README.md create mode 100644 packages/vite-node/package.json create mode 100644 packages/vite-node/rollup.config.js create mode 100644 packages/vite-node/src/cli.ts create mode 100644 packages/vite-node/src/client.ts rename packages/{vitest/src/utils => vite-node/src}/externalize.ts (81%) create mode 100644 packages/vite-node/src/index.ts create mode 100644 packages/vite-node/src/server.ts create mode 100644 packages/vite-node/src/types.ts create mode 100644 packages/vite-node/src/utils.ts create mode 100644 packages/vite-node/tsconfig.json create mode 100755 packages/vite-node/vite-node.mjs delete mode 100644 packages/vitest/src/node/transform.ts diff --git a/packages/vite-node/README.md b/packages/vite-node/README.md new file mode 100644 index 000000000..92a24040f --- /dev/null +++ b/packages/vite-node/README.md @@ -0,0 +1,82 @@ +# vite-node + +[![NPM version](https://img.shields.io/npm/v/vite-node?color=a1b858&label=)](https://www.npmjs.com/package/vite-node) + +Vite as Node runtime. The engine powers [Vitest](https://github.com/vitest-dev/vitest). + +## Features + +- Out-of-box ESM & TypeScript support (possible for more with plugins) +- Top-level await +- Vite plugins, resolve, aliasing +- Respect `vite.config.ts` +- Shims for `__dirname` and `__filename` in ESM +- Access to native node modules like `fs`, `path`, etc. + +## CLI Usage + +Run JS/TS file on Node.js using Vite's resolvers and transformers. + +```bash +npx vite-node index.ts +``` + +Options: + +```bash +npx vite-node -h +``` + +## Programmatic Usage + +In Vite Node, the server and runner (client) are separated, so you can integrate them in different contexts (workers, cross-process, or remote) if needed. The demo below shows a simple example of having the server and running in the same context + +```ts +import { createServer } from 'vite' +import { ViteNodeServer } from 'vite-node/server' +import { ViteNodeRunner } from 'vite-node/client' + +// create vite server +const server = await createServer() +// this is need to initialize the plugins +await server.pluginContainer.buildStart({}) + +// create vite-node server +const node = new ViteNodeServer(server) + +// create vite-node runner +const runner = new ViteNodeRunner({ + root: server.config.root, + base: server.config.base, + // when having the server and runner in a different context, + // you will need to handle the communication between them + // and pass to this function + fetchModule(id) { + return node.fetchModule(id) + }, +}) + +// execute the file +await runner.run('./example.ts') + +// close the vite server +await server.close() +``` + +## Credits + +Based on [@pi0](https://github.com/pi0)'s brilliant idea of having a Vite server as the on-demand transforming service for [Nuxt's Vite SSR](https://github.com/nuxt/vite/pull/201). + +Thanks [@brillout](https://github.com/brillout) for kindly sharing this package name. + +## Sponsors + +

+ + + +

+ +## License + +[MIT](./LICENSE) License © 2021 [Anthony Fu](https://github.com/antfu) diff --git a/packages/vite-node/package.json b/packages/vite-node/package.json new file mode 100644 index 000000000..dd14ca061 --- /dev/null +++ b/packages/vite-node/package.json @@ -0,0 +1,67 @@ +{ + "name": "vite-node", + "version": "0.0.139", + "description": "Vite as Node.js runtime", + "homepage": "https://github.com/vitest-dev/vitest#readme", + "bugs": { + "url": "https://github.com/vitest-dev/vitest/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vitest-dev/vitest.git", + "directory": "packages/vite-node" + }, + "funding": "https://github.com/sponsors/antfu", + "license": "MIT", + "author": "Anthony Fu ", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./client": { + "import": "./dist/client.js", + "types": "./dist/client.d.ts" + }, + "./server": { + "import": "./dist/server.js", + "types": "./dist/server.d.ts" + }, + "./utils": { + "import": "./dist/utils.js", + "types": "./dist/utils.d.ts" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "vite-node": "./vite-node.js" + }, + "files": [ + "dist", + "*.d.ts", + "*.mjs" + ], + "scripts": { + "build": "rimraf dist && rollup -c", + "dev": "rollup -c --watch --watch.include=src/**", + "prepublishOnly": "nr build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "kolorist": "^1.5.1", + "minimist": "^1.2.5", + "mlly": "^0.3.17", + "pathe": "^0.2.0", + "vite": "^2.7.10" + }, + "devDependencies": { + "@types/minimist": "^1.2.2", + "rollup": "^2.63.0" + }, + "engines": { + "node": ">=14.14.0" + } +} diff --git a/packages/vite-node/rollup.config.js b/packages/vite-node/rollup.config.js new file mode 100644 index 000000000..7e3f41872 --- /dev/null +++ b/packages/vite-node/rollup.config.js @@ -0,0 +1,59 @@ +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +import resolve from '@rollup/plugin-node-resolve' +import commonjs from '@rollup/plugin-commonjs' +import json from '@rollup/plugin-json' +import alias from '@rollup/plugin-alias' +import pkg from './package.json' + +const entry = [ + 'src/index.ts', + 'src/server.ts', + 'src/client.ts', + 'src/utils.ts', + 'src/cli.ts', +] + +const external = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + 'birpc', + 'vite', +] + +export default () => [ + { + input: entry, + output: { + dir: 'dist', + format: 'esm', + }, + external, + plugins: [ + alias({ + entries: [ + { find: /^node:(.+)$/, replacement: '$1' }, + ], + }), + resolve({ + preferBuiltins: true, + }), + json(), + commonjs(), + esbuild({ + target: 'node14', + }), + ], + }, + ...entry.map(input => ({ + input, + output: { + file: input.replace('src/', 'dist/').replace('.ts', '.d.ts'), + format: 'esm', + }, + external, + plugins: [ + dts({ respectExternal: true }), + ], + })), +] diff --git a/packages/vite-node/src/cli.ts b/packages/vite-node/src/cli.ts new file mode 100644 index 000000000..2484525be --- /dev/null +++ b/packages/vite-node/src/cli.ts @@ -0,0 +1,91 @@ +import minimist from 'minimist' +import { dim, red } from 'kolorist' +import { createServer } from 'vite' +import { ViteNodeServer } from './server' +import { ViteNodeRunner } from './client' + +const argv = minimist(process.argv.slice(2), { + 'alias': { + r: 'root', + c: 'config', + h: 'help', + w: 'watch', + s: 'silent', + }, + '--': true, + 'string': ['root', 'config'], + 'boolean': ['help', 'watch', 'silent'], + unknown(name: string) { + if (name[0] === '-') { + console.error(red(`Unknown argument: ${name}`)) + help() + process.exit(1) + } + return true + }, +}) + +if (argv.help) { + help() + process.exit(0) +} + +if (!argv._.length) { + console.error(red('No files specified.')) + help() + process.exit(1) +} + +// forward argv +process.argv = [...process.argv.slice(0, 2), ...(argv['--'] || [])] + +run(argv) + +function help() { + // eslint-disable-next-line no-console + console.log(` +Usage: + $ vite-node [options] [files] + +Options: + -r, --root ${dim('[string]')} use specified root directory + -c, --config ${dim('[string]')} use specified config file + -w, --watch ${dim('[boolean]')} restart on file changes, similar to "nodemon" + -s, --silent ${dim('[boolean]')} do not emit errors and logs + --vue ${dim('[boolean]')} support for importing Vue component +`) +} + +export interface CliOptions { + files?: string[] + _?: string[] + root?: string + config?: string +} + +async function run(options: CliOptions = {}) { + const files = options.files || options._ || [] + + const server = await createServer({ + logLevel: 'error', + clearScreen: false, + configFile: options.config, + root: options.root, + }) + await server.pluginContainer.buildStart({}) + + const node = new ViteNodeServer(server) + + const runner = new ViteNodeRunner({ + root: server.config.root, + base: server.config.base, + fetchModule(id) { + return node.fetchModule(id) + }, + }) + + for (const file of files) + await runner.run(file) + + await server.close() +} diff --git a/packages/vite-node/src/client.ts b/packages/vite-node/src/client.ts new file mode 100644 index 000000000..d586caf99 --- /dev/null +++ b/packages/vite-node/src/client.ts @@ -0,0 +1,163 @@ +import { builtinModules, createRequire } from 'module' +import { fileURLToPath, pathToFileURL } from 'url' +import vm from 'vm' +import { dirname, resolve } from 'pathe' +import { normalizeId, slash, toFilePath } from './utils' +import type { ModuleCache, ViteNodeRunnerOptions } from './types' + +export class ViteNodeRunner { + root: string + + externalCache: Map> + moduleCache: Map + + constructor(public options: ViteNodeRunnerOptions) { + this.root = options.root || process.cwd() + this.moduleCache = options.moduleCache || new Map() + this.externalCache = new Map>() + builtinModules.forEach(m => this.externalCache.set(m, m)) + } + + async run(file: string) { + return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, []) + } + + async cachedRequest(rawId: string, callstack: string[]) { + const id = normalizeId(rawId, this.options.base) + const fsPath = toFilePath(id, this.root) + + if (this.moduleCache.get(fsPath)?.promise) + return this.moduleCache.get(fsPath)?.promise + + const promise = this.directRequest(id, fsPath, callstack) + this.setCache(fsPath, { promise }) + + return await promise + } + + async directRequest(id: string, fsPath: string, callstack: string[]) { + callstack = [...callstack, id] + const request = async(dep: string) => { + if (callstack.includes(dep)) { + const cacheKey = toFilePath(dep, this.root) + if (!this.moduleCache.get(cacheKey)?.exports) + throw new Error(`Circular dependency detected\nStack:\n${[...callstack, dep].reverse().map(p => `- ${p}`).join('\n')}`) + return this.moduleCache.get(cacheKey)!.exports + } + return this.cachedRequest(dep, callstack) + } + + if (this.options.requestStubs && id in this.options.requestStubs) + return this.options.requestStubs[id] + + const { code: transformed, externalize } = await this.options.fetchModule(id) + if (externalize) { + const mod = await interpretedImport(externalize, this.options.interpretDefault ?? true) + this.setCache(fsPath, { exports: mod }) + return mod + } + + if (transformed == null) + throw new Error(`failed to load ${id}`) + + // disambiguate the `:/` on windows: see nodejs/node#31710 + const url = pathToFileURL(fsPath).href + const exports: any = {} + + this.setCache(fsPath, { code: transformed, exports }) + + const __filename = fileURLToPath(url) + const moduleProxy = { + set exports(value) { + exportAll(exports, value) + exports.default = value + }, + get exports() { + return exports.default + }, + } + + const context = this.prepareContext({ + // esm transformed by Vite + __vite_ssr_import__: request, + __vite_ssr_dynamic_import__: request, + __vite_ssr_exports__: exports, + __vite_ssr_exportAll__: (obj: any) => exportAll(exports, obj), + __vite_ssr_import_meta__: { url }, + + // cjs compact + require: createRequire(url), + exports, + module: moduleProxy, + __filename, + __dirname: dirname(__filename), + }) + + const fn = vm.runInThisContext(`async (${Object.keys(context).join(',')})=>{{${transformed}\n}}`, { + filename: fsPath, + lineOffset: 0, + }) + + await fn(...Object.values(context)) + + return exports + } + + prepareContext(context: Record) { + return context + } + + setCache(id: string, mod: Partial) { + if (!this.moduleCache.has(id)) + this.moduleCache.set(id, mod) + else + Object.assign(this.moduleCache.get(id), mod) + } +} + +function hasNestedDefault(target: any) { + return '__esModule' in target && target.__esModule && 'default' in target.default +} + +function proxyMethod(name: 'get' | 'set' | 'has' | 'deleteProperty', tryDefault: boolean) { + return function(target: any, key: string | symbol, ...args: [any?, any?]) { + const result = Reflect[name](target, key, ...args) + if (typeof target.default !== 'object') + return result + if ((tryDefault && key === 'default') || typeof result === 'undefined') + return Reflect[name](target.default, key, ...args) + return result + } +} + +async function interpretedImport(path: string, interpretDefault: boolean) { + const mod = await import(path) + + if (interpretDefault && 'default' in mod) { + const tryDefault = hasNestedDefault(mod) + return new Proxy(mod, { + get: proxyMethod('get', tryDefault), + set: proxyMethod('set', tryDefault), + has: proxyMethod('has', tryDefault), + deleteProperty: proxyMethod('deleteProperty', tryDefault), + }) + } + + return mod +} + +function exportAll(exports: any, sourceModule: any) { + // eslint-disable-next-line no-restricted-syntax + for (const key in sourceModule) { + if (key !== 'default') { + try { + Object.defineProperty(exports, key, { + enumerable: true, + configurable: true, + get() { return sourceModule[key] }, + }) + } + catch (_err) { } + } + } +} diff --git a/packages/vitest/src/utils/externalize.ts b/packages/vite-node/src/externalize.ts similarity index 81% rename from packages/vitest/src/utils/externalize.ts rename to packages/vite-node/src/externalize.ts index 8e1051fc8..7c1be587b 100644 --- a/packages/vitest/src/utils/externalize.ts +++ b/packages/vite-node/src/externalize.ts @@ -1,7 +1,7 @@ import { existsSync } from 'fs' import { isNodeBuiltin, isValidNodeImport } from 'mlly' -import type { ResolvedConfig } from '../types' -import { slash } from '../utils' +import type { ExternalizeOptions } from './types' +import { slash } from './utils' const ESM_EXT_RE = /\.(es|esm|esm-browser|esm-bundler|es6|module)\.js$/ const ESM_FOLDER_RE = /\/esm\/(.*\.js)$/ @@ -47,7 +47,7 @@ export function guessCJSversion(id: string): string | undefined { export async function shouldExternalize( id: string, - config: Pick, + config?: ExternalizeOptions, cache = new Map>(), ) { if (!cache.has(id)) @@ -57,16 +57,16 @@ export async function shouldExternalize( async function _shouldExternalize( id: string, - config: Pick, + config?: ExternalizeOptions, ): Promise { if (isNodeBuiltin(id)) return id id = patchWindowsImportPath(id) - if (matchExternalizePattern(id, config.depsInline)) + if (matchExternalizePattern(id, config?.inline)) return false - if (matchExternalizePattern(id, config.depsExternal)) + if (matchExternalizePattern(id, config?.external)) return id const isNodeModule = id.includes('/node_modules/') @@ -84,7 +84,9 @@ async function _shouldExternalize( return false } -function matchExternalizePattern(id: string, patterns: (string | RegExp)[]) { +function matchExternalizePattern(id: string, patterns?: (string | RegExp)[]) { + if (!patterns) + return false for (const ex of patterns) { if (typeof ex === 'string') { if (id.includes(`/node_modules/${ex}/`)) @@ -98,7 +100,7 @@ function matchExternalizePattern(id: string, patterns: (string | RegExp)[]) { return false } -export function patchWindowsImportPath(path: string) { +function patchWindowsImportPath(path: string) { if (path.match(/^\w:\\/)) return `file:///${slash(path)}` else if (path.match(/^\w:\//)) diff --git a/packages/vite-node/src/index.ts b/packages/vite-node/src/index.ts new file mode 100644 index 000000000..c9f6f047d --- /dev/null +++ b/packages/vite-node/src/index.ts @@ -0,0 +1 @@ +export * from './types' diff --git a/packages/vite-node/src/server.ts b/packages/vite-node/src/server.ts new file mode 100644 index 000000000..e30161c40 --- /dev/null +++ b/packages/vite-node/src/server.ts @@ -0,0 +1,92 @@ +import type { TransformResult, ViteDevServer } from 'vite' +import { shouldExternalize } from './externalize' +import type { ViteNodeServerOptions } from './types' +import { toFilePath } from './utils' + +export * from './externalize' + +let SOURCEMAPPING_URL = 'sourceMa' +SOURCEMAPPING_URL += 'ppingURL' + +export class ViteNodeServer { + promiseMap = new Map>() + + constructor( + public server: ViteDevServer, + public options: ViteNodeServerOptions = {}, + ) {} + + shouldExternalize(id: string) { + return shouldExternalize(id, this.options.deps) + } + + async fetchModule(id: string) { + const externalize = await this.shouldExternalize(toFilePath(id, this.server.config.root)) + if (externalize) + return { externalize } + const r = await this.transformRequest(id) + return { code: r?.code } + } + + async transformRequest(id: string) { + // reuse transform for concurrent requests + if (!this.promiseMap.has(id)) { + this.promiseMap.set(id, + this._transformRequest(id) + .then((r) => { + this.promiseMap.delete(id) + return r + }), + ) + } + return this.promiseMap.get(id) + } + + private getTransformMode(id: string) { + const withoutQuery = id.split('?')[0] + + if (this.options.transformMode?.web?.some(r => withoutQuery.match(r))) + return 'web' + if (this.options.transformMode?.ssr?.some(r => withoutQuery.match(r))) + return 'ssr' + + if (withoutQuery.match(/\.([cm]?[jt]sx?|json)$/)) + return 'ssr' + return 'web' + } + + private async _transformRequest(id: string) { + let result: TransformResult | null = null + + const mode = this.getTransformMode(id) + if (mode === 'web') { + // for components like Vue, we want to use the client side + // plugins but then covert the code to be consumed by the server + result = await this.server.transformRequest(id) + if (result) + result = await this.server.ssrTransform(result.code, result.map, id) + } + else { + result = await this.server.transformRequest(id, { ssr: true }) + } + + if (result && !id.includes('node_modules')) + withInlineSourcemap(result) + + // if (result?.map && process.env.NODE_V8_COVERAGE) + // visitedFilesMap.set(toFilePath(id, config.root), result.map as any) + + return result + } +} + +export async function withInlineSourcemap(result: TransformResult) { + const { code, map } = result + + if (code.includes(`${SOURCEMAPPING_URL}=`)) + return result + if (map) + result.code = `${code}\n\n//# ${SOURCEMAPPING_URL}=data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(map), 'utf-8').toString('base64')}\n` + + return result +} diff --git a/packages/vite-node/src/types.ts b/packages/vite-node/src/types.ts new file mode 100644 index 000000000..f9ba4b00e --- /dev/null +++ b/packages/vite-node/src/types.ts @@ -0,0 +1,30 @@ +export interface ExternalizeOptions { + external?: (string | RegExp)[] + inline?: (string | RegExp)[] + fallbackCJS?: boolean +} + +export type FetchFunction = (id: string) => Promise<{ code?: string; externalize?: string }> + +export interface ModuleCache { + promise?: Promise + exports?: any + code?: string +} + +export interface ViteNodeRunnerOptions { + fetchModule: FetchFunction + root: string + base?: string + moduleCache?: Map + interpretDefault?: boolean + requestStubs?: Record +} + +export interface ViteNodeServerOptions { + deps?: ExternalizeOptions + transformMode?: { + ssr?: RegExp[] + web?: RegExp[] + } +} diff --git a/packages/vite-node/src/utils.ts b/packages/vite-node/src/utils.ts new file mode 100644 index 000000000..7fdb98f9c --- /dev/null +++ b/packages/vite-node/src/utils.ts @@ -0,0 +1,39 @@ +import { fileURLToPath, pathToFileURL } from 'url' +import { dirname, resolve } from 'pathe' + +export const isWindows = process.platform === 'win32' + +export function slash(str: string) { + return str.replace(/\\/g, '/') +} + +export function normalizeId(id: string, base?: string): string { + if (base && id.startsWith(base)) + id = `/${id.slice(base.length)}` + + return id + .replace(/^\/@id\/__x00__/, '\0') // virtual modules start with `\0` + .replace(/^\/@id\//, '') + .replace(/^__vite-browser-external:/, '') + .replace(/^node:/, '') + .replace(/[?&]v=\w+/, '?') // remove ?v= query + .replace(/\?$/, '') // remove end query mark +} + +export function toFilePath(id: string, root: string): string { + let absolute = slash(id).startsWith('/@fs/') + ? id.slice(4) + : id.startsWith(dirname(root)) + ? id + : id.startsWith('/') + ? slash(resolve(root, id.slice(1))) + : id + + if (absolute.startsWith('//')) + absolute = absolute.slice(1) + + // disambiguate the `:/` on windows: see nodejs/node#31710 + return isWindows && absolute.startsWith('/') + ? fileURLToPath(pathToFileURL(absolute.slice(1)).href) + : absolute +} diff --git a/packages/vite-node/tsconfig.json b/packages/vite-node/tsconfig.json new file mode 100644 index 000000000..e2018da90 --- /dev/null +++ b/packages/vite-node/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["./dist"] +} diff --git a/packages/vite-node/vite-node.mjs b/packages/vite-node/vite-node.mjs new file mode 100755 index 000000000..7dbd83110 --- /dev/null +++ b/packages/vite-node/vite-node.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import('./dist/cli.js') diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 89129d4b0..23c85f50d 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -97,7 +97,8 @@ "source-map-js": "^1.0.1", "strip-ansi": "^7.0.1", "typescript": "^4.5.4", - "ws": "^8.4.0" + "ws": "^8.4.0", + "vite-node": "workspace:*" }, "peerDependencies": { "@vitest/ui": "*", diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 2266a1adb..ac59d19c2 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -9,7 +9,6 @@ import { API_PATH } from '../constants' import type { Vitest } from '../node' import type { File, ModuleGraphData, Reporter, TaskResultPack } from '../types' import { interpretSourcePos, parseStacktrace } from '../utils/source-map' -import { transformRequest } from '../node/transform' import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers } from './types' export function setup(ctx: Vitest) { @@ -52,7 +51,7 @@ export function setup(ctx: Vitest) { return ctx.config }, async getTransformResult(id) { - const result: TransformResultWithSource | null | undefined = await transformRequest(ctx, id) + const result: TransformResultWithSource | null | undefined = await ctx.vitenode.transformRequest(id) if (result) { try { result.source = result.source || (await fs.readFile(id, 'utf-8')) @@ -76,7 +75,7 @@ export function setup(ctx: Vitest) { return seen.get(mod) let id = clearId(mod.id) seen.set(mod, id) - const rewrote = await ctx.shouldExternalize(id) + const rewrote = await ctx.vitenode.shouldExternalize(id) if (rewrote) { id = rewrote externalized.add(id) diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 93ac26aff..796e974ac 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -63,10 +63,7 @@ export function resolveConfig( resolved.coverage = resolveC8Options(resolved.coverage, resolved.root) - resolved.depsInline = [...resolved.deps?.inline || []] - resolved.depsExternal = [...resolved.deps?.external || []] - resolved.fallbackCJS = resolved.deps?.fallbackCJS ?? true - resolved.interpretDefault = resolved.deps?.interpretDefault ?? true + resolved.deps = resolved.deps || {} resolved.environment = resolved.environment || 'node' resolved.threads = resolved.threads ?? true diff --git a/packages/vitest/src/node/execute.ts b/packages/vitest/src/node/execute.ts index 3e69f3bed..2aebd6780 100644 --- a/packages/vitest/src/node/execute.ts +++ b/packages/vitest/src/node/execute.ts @@ -1,76 +1,14 @@ -import { builtinModules, createRequire } from 'module' -import { fileURLToPath, pathToFileURL } from 'url' -import vm from 'vm' -import { dirname, resolve } from 'pathe' -import type { FetchFunction, ModuleCache } from '../types' -import { normalizeId, slash, toFilePath } from '../utils' +import { ViteNodeRunner } from 'vite-node/client' +import { toFilePath } from 'vite-node/utils' +import type { ViteNodeRunnerOptions } from 'vite-node' import type { SuiteMocks } from './mocker' import { createMocker } from './mocker' -export interface ViteNodeOptions { - root: string - base?: string - fetch: FetchFunction - moduleCache: Map - depsInline: (string | RegExp)[] - depsExternal: (string | RegExp)[] - fallbackCJS: boolean - interpretDefault: boolean - requestStubs?: Record -} - -export interface ExecuteOptions extends ViteNodeOptions { +export interface ExecuteOptions extends ViteNodeRunnerOptions { files: string[] mockMap: SuiteMocks } -function hasNestedDefault(target: any) { - return '__esModule' in target && target.__esModule && 'default' in target.default -} - -function proxyMethod(name: 'get' | 'set' | 'has' | 'deleteProperty', tryDefault: boolean) { - return function(target: any, key: string | symbol, ...args: [any?, any?]) { - const result = Reflect[name](target, key, ...args) - if (typeof target.default !== 'object') - return result - if ((tryDefault && key === 'default') || typeof result === 'undefined') - return Reflect[name](target.default, key, ...args) - return result - } -} - -export async function interpretedImport(path: string, interpretDefault: boolean) { - const mod = await import(path) - - if (interpretDefault && 'default' in mod) { - const tryDefault = hasNestedDefault(mod) - return new Proxy(mod, { - get: proxyMethod('get', tryDefault), - set: proxyMethod('set', tryDefault), - has: proxyMethod('has', tryDefault), - deleteProperty: proxyMethod('deleteProperty', tryDefault), - }) - } - - return mod -} - -function exportAll(exports: any, sourceModule: any) { - // eslint-disable-next-line no-restricted-syntax - for (const key in sourceModule) { - if (key !== 'default') { - try { - Object.defineProperty(exports, key, { - enumerable: true, - configurable: true, - get() { return sourceModule[key] }, - }) - } - catch (_err) { } - } - } -} - export async function executeInViteNode(options: ExecuteOptions) { const runner = new VitestRunner(options) @@ -81,116 +19,6 @@ export async function executeInViteNode(options: ExecuteOptions) { return result } -export class ViteNodeRunner { - root: string - - externalCache: Map> - moduleCache: Map - - constructor(public options: ViteNodeOptions) { - this.root = options.root || process.cwd() - - this.moduleCache = options.moduleCache || new Map() - this.externalCache = new Map>() - builtinModules.forEach(m => this.externalCache.set(m, m)) - } - - async run(file: string) { - return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, []) - } - - async cachedRequest(rawId: string, callstack: string[]) { - const id = normalizeId(rawId, this.options.base) - const fsPath = toFilePath(id, this.root) - - if (this.moduleCache.get(fsPath)?.promise) - return this.moduleCache.get(fsPath)?.promise - - const promise = this.directRequest(id, fsPath, callstack) - this.setCache(fsPath, { promise }) - - return await promise - } - - async directRequest(id: string, fsPath: string, callstack: string[]) { - callstack = [...callstack, id] - const request = async(dep: string) => { - if (callstack.includes(dep)) { - const cacheKey = toFilePath(dep, this.root) - if (!this.moduleCache.get(cacheKey)?.exports) - throw new Error(`Circular dependency detected\nStack:\n${[...callstack, dep].reverse().map(p => `- ${p}`).join('\n')}`) - return this.moduleCache.get(cacheKey)!.exports - } - return this.cachedRequest(dep, callstack) - } - - if (this.options.requestStubs && id in this.options.requestStubs) - return this.options.requestStubs[id] - - const { code: transformed, externalize } = await this.options.fetch(id) - if (externalize) { - const mod = await interpretedImport(externalize, this.options.interpretDefault) - this.setCache(fsPath, { exports: mod }) - return mod - } - - if (transformed == null) - throw new Error(`failed to load ${id}`) - - // disambiguate the `:/` on windows: see nodejs/node#31710 - const url = pathToFileURL(fsPath).href - const exports: any = {} - - this.setCache(fsPath, { code: transformed, exports }) - - const __filename = fileURLToPath(url) - const moduleProxy = { - set exports(value) { - exportAll(exports, value) - exports.default = value - }, - get exports() { - return exports.default - }, - } - - const context = this.prepareContext({ - // esm transformed by Vite - __vite_ssr_import__: request, - __vite_ssr_dynamic_import__: request, - __vite_ssr_exports__: exports, - __vite_ssr_exportAll__: (obj: any) => exportAll(exports, obj), - __vite_ssr_import_meta__: { url }, - - // cjs compact - require: createRequire(url), - exports, - module: moduleProxy, - __filename, - __dirname: dirname(__filename), - }) - - const fn = vm.runInThisContext(`async (${Object.keys(context).join(',')})=>{{${transformed}\n}}`, { - filename: fsPath, - lineOffset: 0, - }) - await fn(...Object.values(context)) - - return exports - } - - prepareContext(context: Record) { - return context - } - - setCache(id: string, mod: Partial) { - if (!this.moduleCache.has(id)) - this.moduleCache.set(id, mod) - else - Object.assign(this.moduleCache.get(id), mod) - } -} - export class VitestRunner extends ViteNodeRunner { mocker: ReturnType diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 339f12ded..7bf5f88cd 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -7,6 +7,7 @@ import fg from 'fast-glob' import mm from 'micromatch' import c from 'picocolors' import type { RawSourceMap } from 'source-map-js' +import { ViteNodeServer } from 'vite-node/server' import type { ArgumentsType, Reporter, ResolvedConfig, UserConfig } from '../types' import { SnapshotManager } from '../integrations/snapshot/manager' import { configFiles } from '../constants' @@ -14,14 +15,11 @@ import { deepMerge, ensurePackageInstalled, hasFailed, noop, notNullish, slash, import { GlobalSetupPlugin } from '../plugins/globalSetup' import { MocksPlugin } from '../plugins/mock' import { DefaultReporter, ReportersMap } from '../reporters' - import { cleanCoverage, reportCoverage } from '../coverage' -import { shouldExternalize } from '../utils/externalize' import type { WorkerPool } from './pool' import { StateManager } from './state' import { resolveApiConfig, resolveConfig } from './config' import { createPool } from './pool' -import { transformRequest } from './transform' const WATCHER_DEBOUNCE = 100 @@ -37,15 +35,15 @@ class Vitest { outputStream = process.stdout errorStream = process.stderr + vitenode: ViteNodeServer = undefined! + invalidates: Set = new Set() changedTests: Set = new Set() visitedFilesMap: Map = new Map() runningPromise?: Promise closingPromise?: Promise - externalizeCache = new Map>() isFirstRun = true - restartsCount = 0 private _onRestartListeners: Array<() => void> = [] @@ -60,7 +58,6 @@ class Vitest { this.restartsCount += 1 this.pool?.close() this.pool = undefined - this.externalizeCache.clear() const resolved = resolveConfig(options, server.config) @@ -86,6 +83,8 @@ class Vitest { if (this.config.watch) this.registerWatcher() + this.vitenode = new ViteNodeServer(server, this.config) + this.runningPromise = undefined this._onRestartListeners.forEach(fn => fn()) @@ -122,7 +121,7 @@ class Vitest { const deps = new Set() const addImports = async(filepath: string) => { - const transformed = await transformRequest(this, filepath) + const transformed = await this.vitenode.transformRequest(filepath) if (!transformed) return const dependencies = [...transformed.deps || [], ...transformed.dynamicDeps || []] for (const dep of dependencies) { @@ -349,10 +348,6 @@ class Vitest { return mm.isMatch(id, this.config.include) } - shouldExternalize(id: string) { - return shouldExternalize(id, this.config, this.externalizeCache) - } - onServerRestarted(fn: () => void) { this._onRestartListeners.push(fn) } diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index ee699f2b0..8af606548 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -7,8 +7,6 @@ import type { RawSourceMap } from 'source-map-js' import { createBirpc } from 'birpc' import { distDir } from '../constants' import type { WorkerContext, WorkerRPC } from '../types' -import { toFilePath } from '../utils' -import { transformRequest } from './transform' import type { Vitest } from './index' export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise @@ -120,15 +118,11 @@ function createChannel(ctx: Vitest) { if (mod) ctx.server.moduleGraph.invalidateModule(mod) } - const r = await transformRequest(ctx, id) + const r = await ctx.vitenode.transformRequest(id) return r?.map as RawSourceMap | undefined }, - async fetch(id) { - const externalize = await ctx.shouldExternalize(toFilePath(id, ctx.config.root)) - if (externalize) - return { externalize } - const r = await transformRequest(ctx, id) - return { code: r?.code } + fetch(id) { + return ctx.vitenode.fetchModule(id) }, onCollected(files) { ctx.state.collectFiles(files) diff --git a/packages/vitest/src/node/transform.ts b/packages/vitest/src/node/transform.ts deleted file mode 100644 index 281591832..000000000 --- a/packages/vitest/src/node/transform.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { TransformResult } from 'vite' -import { toFilePath } from '../utils' -import type { Vitest } from './index' - -const promiseMap = new Map>() - -export async function transformRequest(ctx: Vitest, id: string) { - // reuse transform for concurrent requests - if (!promiseMap.has(id)) { - promiseMap.set(id, - _transformRequest(ctx, id) - .then((r) => { - promiseMap.delete(id) - return r - }), - ) - } - return promiseMap.get(id) -} - -function getTransformMode(ctx: Vitest, id: string) { - const withoutQuery = id.split('?')[0] - - if (ctx.config.transformMode?.web?.some(r => withoutQuery.match(r))) - return 'web' - if (ctx.config.transformMode?.ssr?.some(r => withoutQuery.match(r))) - return 'ssr' - - if (withoutQuery.match(/\.([cm]?[jt]sx?|json)$/)) - return 'ssr' - return 'web' -} - -async function _transformRequest(ctx: Vitest, id: string) { - let result: TransformResult | null = null - - const mode = getTransformMode(ctx, id) - if (mode === 'web') { - // for components like Vue, we want to use the client side - // plugins but then covert the code to be consumed by the server - result = await ctx.server.transformRequest(id) - if (result) - result = await ctx.server.ssrTransform(result.code, result.map, id) - } - else { - result = await ctx.server.transformRequest(id, { ssr: true }) - } - - if (result && !id.includes('node_modules')) - withInlineSourcemap(result) - - if (result?.map && process.env.NODE_V8_COVERAGE) - ctx.visitedFilesMap.set(toFilePath(id, ctx.config.root), result.map as any) - - return result -} - -let SOURCEMAPPING_URL = 'sourceMa' -SOURCEMAPPING_URL += 'ppingURL' - -export async function withInlineSourcemap(result: TransformResult) { - const { code, map } = result - - if (code.includes(`${SOURCEMAPPING_URL}=`)) - return result - if (map) - result.code = `${code}\n\n//# ${SOURCEMAPPING_URL}=data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(map), 'utf-8').toString('base64')}\n` - - return result -} diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 99ccef700..4631fd7e2 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -34,16 +34,13 @@ async function startViteNode(ctx: WorkerContext) { files: [ resolve(distDir, 'entry.js'), ], - fetch(id) { + fetchModule(id) { return rpc().fetch(id) }, moduleCache, mockMap, + interpretDefault: config.deps.interpretDefault ?? true, root: config.root, - depsInline: config.depsInline, - depsExternal: config.depsExternal, - fallbackCJS: config.fallbackCJS, - interpretDefault: config.interpretDefault, base: config.base, }))[0] diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index 2b89c601a..97f647f1e 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -296,11 +296,6 @@ export interface ResolvedConfig extends Omit, 'config' | 'f testNamePattern?: RegExp related?: string[] - depsInline: (string | RegExp)[] - depsExternal: (string | RegExp)[] - fallbackCJS: boolean - interpretDefault: boolean - coverage: ResolvedC8Options snapshotOptions: SnapshotStateOptions diff --git a/packages/vitest/src/utils/index.ts b/packages/vitest/src/utils/index.ts index 488f86e82..c4af627ba 100644 --- a/packages/vitest/src/utils/index.ts +++ b/packages/vitest/src/utils/index.ts @@ -1,9 +1,8 @@ -import { fileURLToPath, pathToFileURL } from 'url' import c from 'picocolors' import { isPackageExists } from 'local-pkg' -import { dirname, resolve } from 'pathe' +import { resolve } from 'pathe' import type { Suite, Task } from '../types' -import { getNames, slash } from './tasks' +import { getNames } from './tasks' export * from './tasks' export * from './path' @@ -111,22 +110,4 @@ export function deepMerge(target: any, source: any): any { return target } -export function toFilePath(id: string, root: string): string { - let absolute = slash(id).startsWith('/@fs/') - ? id.slice(4) - : id.startsWith(dirname(root)) - ? id - : id.startsWith('/') - ? slash(resolve(root, id.slice(1))) - : id - - if (absolute.startsWith('//')) - absolute = absolute.slice(1) - - // disambiguate the `:/` on windows: see nodejs/node#31710 - return isWindows && absolute.startsWith('/') - ? fileURLToPath(pathToFileURL(absolute.slice(1)).href) - : absolute -} - export { resolve as resolvePath } diff --git a/packages/vitest/tsconfig.json b/packages/vitest/tsconfig.json index a7fd86107..e2018da90 100644 --- a/packages/vitest/tsconfig.json +++ b/packages/vitest/tsconfig.json @@ -1,5 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["./src/**/*.ts"], "exclude": ["./dist"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad00230ba..fc5a7f728 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,25 @@ importers: vue: 3.2.26 vue-router: 4.0.12_vue@3.2.26 + packages/vite-node: + specifiers: + '@types/minimist': ^1.2.2 + kolorist: ^1.5.1 + minimist: ^1.2.5 + mlly: ^0.3.17 + pathe: ^0.2.0 + rollup: ^2.63.0 + vite: ^2.7.10 + dependencies: + kolorist: 1.5.1 + minimist: 1.2.5 + mlly: 0.3.17 + pathe: 0.2.0 + vite: 2.7.10 + devDependencies: + '@types/minimist': 1.2.2 + rollup: 2.63.0 + packages/vitest: specifiers: '@antfu/install-pkg': ^0.1.0 @@ -434,6 +453,7 @@ importers: tinyspy: ^0.2.8 typescript: ^4.5.4 vite: '>=2.7.10' + vite-node: workspace:* ws: ^8.4.0 dependencies: '@types/chai': 4.3.0 @@ -480,6 +500,7 @@ importers: source-map-js: 1.0.1 strip-ansi: 7.0.1 typescript: 4.5.4 + vite-node: link:../vite-node ws: 8.4.0 packages/ws-client: @@ -2064,6 +2085,10 @@ packages: '@types/braces': 3.0.1 dev: true + /@types/minimist/1.2.2: + resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} + dev: true + /@types/natural-compare/1.4.1: resolution: {integrity: sha512-9dr4UakpvN0QUvwNefk9+o14Sr1pPPIDWkgCxPkHcg3kyjtc9eKK1ng6dZ23vRwByloCqXYtZ1T5nJxkk3Ib3A==} dev: true @@ -5759,6 +5784,10 @@ packages: engines: {node: '>=6'} dev: true + /kolorist/1.5.1: + resolution: {integrity: sha512-lxpCM3HTvquGxKGzHeknB/sUjuVoUElLlfYnXZT73K8geR9jQbroGlSCFBax9/0mpGoD3kzcMLnOlGQPJJNyqQ==} + dev: false + /levn/0.3.0: resolution: {integrity: sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=} engines: {node: '>= 0.8.0'} @@ -5984,7 +6013,6 @@ packages: /minimist/1.2.5: resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==} - dev: true /mkdirp-classic/0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -6004,7 +6032,6 @@ packages: /mlly/0.3.17: resolution: {integrity: sha512-C3v8eHB9KqmS1ewOB5DUgljX13C3xuoaXZd5bOLtwpxk9pBZhA+wyVgYXPuP4aukQ9bKYWjy+YQVC+DmniIsgA==} - dev: true /mockdate/3.0.5: resolution: {integrity: sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==} @@ -6493,7 +6520,6 @@ packages: /pathe/0.2.0: resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==} - dev: true /pathval/1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} diff --git a/tsconfig.json b/tsconfig.json index ce69a8a42..abb43bbec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,11 @@ "~/*": ["./packages/ui/client/*"], "vitest": ["./packages/vitest/src/index.ts"], "vitest/global": ["./packages/vitest/global.d.ts"], - "vitest/node": ["./packages/vitest/src/node/index.ts"] + "vitest/node": ["./packages/vitest/src/node/index.ts"], + "vite-node": ["./packages/vite-node/src/index.ts"], + "vite-node/client": ["./packages/vite-node/src/client.ts"], + "vite-node/server": ["./packages/vite-node/src/server.ts"], + "vite-node/utils": ["./packages/vite-node/src/utils.ts"] } }, "exclude": [ -- 2.51.2