diff --git a/packages/vite-node/src/client.ts b/packages/vite-node/src/client.ts index 2425c34e9791..b9cff3267fe9 100644 --- a/packages/vite-node/src/client.ts +++ b/packages/vite-node/src/client.ts @@ -4,7 +4,7 @@ import vm from 'vm' import { dirname, extname, isAbsolute, resolve } from 'pathe' import { isNodeBuiltin } from 'mlly' import createDebug from 'debug' -import { isPrimitive, mergeSlashes, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils' +import { getType, isPrimitive, mergeSlashes, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils' import type { HotContext, ModuleCache, ViteNodeRunnerOptions } from './types' const debugExecute = createDebug('vite-node:client:execute') @@ -241,17 +241,43 @@ export class ViteNodeRunner { enumerable: false, configurable: false, }) + // this prosxy is triggered only on exports.name and module.exports access + const cjsExports = new Proxy(exports, { + get(_, p, receiver) { + return Reflect.get(exports, p, receiver) + }, + set(_, p, value) { + // Node also allows access of named exports via exports.default + // https://nodejs.org/api/esm.html#commonjs-namespaces + if (p !== 'default') { + if (!Reflect.has(exports, 'default')) + exports.default = {} + + // returns undefined, when accessing named exports, if default is not an object + // but is still present inside hasOwnKeys, this is Node behaviour for CJS + if (exports.default === null || typeof exports.default !== 'object') { + defineExport(exports, p, () => undefined) + return true + } + + exports.default[p] = value + defineExport(exports, p, () => value) + return true + } + return Reflect.set(exports, p, value) + }, + }) Object.assign(mod, { code: transformed, exports }) const __filename = fileURLToPath(url) const moduleProxy = { set exports(value) { - exportAll(exports, value) - exports.default = value + exportAll(cjsExports, value) + cjsExports.default = value }, get exports() { - return exports + return cjsExports }, } @@ -282,7 +308,7 @@ export class ViteNodeRunner { // cjs compact require: createRequire(url), - exports, + exports: cjsExports, module: moduleProxy, __filename, __dirname: dirname(__filename), @@ -363,20 +389,35 @@ function proxyMethod(name: 'get' | 'set' | 'has' | 'deleteProperty', tryDefault: } } +// keep consistency with Vite on how exports are defined +function defineExport(exports: any, key: string | symbol, value: () => any) { + Object.defineProperty(exports, key, { + enumerable: true, + configurable: true, + get: value, + }) +} + function exportAll(exports: any, sourceModule: any) { // #1120 when a module exports itself it causes // call stack error if (exports === sourceModule) return + const type = getType(sourceModule) + if (type !== 'Object' && type !== 'Module') + return + + const constructor = sourceModule.constructor?.name + + // allow only plain objects and modules (modules don't have name) + if (constructor && constructor !== 'Object') + return + for (const key in sourceModule) { if (key !== 'default') { try { - Object.defineProperty(exports, key, { - enumerable: true, - configurable: true, - get() { return sourceModule[key] }, - }) + defineExport(exports, key, () => sourceModule[key]) } catch (_err) { } } diff --git a/packages/vite-node/src/utils.ts b/packages/vite-node/src/utils.ts index b5c2d933d59e..84eb5604d09e 100644 --- a/packages/vite-node/src/utils.ts +++ b/packages/vite-node/src/utils.ts @@ -9,6 +9,10 @@ export function slash(str: string) { return str.replace(/\\/g, '/') } +export function getType(value: unknown): string { + return Object.prototype.toString.apply(value).slice(8, -1) +} + export function mergeSlashes(str: string) { return str.replace(/\/\//g, '/') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d6359754131..69050cbb1709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -912,8 +912,10 @@ importers: test/core: specifiers: + tinyspy: ^1.0.2 vitest: workspace:* devDependencies: + tinyspy: 1.0.2 vitest: link:../../packages/vitest test/coverage-test: @@ -17415,7 +17417,6 @@ packages: /tinyspy/1.0.2: resolution: {integrity: sha512-bSGlgwLBYf7PnUsQ6WOc6SJ3pGOcd+d8AA6EUnLDDM0kWEstC1JIlSZA3UNliDXhd9ABoS7hiRBDCu+XP/sf1Q==} engines: {node: '>=14.0.0'} - dev: false /tmp/0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} diff --git a/test/core/package.json b/test/core/package.json index 04d6e0b02701..a31e9517a9bc 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -6,6 +6,7 @@ "coverage": "vitest run --coverage" }, "devDependencies": { + "tinyspy": "^1.0.2", "vitest": "workspace:*" } } diff --git a/test/core/src/cjs/array-cjs.js b/test/core/src/cjs/array-cjs.js new file mode 100644 index 000000000000..f33365f6143f --- /dev/null +++ b/test/core/src/cjs/array-cjs.js @@ -0,0 +1 @@ +module.exports = [1, '2'] diff --git a/test/core/src/cjs/bare-cjs.js b/test/core/src/cjs/bare-cjs.js new file mode 100644 index 000000000000..3f42b8876380 --- /dev/null +++ b/test/core/src/cjs/bare-cjs.js @@ -0,0 +1,3 @@ +module.exports = { c: 'c' } +exports.a = 'a' +exports.b = 'b' diff --git a/test/core/src/cjs/class-cjs.js b/test/core/src/cjs/class-cjs.js new file mode 100644 index 000000000000..6b7177f1f907 --- /dev/null +++ b/test/core/src/cjs/class-cjs.js @@ -0,0 +1,6 @@ +class Test { + variable = 1 +} + +module.exports = new Test() +module.exports.Test = Test diff --git a/test/core/src/module-cjs.ts b/test/core/src/cjs/module-cjs.ts similarity index 100% rename from test/core/src/module-cjs.ts rename to test/core/src/cjs/module-cjs.ts diff --git a/test/core/src/cjs/primitive-cjs.js b/test/core/src/cjs/primitive-cjs.js new file mode 100644 index 000000000000..a4e52bd6a831 --- /dev/null +++ b/test/core/src/cjs/primitive-cjs.js @@ -0,0 +1,2 @@ +module.exports = 'string' +exports.a = 'a' diff --git a/test/core/src/esm/internal-esm.mjs b/test/core/src/esm/internal-esm.mjs new file mode 100644 index 000000000000..bd693c45a4e2 --- /dev/null +++ b/test/core/src/esm/internal-esm.mjs @@ -0,0 +1 @@ +export * from 'tinyspy' diff --git a/test/core/test/module.test.ts b/test/core/test/module.test.ts index e34875140fa6..73425312da6a 100644 --- a/test/core/test/module.test.ts +++ b/test/core/test/module.test.ts @@ -1,7 +1,18 @@ import { expect, it } from 'vitest' -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -import { a, b } from '../src/module-cjs' +// @ts-expect-error is not typed +import cjs, { a, b } from '../src/cjs/module-cjs' +// @ts-expect-error is not typed with imports +import bareCjs, { a as bareA, b as bareB } from '../src/cjs/bare-cjs' +// @ts-expect-error is not typed with imports +import primitiveCjs, { a as primitiveA } from '../src/cjs/primitive-cjs' +// @ts-expect-error is not typed with imports +import * as primitiveAll from '../src/cjs/primitive-cjs' +// @ts-expect-error is not typed with imports +import * as arrayCjs from '../src/cjs/array-cjs' +// @ts-expect-error is not typed with imports +import * as classCjs from '../src/cjs/class-cjs' +// @ts-expect-error is not typed with imports +import * as internalEsm from '../src/esm/internal-esm.mjs' import c, { d } from '../src/module-esm' import * as timeout from '../src/timeout' @@ -9,12 +20,44 @@ it('doesn\'t when extending module', () => { expect(() => Object.assign(globalThis, timeout)).not.toThrow() }) -it('should work when using cjs module', () => { +it('should work when using module.exports cjs', () => { + expect(cjs.a).toBe(1) + expect(cjs.b).toBe(2) expect(a).toBe(1) expect(b).toBe(2) }) +it('works with bare exports cjs', () => { + expect(bareCjs.a).toBe('a') + expect(bareCjs.b).toBe('b') + expect(bareCjs.c).toBe('c') + expect(bareA).toBe('a') + expect(bareB).toBe('b') +}) + +it('primitive cjs retains its logic', () => { + expect(primitiveA).toBeUndefined() + expect(primitiveCjs).toBe('string') + expect(primitiveAll.default).toBe('string') + expect(primitiveAll, 'doesn\'t put chars from "string" on exports').not.toHaveProperty('0') +}) + +it('arrays-cjs', () => { + expect(arrayCjs.default).toEqual([1, '2']) + expect(arrayCjs).not.toHaveProperty('0') +}) + +it('class-cjs', () => { + expect(classCjs.default).toEqual({ variable: 1, Test: expect.any(Function) }) + expect(classCjs.default).toBeInstanceOf(classCjs.Test) + expect(classCjs).not.toHaveProperty('variable') +}) + it('should work when using esm module', () => { expect(c).toBe(1) expect(d).toBe(2) }) + +it('exports all from native ESM module', () => { + expect(internalEsm).toHaveProperty('restoreAll') +}) diff --git a/test/core/vitest.config.ts b/test/core/vitest.config.ts index a0acc89392e5..97d043a26138 100644 --- a/test/core/vitest.config.ts +++ b/test/core/vitest.config.ts @@ -57,6 +57,9 @@ export default defineConfig({ sequence: { seed: 101, }, + deps: { + external: ['tinyspy'], + }, alias: [ { find: 'test-alias',