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

fix: load esm vite config without a temporary file #17894

Closed
wants to merge 16 commits into from
Closed
132 changes: 96 additions & 36 deletions packages/vite/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { Alias, AliasOptions } from 'dep-types/alias'
import aliasPlugin from '@rollup/plugin-alias'
import { build } from 'esbuild'
import type { RollupOptions } from 'rollup'
import { init, parse } from 'es-module-lexer'
import MagicString from 'magic-string'
import { withTrailingSlash } from '../shared/utils'
import {
CLIENT_ENTRY,
Expand Down Expand Up @@ -1068,6 +1070,34 @@ export async function loadConfigFromFile(
}
}

function createNodeResolveForConfigFile(
fileName: string,
packageCache: PackageCache,
) {
return (id: string, importer: string, isRequire: boolean) => {
return tryNodeResolve(
id,
importer,
{
root: path.dirname(fileName),
isBuild: true,
isProduction: true,
preferRelative: false,
tryIndex: true,
mainFields: [],
conditions: [],
overrideConditions: ['node'],
dedupe: [],
extensions: DEFAULT_EXTENSIONS,
preserveSymlinks: false,
packageCache,
isRequire,
},
false,
)?.id
}
}

async function bundleConfigFile(
fileName: string,
isESM: boolean,
Expand Down Expand Up @@ -1098,32 +1128,10 @@ async function bundleConfigFile(
name: 'externalize-deps',
setup(build) {
const packageCache = new Map()
const resolveByViteResolver = (
id: string,
importer: string,
isRequire: boolean,
) => {
return tryNodeResolve(
id,
importer,
{
root: path.dirname(fileName),
isBuild: true,
isProduction: true,
preferRelative: false,
tryIndex: true,
mainFields: [],
conditions: [],
overrideConditions: ['node'],
dedupe: [],
extensions: DEFAULT_EXTENSIONS,
preserveSymlinks: false,
packageCache,
isRequire,
},
false,
)?.id
}
const resolveByViteResolver = createNodeResolveForConfigFile(
fileName,
packageCache,
)

// externalize bare imports
build.onResolve(
Expand Down Expand Up @@ -1213,13 +1221,62 @@ async function bundleConfigFile(
},
],
})
const { text } = result.outputFiles[0]
let { text } = result.outputFiles[0]
if (isESM) {
text = await transformViteConfigDynamicImport(text, fileName)
}
return {
code: text,
dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [],
}
}

async function transformViteConfigDynamicImport(
text: string,
importer: string,
): Promise<string> {
if (!/import\s*\(/.test(text)) {
return text
}
await init
const [imports] = parse(text)
const output = new MagicString(text)
for (const imp of imports) {
// replace `import(anything)` with `__vite_config_import__(anything)`
if (imp.d >= 0 && typeof imp.n === 'undefined') {
output.update(imp.ss, imp.d, `__vite_config_import__`)
}
}
if (output.hasChanged()) {
Object.assign(globalThis, { __vite_config_import_helper__ })
output.prepend(
`const __vite_config_import__ = (id) => globalThis.__vite_config_import_helper__(${JSON.stringify(importer)}, id);`,
)
text = output.toString()
}
return text
}

async function __vite_config_import_helper__(
importer: string,
id: string,
): Promise<unknown> {
let resolved: string
if (isBuiltin(id) || id[0] === '/') {
resolved = id
} else if (id[0] === '.') {
resolved = pathToFileURL(path.resolve(path.dirname(importer), id)).href
} else {
const resolver = createNodeResolveForConfigFile(importer, new Map())
const result = resolver(id, importer, false)
if (!result) {
throw new Error(`Failed to resolve dynamic import '${id}'`)
}
resolved = pathToFileURL(result).href
}
return import(resolved)
}

interface NodeModuleWithCompile extends NodeModule {
_compile(code: string, filename: string): any
}
Expand All @@ -1233,17 +1290,20 @@ async function loadConfigFromBundledFile(
// for esm, before we can register loaders without requiring users to run node
// with --experimental-loader themselves, we have to do a hack here:
// write it to disk, load it with native Node ESM, then delete the file.
// convert to base64, load it with native Node ESM.
if (isESM) {
const fileBase = `${fileName}.timestamp-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`
const fileNameTmp = `${fileBase}.mjs`
const fileUrl = `${pathToFileURL(fileBase)}.mjs`
await fsp.writeFile(fileNameTmp, bundledCode)
try {
return (await import(fileUrl)).default
} finally {
fs.unlink(fileNameTmp, () => {}) // Ignore errors
// prepend timestamp for import cache busting
bundledCode =
`"${Date.now()}-${Math.random().toString(16).slice(2)}";` + bundledCode
return (
await import(
'data:text/javascript;base64,' +
Buffer.from(bundledCode).toString('base64')
)
).default
} catch (e) {
throw new Error(`${e.message} at ${fileName}`)
}
}
// for cjs, we can register a custom loader via `_require.extensions`
Expand Down
75 changes: 75 additions & 0 deletions playground/config/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,78 @@ it.runIf(isImportAttributesSupported)(
`)
},
)

it('dynamic import', async () => {
const { config } = (await loadConfigFromFile(
{ command: 'serve', mode: 'development' },
resolve(__dirname, '../packages/entry/vite.config.dynamic.ts'),
)) as any
expect(await config.knownImport()).toMatchInlineSnapshot(`
{
"default": "ok",
}
`)
expect(await config.rawImport('../siblings/ok.js')).toMatchInlineSnapshot(`
{
"default": "ok",
}
`)
// two are different since one is bundled but the other is from node
expect(await config.knownImport()).not.toBe(
await config.rawImport('../siblings/ok.js'),
)

expect(await config.rawImport('@vite/test-config-plugin-module-condition'))
.toMatchInlineSnapshot(`
{
"default": "import condition",
}
`)

// importing "./ok.js" inside "siblings/dynamic.js" should resolve to "siblings/ok.js"
// but this case has never been supported.
await expect(() => config.siblingsDynamic('./ok.js')).rejects.toThrow(
'Cannot find module',
)

await expect(() =>
config.rawImport('no-such-module'),
).rejects.toMatchInlineSnapshot(
`[Error: Failed to resolve dynamic import 'no-such-module']`,
)
})

it('can reload even when same content', async () => {
const load = () =>
loadConfigFromFile(
{ command: 'serve', mode: 'development' },
resolve(__dirname, '../packages/entry/vite.config.reload.ts'),
)
const result1 = await load()
const result2 = await load()
expect(result1.config).not.toBe(result2.config)
})

it('no export error', async () => {
await expect(() =>
loadConfigFromFile(
{ command: 'serve', mode: 'development' },
resolve(__dirname, '../packages/entry/vite.config.no-export.ts'),
undefined,
'silent',
),
).rejects.toMatchInlineSnapshot(
`[Error: config must export or return an object.]`,
)
})

it('error', async () => {
await expect(() =>
loadConfigFromFile(
{ command: 'serve', mode: 'development' },
resolve(__dirname, '../packages/entry/vite.config.error.ts'),
undefined,
'silent',
),
).rejects.toThrow(`error-dep at `)
})
7 changes: 7 additions & 0 deletions playground/config/packages/entry/vite.config.dynamic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import siblingsDynamic from '../siblings/dynamic.js'

export default {
knownImport: () => import('../siblings/ok.js'),
rawImport: (id: string) => import(id),
siblingsDynamic,
}
1 change: 1 addition & 0 deletions playground/config/packages/entry/vite.config.error-dep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('error-dep')
34 changes: 34 additions & 0 deletions playground/config/packages/entry/vite.config.error-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'

export default defineConfig({
plugins: [
{
name: 'test-plugin-error',
transform(code, id, options) {
testError()
},
},
{
name: 'virtual-entry',
resolveId(source, importer, options) {
if (source === 'virtual:entry') {
return '\0' + source
}
},
load(id, options) {
if (id === '\0virtual:entry') {
return `export default {}`
}
},
},
],
build: {
rollupOptions: {
input: 'virtual:entry',
},
},
})

function testError() {
throw new Error('Testing Error')
}
1 change: 1 addition & 0 deletions playground/config/packages/entry/vite.config.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './vite.config.error-dep'
Empty file.
1 change: 1 addition & 0 deletions playground/config/packages/entry/vite.config.reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {}
1 change: 1 addition & 0 deletions playground/config/packages/siblings/dynamic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default (id) => import(id)
1 change: 1 addition & 0 deletions playground/config/packages/siblings/ok.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'ok'
Loading