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: handle named imports of builtin modules #8338

Merged
merged 13 commits into from Jun 5, 2022
Merged
36 changes: 29 additions & 7 deletions packages/vite/src/node/optimizer/esbuildDepPlugin.ts
Expand Up @@ -198,15 +198,37 @@ export function esbuildDepPlugin(

build.onLoad(
{ filter: /.*/, namespace: 'browser-external' },
({ path: id }) => {
({ path }) => {
return {
contents:
`export default new Proxy({}, {
get() {
throw new Error('Module "${id}" has been externalized for ` +
`browser compatibility and cannot be accessed in client code.')
// Return in CJS to intercept named imports. Use `Object.create` to
// create the Proxy in the prototype to workaround esbuild issue. Why?
//
// In short, esbuild cjs->esm flow:
// 1. Create empty object using `Object.create(Object.getPrototypeOf(module.exports))`.
// 2. Assign props of `module.exports` to the object.
// 3. Return object for ESM use.
//
// If we do `module.exports = new Proxy({}, {})`, step 1 returns empty object,
// step 2 does nothing as there's no props for `module.exports`. The final object
// is just an empty object.
//
// Creating the Proxy in the prototype satisfies step 1 immediately, which means
// the returned object is a Proxy that we can intercept.
//
// Note: Skip keys that are accessed by esbuild and browser devtools.
contents: `\
module.exports = Object.create(new Proxy({}, {
get(_, key) {
if (
key !== '__esModule' &&
key !== '__proto__' &&
key !== 'constructor' &&
key !== 'splice'
) {
throw new Error(\`Cannot access "${path}.\${key}" in client code.\`)
sapphi-red marked this conversation as resolved.
Show resolved Hide resolved
}
}
})`
}))`
}
}
)
Expand Down
69 changes: 47 additions & 22 deletions packages/vite/src/node/plugins/importAnalysis.ts
Expand Up @@ -51,6 +51,7 @@ import {
import { checkPublicFile } from './asset'
import { ERR_OUTDATED_OPTIMIZED_DEP } from './optimizedDeps'
import { isCSSRequest, isDirectCSSRequest } from './css'
import { browserExternalId } from './resolve'

const isDebug = !!process.env.DEBUG
const debug = createDebugger('vite:import-analysis')
Expand Down Expand Up @@ -314,11 +315,9 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
s: start,
e: end,
ss: expStart,
se: expEnd,
d: dynamicIndex,
// #2083 User may use escape path,
// so use imports[index].n to get the unescaped string
// @ts-ignore
n: specifier
} = imports[index]

Expand Down Expand Up @@ -430,29 +429,20 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
}
} else if (needsInterop) {
debug(`${url} needs interop`)
if (isDynamicImport) {
// rewrite `import('package')` to expose the default directly
str().overwrite(
expStart,
expEnd,
`import('${url}').then(m => m.default && m.default.__esModule ? m.default : ({ ...m.default, default: m.default }))`,
{ contentOnly: true }
)
} else {
const exp = source.slice(expStart, expEnd)
const rewritten = transformCjsImport(exp, url, rawUrl, index)
if (rewritten) {
str().overwrite(expStart, expEnd, rewritten, {
contentOnly: true
})
} else {
// #1439 export * from '...'
str().overwrite(start, end, url, { contentOnly: true })
}
}
interopNamedImports(str(), imports[index], url, index)
rewriteDone = true
}
}
// If source code imports builtin modules via named imports, the stub proxy export
// would fail as it's `export default` only. Apply interop for builtin modules to
// correctly throw the error message.
else if (
url.includes(browserExternalId) &&
source.slice(expStart, start).includes('{')
) {
interopNamedImports(str(), imports[index], url, index)
rewriteDone = true
}
if (!rewriteDone) {
str().overwrite(start, end, isDynamicImport ? `'${url}'` : url, {
contentOnly: true
Expand Down Expand Up @@ -634,6 +624,41 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
}
}

function interopNamedImports(
str: MagicString,
importSpecifier: ImportSpecifier,
rewrittenUrl: string,
importIndex: number
) {
const source = str.original
const {
s: start,
e: end,
ss: expStart,
se: expEnd,
d: dynamicIndex
} = importSpecifier
if (dynamicIndex > -1) {
// rewrite `import('package')` to expose the default directly
str.overwrite(
expStart,
expEnd,
`import('${rewrittenUrl}').then(m => m.default && m.default.__esModule ? m.default : ({ ...m.default, default: m.default }))`,
{ contentOnly: true }
)
} else {
const exp = source.slice(expStart, expEnd)
const rawUrl = source.slice(start, end)
const rewritten = transformCjsImport(exp, rewrittenUrl, rawUrl, importIndex)
if (rewritten) {
str.overwrite(expStart, expEnd, rewritten, { contentOnly: true })
} else {
// #1439 export * from '...'
str.overwrite(start, end, rewrittenUrl, { contentOnly: true })
}
}
}

type ImportNameSpecifier = { importedName: string; localName: string }

/**
Expand Down
16 changes: 9 additions & 7 deletions packages/vite/src/node/plugins/resolve.ts
Expand Up @@ -329,15 +329,17 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin {

load(id) {
if (id.startsWith(browserExternalId)) {
return isProduction
? `export default {}`
: `export default new Proxy({}, {
get() {
throw new Error('Module "${id.slice(
browserExternalId.length + 1
)}" has been externalized for browser compatibility and cannot be accessed in client code.')
if (isProduction) {
return `export default {}`
} else {
id = id.slice(browserExternalId.length + 1)
return `\
export default new Proxy({}, {
get(_, key) {
throw new Error(\`Cannot access "${id}.\${key}" in client code.\`)
sapphi-red marked this conversation as resolved.
Show resolved Hide resolved
}
})`
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion playground/optimize-deps/.env
@@ -1 +1 @@
NODE_ENV=production
NODE_ENV=development
41 changes: 39 additions & 2 deletions playground/optimize-deps/__tests__/optimize-deps.spec.ts
@@ -1,4 +1,11 @@
import { getColor, isBuild, page } from '~utils'
import {
browserErrors,
browserLogs,
getColor,
isBuild,
isServe,
page
} from '~utils'

test('default + named imports from cjs dep (react)', async () => {
expect(await page.textContent('.cjs button')).toBe('count is 0')
Expand Down Expand Up @@ -63,7 +70,7 @@ test('import * from optimized dep', async () => {
})

test('import from dep with process.env.NODE_ENV', async () => {
expect(await page.textContent('.node-env')).toMatch(`prod`)
expect(await page.textContent('.node-env')).toMatch(isBuild ? 'prod' : 'dev')
})

test('import from dep with .notjs files', async () => {
Expand Down Expand Up @@ -109,3 +116,33 @@ test('import aliased package with colon', async () => {
test('variable names are reused in different scripts', async () => {
expect(await page.textContent('.reused-variable-names')).toBe('reused')
})

test.runIf(isServe)('error on builtin modules usage', () => {
expect(browserLogs).toEqual(
expect.arrayContaining([
// from dep-with-builtin-module-esm top-level try-catch
expect.stringContaining(
'dep-with-builtin-module-esm Error: Cannot access "fs.readFileSync" in client code.'
),
expect.stringContaining(
'dep-with-builtin-module-esm Error: Cannot access "path.join" in client code.'
),
// from dep-with-builtin-module-cjs top-level try-catch
expect.stringContaining(
'dep-with-builtin-module-cjs Error: Cannot access "path.join" in client code.'
)
])
)

expect(browserErrors.map((error) => error.message)).toEqual(
expect.arrayContaining([
// from user source code
'Cannot access "buffer.Buffer" in client code.',
'Cannot access "child_process.execSync" in client code.',
// from dep-with-builtin-module-esm read()
'Cannot access "fs.readFileSync" in client code.',
// from dep-with-builtin-module-esm read()
'Cannot access "fs.readFileSync" in client code.'
])
)
})
18 changes: 18 additions & 0 deletions playground/optimize-deps/dep-with-builtin-module-cjs/index.js
@@ -0,0 +1,18 @@
const fs = require('fs')
const path = require('path')

// NOTE: require destructure would error immediately because of how esbuild
// compiles it. There's no way around it as it's direct property access, which
// triggers the Proxy get trap.

// access from default import
try {
path.join()
} catch (e) {
console.log('dep-with-builtin-module-cjs', e)
}

// access from function
module.exports.read = () => {
return fs.readFileSync('test')
}
@@ -0,0 +1,6 @@
{
"name": "dep-with-builtin-module-cjs",
"private": true,
"version": "0.0.0",
"main": "index.js"
}
21 changes: 21 additions & 0 deletions playground/optimize-deps/dep-with-builtin-module-esm/index.js
@@ -0,0 +1,21 @@
import { readFileSync } from 'fs'
import path from 'path'

// access from named import
try {
readFileSync()
} catch (e) {
console.log('dep-with-builtin-module-esm', e)
}

// access from default import
try {
path.join()
} catch (e) {
console.log('dep-with-builtin-module-esm', e)
}

// access from function
export function read() {
return readFileSync('test')
}
@@ -0,0 +1,7 @@
{
"name": "dep-with-builtin-module-esm",
"private": true,
"version": "0.0.0",
"main": "index.js",
"type": "module"
}
25 changes: 25 additions & 0 deletions playground/optimize-deps/index.html
Expand Up @@ -130,3 +130,28 @@ <h2>Reused variable names</h2>
const reusedName = 'reused'
text('.reused-variable-names', reusedName)
</script>

<script type="module">
// should error on builtin modules (named import)
import { Buffer } from 'buffer'
// named imports error immediately
</script>

<script type="module">
// should error on builtin modules (default import)
import cp from 'child_process'
// must access property to error
cp.execSync()
</script>

<script type="module">
// should error on builtin modules from dep
import { read } from 'dep-with-builtin-module-esm'
read()
</script>

<script type="module">
// should error on builtin modules from dep
import { read } from 'dep-with-builtin-module-cjs'
read()
</script>
2 changes: 2 additions & 0 deletions playground/optimize-deps/package.json
Expand Up @@ -18,6 +18,8 @@
"dep-linked-include": "link:./dep-linked-include",
"dep-node-env": "file:./dep-node-env",
"dep-not-js": "file:./dep-not-js",
"dep-with-builtin-module-cjs": "file:./dep-with-builtin-module-cjs",
"dep-with-builtin-module-esm": "file:./dep-with-builtin-module-esm",
"dep-with-dynamic-import": "file:./dep-with-dynamic-import",
"lodash-es": "^4.17.21",
"nested-exclude": "file:./nested-exclude",
Expand Down
13 changes: 13 additions & 0 deletions playground/optimize-deps/vite.config.js
Expand Up @@ -62,6 +62,19 @@ module.exports = {
return { code }
}
}
},
// TODO: Remove this one support for prebundling in build lands.
// It is expected that named importing in build doesn't work
// as it incurs a lot of overhead in build.
{
name: 'polyfill-named-fs-build',
apply: 'build',
enforce: 'pre',
load(id) {
if (id === '__vite-browser-external:fs') {
return `export default {}; export function readFileSync() {}`
}
}
}
]
}
Expand Down