From 6da621df635304615783b2442b002f0618199796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sat, 27 Aug 2022 00:11:57 +0100 Subject: [PATCH] feat: add `experimental.fallbackNodePolyfills` flag (#39248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For historical reasons, Next.js has been falling back to polyfill certain Node.js APIs in the browser. `webpack` itself stopped doing it, and so should Next.js. This might unexpectedly break some packages in the ecosystem though, so it is being introduced as an experimental flag. These imports will now throw a `Module not found` error and the package maintainer should make sure that the library isn't relying on these Node.js APIs when the package is meant for browser usage. Let's take a look at a common example, the `crypto` API, which can be imported as `import crypto from "crypto"` but [should already be available in browsers](https://caniuse.com/cryptography). Until now, Next.js has fallen back to use a polyfilled version for the import, which resulted in a bundle-size increase. ```js import crypto from 'crypto' import { useEffect } from 'react' export default function Page() { useEffect(() => { console.log(crypto) }, []) } ``` it imports `crypto`, which currently resolves to [`crypto-browserify`](https://www.npmjs.com/package/crypto-browserify). So the bundle will include `crypto-browserify` as well: ```sh Page Size First Load JS ┌ ○ / 131 kB 213 kB # <-- └ ○ /404 194 B 82.2 kB + First Load JS shared by all 82 kB ├ chunks/framework-bcc2dc0ea27ab0c6.js 45.1 kB ├ chunks/main-dc2421aef72299b4.js 35.4 kB ├ chunks/pages/_app-a85935458980c5c2.js 708 B └ chunks/webpack-9b312e20a4e32339.js 836 B ``` Here, we can just remove the import, as we are [safely accessing](https://nextjs.org/docs/migrating/from-create-react-app#safely-accessing-web-apis) the [Crypto Web API](https://caniuse.com/cryptography): ```diff - import crypto from 'crypto' import { useEffect } from 'react' export default function Page() { useEffect(() => { console.log(crypto) }, []) } ``` Which will reduce the bundle size: ```sh Page Size First Load JS ┌ ○ / 269 B 82.2 kB # <-- └ ○ /404 194 B 82.1 kB + First Load JS shared by all 81.9 kB ├ chunks/framework-bcc2dc0ea27ab0c6.js 45.1 kB ├ chunks/main-dc2421aef72299b4.js 35.4 kB ├ chunks/pages/_app-a85935458980c5c2.js 708 B └ chunks/webpack-fd82975a6094609f.js 727 B ``` This is harder to detect if the `crypto` import is in a third-party package though. By setting `experimental: { fallbackNodePolyfills: false }`, Next.js will now fail at build-time and should show where the unnecessary import comes from, so the developer can reach out to the package maintainer to fix this issue. Note: There might be differences between the living standard and some of these older polyfills, so you have to make sure your code works well without the polyfilled version. Related feedback: https://twitter.com/lfredolo/status/1539608666026000384 --- packages/next/build/webpack-config.ts | 123 ++++++++++-------- packages/next/server/config-schema.ts | 3 + packages/next/server/config-shared.ts | 6 + .../disable-fallback-polyfills/index.test.ts | 48 +++++++ 4 files changed, 125 insertions(+), 55 deletions(-) create mode 100644 test/production/disable-fallback-polyfills/index.test.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 581cec0af01e..e873787ae58b 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1577,61 +1577,74 @@ export default async function getBaseWebpackConfig( }, { resolve: { - // Full list of old polyfills is accessible here: - // https://github.com/webpack/webpack/blob/2a0536cf510768111a3a6dceeb14cb79b9f59273/lib/ModuleNotFoundError.js#L13-L42 - fallback: { - assert: require.resolve('next/dist/compiled/assert'), - buffer: require.resolve('next/dist/compiled/buffer/'), - constants: require.resolve( - 'next/dist/compiled/constants-browserify' - ), - crypto: require.resolve( - 'next/dist/compiled/crypto-browserify' - ), - domain: require.resolve( - 'next/dist/compiled/domain-browser' - ), - http: require.resolve('next/dist/compiled/stream-http'), - https: require.resolve( - 'next/dist/compiled/https-browserify' - ), - os: require.resolve('next/dist/compiled/os-browserify'), - path: require.resolve( - 'next/dist/compiled/path-browserify' - ), - punycode: require.resolve( - 'next/dist/compiled/punycode' - ), - process: require.resolve('./polyfills/process'), - // Handled in separate alias - querystring: require.resolve( - 'next/dist/compiled/querystring-es3' - ), - stream: require.resolve( - 'next/dist/compiled/stream-browserify' - ), - string_decoder: require.resolve( - 'next/dist/compiled/string_decoder' - ), - sys: require.resolve('next/dist/compiled/util/'), - timers: require.resolve( - 'next/dist/compiled/timers-browserify' - ), - tty: require.resolve( - 'next/dist/compiled/tty-browserify' - ), - // Handled in separate alias - // url: require.resolve('url/'), - util: require.resolve('next/dist/compiled/util/'), - vm: require.resolve('next/dist/compiled/vm-browserify'), - zlib: require.resolve( - 'next/dist/compiled/browserify-zlib' - ), - events: require.resolve('next/dist/compiled/events/'), - setImmediate: require.resolve( - 'next/dist/compiled/setimmediate' - ), - }, + fallback: + config.experimental.fallbackNodePolyfills === false + ? {} + : { + assert: require.resolve( + 'next/dist/compiled/assert' + ), + buffer: require.resolve( + 'next/dist/compiled/buffer/' + ), + constants: require.resolve( + 'next/dist/compiled/constants-browserify' + ), + crypto: require.resolve( + 'next/dist/compiled/crypto-browserify' + ), + domain: require.resolve( + 'next/dist/compiled/domain-browser' + ), + http: require.resolve( + 'next/dist/compiled/stream-http' + ), + https: require.resolve( + 'next/dist/compiled/https-browserify' + ), + os: require.resolve( + 'next/dist/compiled/os-browserify' + ), + path: require.resolve( + 'next/dist/compiled/path-browserify' + ), + punycode: require.resolve( + 'next/dist/compiled/punycode' + ), + process: require.resolve('./polyfills/process'), + // Handled in separate alias + querystring: require.resolve( + 'next/dist/compiled/querystring-es3' + ), + stream: require.resolve( + 'next/dist/compiled/stream-browserify' + ), + string_decoder: require.resolve( + 'next/dist/compiled/string_decoder' + ), + sys: require.resolve('next/dist/compiled/util/'), + timers: require.resolve( + 'next/dist/compiled/timers-browserify' + ), + tty: require.resolve( + 'next/dist/compiled/tty-browserify' + ), + // Handled in separate alias + // url: require.resolve('url/'), + util: require.resolve('next/dist/compiled/util/'), + vm: require.resolve( + 'next/dist/compiled/vm-browserify' + ), + zlib: require.resolve( + 'next/dist/compiled/browserify-zlib' + ), + events: require.resolve( + 'next/dist/compiled/events/' + ), + setImmediate: require.resolve( + 'next/dist/compiled/setimmediate' + ), + }, }, }, ], diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index db74156d29e3..56def7e1fdef 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -258,6 +258,9 @@ const configSchema = { externalDir: { type: 'boolean', }, + fallbackNodePolyfills: { + type: 'boolean', + }, forceSwcTransforms: { type: 'boolean', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 0977434043f7..fe2238449320 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -145,6 +145,12 @@ export interface ExperimentalConfig { } swcPlugins?: Array<[string, Record]> largePageDataBytes?: number + /** + * If set to `false`, webpack won't fall back to polyfill Node.js modules in the browser + * Full list of old polyfills is accessible here: + * [webpack/webpack#ModuleNotoundError.js#L13-L42](https://github.com/webpack/webpack/blob/2a0536cf510768111a3a6dceeb14cb79b9f59273/lib/ModuleNotFoundError.js#L13-L42) + */ + fallbackNodePolyfills?: false } export type ExportPathMap = { diff --git a/test/production/disable-fallback-polyfills/index.test.ts b/test/production/disable-fallback-polyfills/index.test.ts new file mode 100644 index 000000000000..b22fd400cb09 --- /dev/null +++ b/test/production/disable-fallback-polyfills/index.test.ts @@ -0,0 +1,48 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('Disable fallback polyfills', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + import { useEffect } from 'react' + import crypto from 'crypto' + + export default function Page() { + useEffect(() => { + crypto; + }, []) + return

hello world

+ } + `, + }, + dependencies: {}, + }) + await next.stop() + }) + afterAll(() => next.destroy()) + + it('Fallback polyfills added by default', async () => { + const firstLoadJSSize = Number( + next.cliOutput.match(/○ \/\s{38}(\d*) kB\s{10}(?\d*) kB/) + .groups.firstLoad + ) + expect(firstLoadJSSize).not.toBeLessThan(200) + }) + + it('Build should fail, when fallback polyfilling is disabled', async () => { + await next.patchFile( + 'next.config.js', + `module.exports = { + experimental: { + fallbackNodePolyfills: false + } + }` + ) + + await expect(next.start()).rejects.toThrow(/next build failed/) + }) +})