Skip to content

Commit

Permalink
feat: handle named imports of builtin modules (#8338)
Browse files Browse the repository at this point in the history
  • Loading branch information
bluwy committed Jun 5, 2022
1 parent 1061bbd commit e2e44ff
Show file tree
Hide file tree
Showing 13 changed files with 244 additions and 39 deletions.
36 changes: 29 additions & 7 deletions packages/vite/src/node/optimizer/esbuildDepPlugin.ts
Expand Up @@ -219,15 +219,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(\`Module "${path}" has been externalized for browser compatibility. Cannot access "${path}.\${key}" in client code.\`)
}
}
})`
}))`
}
}
)
Expand Down
69 changes: 47 additions & 22 deletions packages/vite/src/node/plugins/importAnalysis.ts
Expand Up @@ -58,6 +58,7 @@ import {
delayDepsOptimizerUntil
} 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 @@ -322,11 +323,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 @@ -434,29 +433,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 @@ -639,6 +629,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 @@ -339,15 +339,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(\`Module "${id}" has been externalized for browser compatibility. Cannot access "${id}.\${key}" in client code.\`)
}
})`
}
}
}
}
Expand Down
1 change: 0 additions & 1 deletion playground/optimize-deps/.env

This file was deleted.

41 changes: 39 additions & 2 deletions playground/optimize-deps/__tests__/optimize-deps.spec.ts
@@ -1,4 +1,11 @@
import { getColor, 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 @@ -113,3 +120,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: Module "fs" has been externalized for browser compatibility. Cannot access "fs.readFileSync" in client code.'
),
expect.stringContaining(
'dep-with-builtin-module-esm Error: Module "path" has been externalized for browser compatibility. 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: Module "path" has been externalized for browser compatibility. Cannot access "path.join" in client code.'
)
])
)

expect(browserErrors.map((error) => error.message)).toEqual(
expect.arrayContaining([
// from user source code
'Module "buffer" has been externalized for browser compatibility. Cannot access "buffer.Buffer" in client code.',
'Module "child_process" has been externalized for browser compatibility. Cannot access "child_process.execSync" in client code.',
// from dep-with-builtin-module-esm read()
'Module "fs" has been externalized for browser compatibility. Cannot access "fs.readFileSync" in client code.',
// from dep-with-builtin-module-esm read()
'Module "fs" has been externalized for browser compatibility. 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"
}
31 changes: 31 additions & 0 deletions playground/optimize-deps/index.html
Expand Up @@ -138,3 +138,34 @@ <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
if (import.meta.env.DEV) {
cp.execSync()
}
</script>

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

<script type="module">
// should error on builtin modules from dep
import { read } from 'dep-with-builtin-module-cjs'
if (import.meta.env.DEV) {
read()
}
</script>
2 changes: 2 additions & 0 deletions playground/optimize-deps/package.json
Expand Up @@ -19,6 +19,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

0 comments on commit e2e44ff

Please sign in to comment.