From 5786837a02566accaafe04a4a6cda9ea7d00ee2e Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Thu, 19 Oct 2023 17:59:11 +0800 Subject: [PATCH] feat!: remove ssr proxy for externalized modules (#14521) --- docs/guide/migration.md | 26 +++++ packages/vite/src/node/config.ts | 44 +++----- packages/vite/src/node/plugins/resolve.ts | 10 +- .../node/ssr/__tests__/ssrTransform.spec.ts | 83 +++++++------- packages/vite/src/node/ssr/ssrModuleLoader.ts | 102 ++++++++++++++++-- packages/vite/src/node/ssr/ssrTransform.ts | 37 ++++++- packages/vite/src/node/utils.ts | 25 ++++- playground/ssr-deps/src/app.js | 9 +- 8 files changed, 240 insertions(+), 96 deletions(-) diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 4ac7ba1f10bc2a..65d8b4e78c6101 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -34,6 +34,32 @@ See the [troubleshooting guide](/guide/troubleshooting.html#vite-cjs-node-api-de ## General Changes +### SSR externalized modules value now matches production + +In Vite 4, SSR externalized modules are wrapped with `.default` and `.__esModule` handling for better interoperability, but it doesn't match the production behaviour when loaded by the runtime environment (e.g. Node.js), causing hard-to-catch inconsistencies. By default, all direct project dependencies are SSR externalized. + +Vite 5 now removes the `.default` and `.__esModule` handling to match the production behaviour. In practice, this shouldn't affect properly-packaged dependencies, but if you encounter new issues loading modules, you can try these refactors: + +```js +// Before: +import { foo } from 'bar' + +// After: +import _bar from 'bar' +const { foo } = _bar +``` + +```js +// Before: +import foo from 'bar' + +// After: +import * as _foo from 'bar' +const foo = _foo.default +``` + +Note that these changes matches the Node.js behaviour, so you can also run the imports in Node.js to test it out. If you prefer to stick with the previous behaviour, you can set `legacy.proxySsrExternalModules` to `true`. + ### `worker.plugins` is now a function In Vite 4, `worker.plugins` accepted an array of plugins (`(Plugin | Plugin[])[]`). From Vite 5, it needs to be configured as a function that returns an array of plugins (`() => (Plugin | Plugin[])[]`). This change is required so parallel worker builds run more consistently and predictably. diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 56d3bb8a64d43d..7c2c5c5dff9732 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -32,9 +32,9 @@ import { createFilter, isBuiltin, isExternalUrl, + isFilePathESM, isNodeBuiltin, isObject, - lookupFile, mergeAlias, mergeConfig, normalizeAlias, @@ -314,8 +314,16 @@ export interface ExperimentalOptions { export interface LegacyOptions { /** - * No longer needed for now, but kept for backwards compatibility. + * In Vite 4, SSR-externalized modules (modules not bundled and loaded by Node.js at runtime) + * are implicitly proxied in dev to automatically handle `default` and `__esModule` access. + * However, this does not correctly reflect how it works in the Node.js runtime, causing + * inconsistencies between dev and prod. + * + * In Vite 5, the proxy is removed so dev and prod are consistent, but if you still require + * the old behaviour, you can enable this option. If so, please leave your feedback at + * https://github.com/vitejs/vite/discussions/14697. */ + proxySsrExternalModules?: boolean } export interface ResolvedWorkerOptions { @@ -978,19 +986,7 @@ export async function loadConfigFromFile( return null } - let isESM = false - if (/\.m[jt]s$/.test(resolvedPath)) { - isESM = true - } else if (/\.c[jt]s$/.test(resolvedPath)) { - isESM = false - } else { - // check package.json for type: "module" and set `isESM` to true - try { - const pkg = lookupFile(configRoot, ['package.json']) - isESM = - !!pkg && JSON.parse(fs.readFileSync(pkg, 'utf-8')).type === 'module' - } catch (e) {} - } + const isESM = isFilePathESM(resolvedPath) try { const bundled = await bundleConfigFile(resolvedPath, isESM) @@ -1076,18 +1072,6 @@ async function bundleConfigFile( false, )?.id } - const isESMFile = (id: string): boolean => { - if (id.endsWith('.mjs')) return true - if (id.endsWith('.cjs')) return false - - const nearestPackageJson = findNearestPackageData( - path.dirname(id), - packageCache, - ) - return ( - !!nearestPackageJson && nearestPackageJson.data.type === 'module' - ) - } // externalize bare imports build.onResolve( @@ -1135,7 +1119,11 @@ async function bundleConfigFile( if (idFsPath && isImport) { idFsPath = pathToFileURL(idFsPath).href } - if (idFsPath && !isImport && isESMFile(idFsPath)) { + if ( + idFsPath && + !isImport && + isFilePathESM(idFsPath, packageCache) + ) { throw new Error( `${JSON.stringify( id, diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 2c89918968f981..3abb5d87be2a23 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -26,6 +26,7 @@ import { isBuiltin, isDataUrl, isExternalUrl, + isFilePathESM, isInNodeModules, isNonDriveRelativeAbsolutePath, isObject, @@ -822,8 +823,6 @@ export function tryNodeResolve( }) } - const ext = path.extname(resolved) - if ( !options.ssrOptimizeCheck && (!isInNodeModules(resolved) || // linked @@ -859,12 +858,7 @@ export function tryNodeResolve( (!options.ssrOptimizeCheck && !isBuild && ssr) || // Only optimize non-external CJS deps during SSR by default (ssr && - !( - ext === '.cjs' || - (ext === '.js' && - findNearestPackageData(path.dirname(resolved), options.packageCache) - ?.data.type !== 'module') - ) && + isFilePathESM(resolved, options.packageCache) && !(include?.includes(pkgId) || include?.includes(id))) if (options.ssrOptimizeCheck) { diff --git a/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts b/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts index 1afcb3ed5599dd..042923fb10398d 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts +++ b/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts @@ -22,7 +22,7 @@ test('named import', async () => { `import { ref } from 'vue';function foo() { return ref(0) }`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"ref\\"]}); function foo() { return __vite_ssr_import_0__.ref(0) }" `) }) @@ -77,7 +77,7 @@ test('export named from', async () => { expect( await ssrTransformSimpleCode(`export { ref, computed as c } from 'vue'`), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"ref\\",\\"computed\\"]}); Object.defineProperty(__vite_ssr_exports__, \\"ref\\", { enumerable: true, configurable: true, get(){ return __vite_ssr_import_0__.ref }}); Object.defineProperty(__vite_ssr_exports__, \\"c\\", { enumerable: true, configurable: true, get(){ return __vite_ssr_import_0__.computed }});" @@ -90,7 +90,7 @@ test('named exports of imported binding', async () => { `import {createApp} from 'vue';export {createApp}`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"createApp\\"]}); Object.defineProperty(__vite_ssr_exports__, \\"createApp\\", { enumerable: true, configurable: true, get(){ return __vite_ssr_import_0__.createApp }});" `) @@ -102,9 +102,9 @@ test('export * from', async () => { `export * from 'vue'\n` + `export * from 'react'`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"isExportAll\\":true}); __vite_ssr_exportAll__(__vite_ssr_import_0__); - const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"react\\"); + const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"react\\", {\\"isExportAll\\":true}); __vite_ssr_exportAll__(__vite_ssr_import_1__); " @@ -114,10 +114,10 @@ test('export * from', async () => { test('export * as from', async () => { expect(await ssrTransformSimpleCode(`export * as foo from 'vue'`)) .toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"isExportAll\\":true}); - Object.defineProperty(__vite_ssr_exports__, \\"foo\\", { enumerable: true, configurable: true, get(){ return __vite_ssr_import_0__ }});" - `) + Object.defineProperty(__vite_ssr_exports__, \\"foo\\", { enumerable: true, configurable: true, get(){ return __vite_ssr_import_0__ }});" + `) }) test('export default', async () => { @@ -132,8 +132,8 @@ test('export then import minified', async () => { `export * from 'vue';import {createApp} from 'vue';`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); - const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"createApp\\"]}); + const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"vue\\", {\\"isExportAll\\":true}); __vite_ssr_exportAll__(__vite_ssr_import_1__); " `) @@ -173,7 +173,7 @@ test('do not rewrite method definition', async () => { `import { fn } from 'vue';class A { fn() { fn() } }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); class A { fn() { __vite_ssr_import_0__.fn() } }" `) expect(result?.deps).toEqual(['vue']) @@ -184,7 +184,7 @@ test('do not rewrite when variable is in scope', async () => { `import { fn } from 'vue';function A(){ const fn = () => {}; return { fn }; }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); function A(){ const fn = () => {}; return { fn }; }" `) expect(result?.deps).toEqual(['vue']) @@ -196,7 +196,7 @@ test('do not rewrite when variable is in scope with object destructuring', async `import { fn } from 'vue';function A(){ let {fn, test} = {fn: 'foo', test: 'bar'}; return { fn }; }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); function A(){ let {fn, test} = {fn: 'foo', test: 'bar'}; return { fn }; }" `) expect(result?.deps).toEqual(['vue']) @@ -208,7 +208,7 @@ test('do not rewrite when variable is in scope with array destructuring', async `import { fn } from 'vue';function A(){ let [fn, test] = ['foo', 'bar']; return { fn }; }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); function A(){ let [fn, test] = ['foo', 'bar']; return { fn }; }" `) expect(result?.deps).toEqual(['vue']) @@ -220,7 +220,7 @@ test('rewrite variable in string interpolation in function nested arguments', as `import { fn } from 'vue';function A({foo = \`test\${fn}\`} = {}){ return {}; }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); function A({foo = \`test\${__vite_ssr_import_0__.fn}\`} = {}){ return {}; }" `) expect(result?.deps).toEqual(['vue']) @@ -232,7 +232,7 @@ test('rewrite variables in default value of destructuring params', async () => { `import { fn } from 'vue';function A({foo = fn}){ return {}; }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); function A({foo = __vite_ssr_import_0__.fn}){ return {}; }" `) expect(result?.deps).toEqual(['vue']) @@ -243,7 +243,7 @@ test('do not rewrite when function declaration is in scope', async () => { `import { fn } from 'vue';function A(){ function fn() {}; return { fn }; }`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"fn\\"]}); function A(){ function fn() {}; return { fn }; }" `) expect(result?.deps).toEqual(['vue']) @@ -254,7 +254,7 @@ test('do not rewrite catch clause', async () => { `import {error} from './dependency';try {} catch(error) {}`, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./dependency\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./dependency\\", {\\"namedImportSpecifiers\\":[\\"error\\"]}); try {} catch(error) {}" `) expect(result?.deps).toEqual(['./dependency']) @@ -267,7 +267,7 @@ test('should declare variable for imported super class', async () => { `import { Foo } from './dependency';` + `class A extends Foo {}`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./dependency\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./dependency\\", {\\"namedImportSpecifiers\\":[\\"Foo\\"]}); const Foo = __vite_ssr_import_0__.Foo; class A extends Foo {}" `) @@ -281,7 +281,7 @@ test('should declare variable for imported super class', async () => { `export class B extends Foo {}`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./dependency\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./dependency\\", {\\"namedImportSpecifiers\\":[\\"Foo\\"]}); const Foo = __vite_ssr_import_0__.Foo; class A extends Foo {} class B extends Foo {} @@ -354,7 +354,7 @@ test('overwrite bindings', async () => { `function g() { const f = () => { const inject = true }; console.log(inject) }\n`, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"inject\\"]}); const a = { inject: __vite_ssr_import_0__.inject } const b = { test: __vite_ssr_import_0__.inject } function c() { const { test: inject } = { test: true }; console.log(inject) } @@ -383,7 +383,7 @@ function c({ _ = bar() + foo() }) {} `, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\", {\\"namedImportSpecifiers\\":[\\"foo\\",\\"bar\\"]}); const a = ({ _ = __vite_ssr_import_0__.foo() }) => {} @@ -405,7 +405,7 @@ const a = () => { `, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\", {\\"namedImportSpecifiers\\":[\\"n\\"]}); const a = () => { @@ -428,7 +428,7 @@ const foo = {} `, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\", {\\"namedImportSpecifiers\\":[\\"n\\",\\"m\\"]}); const foo = {} @@ -471,7 +471,7 @@ objRest() `, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"remove\\",\\"add\\",\\"get\\",\\"set\\",\\"rest\\",\\"objRest\\"]}); @@ -553,7 +553,7 @@ class A { `, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"remove\\",\\"add\\"]}); @@ -631,7 +631,7 @@ bbb() `, ), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"vue\\", {\\"namedImportSpecifiers\\":[\\"aaa\\",\\"bbb\\",\\"ccc\\",\\"ddd\\"]}); @@ -677,7 +677,7 @@ test('jsx', async () => { expect(await ssrTransformSimpleCode(result.code, '/foo.jsx')) .toMatchInlineSnapshot(` "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"react\\"); - const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"foo\\"); + const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"foo\\", {\\"namedImportSpecifiers\\":[\\"Foo\\",\\"Slot\\"]}); function Bar({ Slot: Slot2 = /* @__PURE__ */ __vite_ssr_import_0__.default.createElement(__vite_ssr_import_1__.Foo, null) }) { @@ -788,7 +788,7 @@ export class Test { };`.trim() expect(await ssrTransformSimpleCode(code)).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foobar\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foobar\\", {\\"namedImportSpecifiers\\":[\\"foo\\",\\"bar\\"]}); if (false) { const foo = 'foo' @@ -830,7 +830,7 @@ function test() { return [foo, bar] }`), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foobar\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foobar\\", {\\"namedImportSpecifiers\\":[\\"foo\\",\\"bar\\"]}); function test() { @@ -857,7 +857,7 @@ function test() { return bar; }`), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foobar\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foobar\\", {\\"namedImportSpecifiers\\":[\\"foo\\",\\"bar\\",\\"baz\\"]}); function test() { @@ -889,7 +889,7 @@ for (const test in tests) { console.log(test) }`), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./test.js\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./test.js\\", {\\"namedImportSpecifiers\\":[\\"test\\"]}); @@ -921,7 +921,7 @@ const Baz = class extends Foo {} `, ) expect(result?.code).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./foo\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./foo\\", {\\"namedImportSpecifiers\\":[\\"Bar\\"]}); @@ -942,11 +942,12 @@ test('import assertion attribute', async () => { import('./bar.json', { with: { type: 'json' } }); `), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./foo.json\\"); - - - __vite_ssr_dynamic_import__('./bar.json', { with: { type: 'json' } }); - "`) + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./foo.json\\"); + + + __vite_ssr_dynamic_import__('./bar.json', { with: { type: 'json' } }); + " + `) }) test('import and export ordering', async () => { @@ -962,10 +963,10 @@ export * from './b' console.log(foo + 2) `), ).toMatchInlineSnapshot(` - "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./foo\\"); - const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"./a\\"); + "const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"./foo\\", {\\"namedImportSpecifiers\\":[\\"foo\\"]}); + const __vite_ssr_import_1__ = await __vite_ssr_import__(\\"./a\\", {\\"isExportAll\\":true}); __vite_ssr_exportAll__(__vite_ssr_import_1__); - const __vite_ssr_import_2__ = await __vite_ssr_import__(\\"./b\\"); + const __vite_ssr_import_2__ = await __vite_ssr_import__(\\"./b\\", {\\"isExportAll\\":true}); __vite_ssr_exportAll__(__vite_ssr_import_2__); console.log(__vite_ssr_import_0__.foo + 1) diff --git a/packages/vite/src/node/ssr/ssrModuleLoader.ts b/packages/vite/src/node/ssr/ssrModuleLoader.ts index a971a4fd490619..9e895d45304d92 100644 --- a/packages/vite/src/node/ssr/ssrModuleLoader.ts +++ b/packages/vite/src/node/ssr/ssrModuleLoader.ts @@ -2,11 +2,12 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import colors from 'picocolors' import type { ViteDevServer } from '../server' -import { isBuiltin, unwrapId } from '../utils' +import { isBuiltin, isFilePathESM, unwrapId } from '../utils' import { transformRequest } from '../server/transformRequest' import type { InternalResolveOptionsWithOverrideConditions } from '../plugins/resolve' import { tryNodeResolve } from '../plugins/resolve' import { genSourceMapUrl } from '../server/sourcemap' +import type { PackageCache } from '../packages' import { ssrDynamicImportKey, ssrExportAllKey, @@ -22,6 +23,18 @@ interface SSRContext { type SSRModule = Record +interface NodeImportResolveOptions + extends InternalResolveOptionsWithOverrideConditions { + legacyProxySsrExternalModules?: boolean + packageCache?: PackageCache +} + +interface SSRImportMetadata { + isDynamicImport?: boolean + isExportAll?: boolean + namedImportSpecifiers?: string[] +} + // eslint-disable-next-line @typescript-eslint/no-empty-function const AsyncFunction = async function () {}.constructor as typeof Function let fnDeclarationLineCount = 0 @@ -123,7 +136,7 @@ async function instantiateModule( const overrideConditions = ssr.resolve?.externalConditions || [] - const resolveOptions: InternalResolveOptionsWithOverrideConditions = { + const resolveOptions: NodeImportResolveOptions = { mainFields: ['main'], browserField: true, conditions: [], @@ -135,16 +148,19 @@ async function instantiateModule( isProduction, root, ssrConfig: ssr, + legacyProxySsrExternalModules: + server.config.legacy?.proxySsrExternalModules, + packageCache: server.config.packageCache, } // Since dynamic imports can happen in parallel, we need to // account for multiple pending deps and duplicate imports. const pendingDeps: string[] = [] - const ssrImport = async (dep: string) => { + const ssrImport = async (dep: string, metadata?: SSRImportMetadata) => { try { if (dep[0] !== '.' && dep[0] !== '/') { - return await nodeImport(dep, mod.file!, resolveOptions) + return await nodeImport(dep, mod.file!, resolveOptions, metadata) } // convert to rollup URL because `pendingImports`, `moduleGraph.urlToModuleMap` requires that dep = unwrapId(dep) @@ -183,7 +199,7 @@ async function instantiateModule( if (dep[0] === '.') { dep = path.posix.resolve(path.dirname(url), dep) } - return ssrImport(dep) + return ssrImport(dep, { isDynamicImport: true }) } function ssrExportAll(sourceModule: any) { @@ -264,10 +280,12 @@ async function instantiateModule( async function nodeImport( id: string, importer: string, - resolveOptions: InternalResolveOptionsWithOverrideConditions, + resolveOptions: NodeImportResolveOptions, + metadata?: SSRImportMetadata, ) { let url: string - if (id.startsWith('data:') || isBuiltin(id)) { + const isRuntimeHandled = id.startsWith('data:') || isBuiltin(id) + if (isRuntimeHandled) { url = id } else { const resolved = tryNodeResolve( @@ -289,7 +307,21 @@ async function nodeImport( } const mod = await import(url) - return proxyESM(mod) + + if (resolveOptions.legacyProxySsrExternalModules) { + return proxyESM(mod) + } else if (isRuntimeHandled) { + return mod + } else { + analyzeImportedModDifference( + mod, + url, + id, + metadata, + resolveOptions.packageCache, + ) + return proxyGuardOnlyEsm(mod, id) + } } // rollup-style default import interop for cjs @@ -317,3 +349,57 @@ function proxyESM(mod: any) { function isPrimitive(value: any) { return !value || (typeof value !== 'object' && typeof value !== 'function') } + +/** + * Vite converts `import { } from 'foo'` to `const _ = __vite_ssr_import__('foo')`. + * Top-level imports and dynamic imports work slightly differently in Node.js. + * This function normalizes the differences so it matches prod behaviour. + */ +function analyzeImportedModDifference( + mod: any, + filePath: string, + rawId: string, + metadata?: SSRImportMetadata, + packageCache?: PackageCache, +) { + // No normalization needed if the user already dynamic imports this module + if (metadata?.isDynamicImport) return + // If file path is ESM, everything should be fine + if (isFilePathESM(filePath, packageCache)) return + + // For non-ESM, named imports is done via static analysis with cjs-module-lexer in Node.js. + // If the user named imports a specifier that can't be analyzed, error. + if (metadata?.namedImportSpecifiers?.length) { + const missingBindings = metadata.namedImportSpecifiers.filter( + (s) => !(s in mod), + ) + if (missingBindings.length) { + const lastBinding = missingBindings[missingBindings.length - 1] + // Copied from Node.js + throw new SyntaxError(`\ +Named export '${lastBinding}' not found. The requested module '${rawId}' is a CommonJS module, which may not support all module.exports as named exports. +CommonJS modules can always be imported via the default export, for example using: + +import pkg from '${rawId}'; +const {${missingBindings.join(', ')}} = pkg; +`) + } + } +} + +/** + * Guard invalid named exports only, similar to how Node.js errors for top-level imports. + * But since we transform as dynamic imports, we need to emulate the error manually. + */ +function proxyGuardOnlyEsm(mod: any, rawId: string) { + return new Proxy(mod, { + get(mod, prop) { + if (prop !== 'then' && !(prop in mod)) { + throw new SyntaxError( + `The requested module '${rawId}' does not provide an export named '${prop.toString()}'`, + ) + } + return mod[prop] + }, + }) +} diff --git a/packages/vite/src/node/ssr/ssrTransform.ts b/packages/vite/src/node/ssr/ssrTransform.ts index bb6dbb35657b5b..77b9d9b831ea9c 100644 --- a/packages/vite/src/node/ssr/ssrTransform.ts +++ b/packages/vite/src/node/ssr/ssrTransform.ts @@ -28,6 +28,11 @@ interface TransformOptions { } } +interface DefineImportMetadata { + isExportAll?: boolean + namedImportSpecifiers?: string[] +} + export const ssrModuleExportsKey = `__vite_ssr_exports__` export const ssrImportKey = `__vite_ssr_import__` export const ssrDynamicImportKey = `__vite_ssr_dynamic_import__` @@ -93,14 +98,28 @@ async function ssrTransformScript( // hoist at the start of the file, after the hashbang const hoistIndex = code.match(hashbangRE)?.[0].length ?? 0 - function defineImport(source: string) { + function defineImport(source: string, metadata?: DefineImportMetadata) { deps.add(source) const importId = `__vite_ssr_import_${uid++}__` + + // Reduce metadata to undefined if it's all default values + if ( + metadata && + metadata.isExportAll !== true && + (metadata.namedImportSpecifiers == null || + metadata.namedImportSpecifiers.length === 0) + ) { + metadata = undefined + } + const metadataStr = metadata ? `, ${JSON.stringify(metadata)}` : '' + // There will be an error if the module is called before it is imported, // so the module import statement is hoisted to the top s.appendLeft( hoistIndex, - `const ${importId} = await ${ssrImportKey}(${JSON.stringify(source)});\n`, + `const ${importId} = await ${ssrImportKey}(${JSON.stringify( + source, + )}${metadataStr});\n`, ) return importId } @@ -119,7 +138,11 @@ async function ssrTransformScript( // import { baz } from 'foo' --> baz -> __import_foo__.baz // import * as ok from 'foo' --> ok -> __import_foo__ if (node.type === 'ImportDeclaration') { - const importId = defineImport(node.source.value as string) + const importId = defineImport(node.source.value as string, { + namedImportSpecifiers: node.specifiers + .map((s) => s.type === 'ImportSpecifier' && s.imported.name) + .filter(Boolean) as string[], + }) s.remove(node.start, node.end) for (const spec of node.specifiers) { if (spec.type === 'ImportSpecifier') { @@ -162,7 +185,9 @@ async function ssrTransformScript( s.remove(node.start, node.end) if (node.source) { // export { foo, bar } from './foo' - const importId = defineImport(node.source.value as string) + const importId = defineImport(node.source.value as string, { + namedImportSpecifiers: node.specifiers.map((s) => s.local.name), + }) // hoist re-exports near the defined import so they are immediately exported for (const spec of node.specifiers) { defineExport( @@ -212,7 +237,9 @@ async function ssrTransformScript( // export * from './foo' if (node.type === 'ExportAllDeclaration') { s.remove(node.start, node.end) - const importId = defineImport(node.source.value as string) + const importId = defineImport(node.source.value as string, { + isExportAll: true, + }) // hoist re-exports near the defined import so they are immediately exported if (node.exported) { defineExport(hoistIndex, node.exported.name, `${importId}`) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 52d0ff6a6c0fc0..1e4adf07d14cc2 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -32,7 +32,11 @@ import { import type { DepOptimizationConfig } from './optimizer' import type { ResolvedConfig } from './config' import type { ResolvedServerUrls, ViteDevServer } from './server' -import { resolvePackageData } from './packages' +import { + type PackageCache, + findNearestPackageData, + resolvePackageData, +} from './packages' import type { CommonServerOptions } from '.' /** @@ -427,6 +431,25 @@ export function lookupFile( } } +export function isFilePathESM( + filePath: string, + packageCache?: PackageCache, +): boolean { + if (/\.m[jt]s$/.test(filePath)) { + return true + } else if (/\.c[jt]s$/.test(filePath)) { + return false + } else { + // check package.json for type: "module" + try { + const pkg = findNearestPackageData(path.dirname(filePath), packageCache) + return pkg?.data.type === 'module' + } catch { + return false + } + } +} + const splitRE = /\r?\n/ const range: number = 2 diff --git a/playground/ssr-deps/src/app.js b/playground/ssr-deps/src/app.js index 9942dec47d7401..719c6eec7e8205 100644 --- a/playground/ssr-deps/src/app.js +++ b/playground/ssr-deps/src/app.js @@ -21,10 +21,8 @@ import '@vitejs/test-css-lib' // This import will set a 'Hello World!" message in the nested-external non-entry dependency import '@vitejs/test-non-optimized-with-nested-external' -// These two are optimized and get the message from nested-external, if the dependency is -// not properly externalized and ends up bundled, the message will be undefined -import optimizedWithNestedExternal from '@vitejs/test-optimized-with-nested-external' -import optimizedCjsWithNestedExternal from '@vitejs/test-optimized-cjs-with-nested-external' +import * as optimizedWithNestedExternal from '@vitejs/test-optimized-with-nested-external' +import * as optimizedCjsWithNestedExternal from '@vitejs/test-optimized-cjs-with-nested-external' import { setMessage } from '@vitejs/test-external-entry/entry' setMessage('Hello World!') @@ -42,7 +40,8 @@ export async function render(url, rootDir) { html += `\n

message from primitive export: ${primitiveExport}

` - const tsDefaultExportMessage = tsDefaultExport() + // `.default()` as incorrectly packaged + const tsDefaultExportMessage = tsDefaultExport.default() html += `\n

message from ts-default-export: ${tsDefaultExportMessage}

` const tsNamedExportMessage = tsNamedExport()