diff --git a/packages/next/src/build/handle-externals.ts b/packages/next/src/build/handle-externals.ts index 5f486d57f9..b77e9656ea 100644 --- a/packages/next/src/build/handle-externals.ts +++ b/packages/next/src/build/handle-externals.ts @@ -151,6 +151,7 @@ export function makeExternalHandler({ }) { let resolvedExternalPackageDirs: Map const looseEsmExternals = config.experimental?.esmExternals === 'loose' + const optOutBundlingPackagesSet = new Set(optOutBundlingPackages) return async function handleExternals( context: string, @@ -220,27 +221,6 @@ export function makeExternalHandler({ // Also disable esm request when appDir is enabled const isEsmRequested = dependencyType === 'esm' - /** - * @param localRes the full path to the file - * @returns the externalized path - * @description returns an externalized path if the file is a Next.js file and ends with either `.shared-runtime.js` or `.external.js` - * This is used to ensure that files used across the rendering runtime(s) and the user code are one and the same. The logic in this function - * will rewrite the require to the correct bundle location depending on the layer at which the file is being used. - */ - const resolveNextExternal = (localRes: string) => { - const isExternal = externalPattern.test(localRes) - - // if the file ends with .external, we need to make it a commonjs require in all cases - // this is used mainly to share the async local storage across the routing, rendering and user layers. - if (isExternal) { - // it's important we return the path that starts with `next/dist/` here instead of the absolute path - // otherwise NFT will get tripped up - return `commonjs ${normalizePathSep( - localRes.replace(/.*?next[/\\]dist/, 'next/dist') - )}` - } - } - // Don't bundle @vercel/og nodejs bundle for nodejs runtime. // TODO-APP: bundle route.js with different layer that externals common node_module deps. // Make sure @vercel/og is loaded as ESM for Node.js runtime @@ -288,11 +268,17 @@ export function makeExternalHandler({ // Early return if the request needs to be bundled, such as in the client layer. // Treat react packages and next internals as external for SSR layer, // also map react to builtin ones with require-hook. + // Otherwise keep continue the process to resolve the externals. if (layer === WEBPACK_LAYERS.serverSideRendering) { const isRelative = request.startsWith('.') const fullRequest = isRelative ? normalizePathSep(path.join(context, request)) : request + + // Check if it's opt out bundling package first + if (optOutBundlingPackagesSet.has(fullRequest)) { + return fullRequest + } return resolveNextExternal(fullRequest) } @@ -374,28 +360,85 @@ export function makeExternalHandler({ } } - const shouldBeBundled = - isResourceInPackages( - res, - config.transpilePackages, - resolvedExternalPackageDirs - ) || - (isEsm && isAppLayer) || - (!isAppLayer && config.experimental.bundlePagesExternals) - - if (nodeModulesRegex.test(res)) { - if (isWebpackServerLayer(layer)) { - if (!optOutBundlingPackageRegex.test(res)) { - return // Bundle for server layer - } - return `${externalType} ${request}` // Externalize if opted out - } - - if (!shouldBeBundled || optOutBundlingPackageRegex.test(res)) { - return `${externalType} ${request}` // Externalize if not bundled or opted out - } + const resolvedBundlingOptOutRes = resolveBundlingOptOutPackages({ + resolvedRes: res, + optOutBundlingPackageRegex, + config, + resolvedExternalPackageDirs, + isEsm, + isAppLayer, + layer, + externalType, + request, + }) + if (resolvedBundlingOptOutRes) { + return resolvedBundlingOptOutRes } // if here, we default to bundling the file + return + } +} + +function resolveBundlingOptOutPackages({ + resolvedRes, + optOutBundlingPackageRegex, + config, + resolvedExternalPackageDirs, + isEsm, + isAppLayer, + layer, + externalType, + request, +}: { + resolvedRes: string + optOutBundlingPackageRegex: RegExp + config: NextConfigComplete + resolvedExternalPackageDirs: Map + isEsm: boolean + isAppLayer: boolean + layer: WebpackLayerName | null + externalType: string + request: string +}) { + const shouldBeBundled = + isResourceInPackages( + resolvedRes, + config.transpilePackages, + resolvedExternalPackageDirs + ) || + (isEsm && isAppLayer) || + (!isAppLayer && config.experimental.bundlePagesExternals) + + if (nodeModulesRegex.test(resolvedRes)) { + const isOptOutBundling = optOutBundlingPackageRegex.test(resolvedRes) + if (isWebpackServerLayer(layer)) { + if (isOptOutBundling) { + return `${externalType} ${request}` // Externalize if opted out + } + } else if (!shouldBeBundled || isOptOutBundling) { + return `${externalType} ${request}` // Externalize if not bundled or opted out + } + } +} + +/** + * @param localRes the full path to the file + * @returns the externalized path + * @description returns an externalized path if the file is a Next.js file and ends with either `.shared-runtime.js` or `.external.js` + * This is used to ensure that files used across the rendering runtime(s) and the user code are one and the same. The logic in this function + * will rewrite the require to the correct bundle location depending on the layer at which the file is being used. + */ +function resolveNextExternal(localRes: string) { + const isExternal = externalPattern.test(localRes) + + // if the file ends with .external, we need to make it a commonjs require in all cases + // this is used mainly to share the async local storage across the routing, rendering and user layers. + if (isExternal) { + // it's important we return the path that starts with `next/dist/` here instead of the absolute path + // otherwise NFT will get tripped up + return `commonjs ${normalizePathSep( + localRes.replace(/.*?next[/\\]dist/, 'next/dist') + )}` } } diff --git a/test/e2e/app-dir/server-components-externals/app/client/page.tsx b/test/e2e/app-dir/server-components-externals/app/client/page.tsx new file mode 100644 index 0000000000..2db8ae92c0 --- /dev/null +++ b/test/e2e/app-dir/server-components-externals/app/client/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { dir } from 'external-package' + +export default function Page() { + return
{dir}
+} diff --git a/test/e2e/app-dir/externals/app/layout.tsx b/test/e2e/app-dir/server-components-externals/app/layout.tsx similarity index 100% rename from test/e2e/app-dir/externals/app/layout.tsx rename to test/e2e/app-dir/server-components-externals/app/layout.tsx diff --git a/test/e2e/app-dir/externals/app/page.tsx b/test/e2e/app-dir/server-components-externals/app/page.tsx similarity index 100% rename from test/e2e/app-dir/externals/app/page.tsx rename to test/e2e/app-dir/server-components-externals/app/page.tsx diff --git a/test/e2e/app-dir/externals/app/predefined/page.tsx b/test/e2e/app-dir/server-components-externals/app/predefined/page.tsx similarity index 100% rename from test/e2e/app-dir/externals/app/predefined/page.tsx rename to test/e2e/app-dir/server-components-externals/app/predefined/page.tsx diff --git a/test/e2e/app-dir/externals/externals.test.ts b/test/e2e/app-dir/server-components-externals/index.test.ts similarity index 61% rename from test/e2e/app-dir/externals/externals.test.ts rename to test/e2e/app-dir/server-components-externals/index.test.ts index cd25edbda2..51af2825d0 100644 --- a/test/e2e/app-dir/externals/externals.test.ts +++ b/test/e2e/app-dir/server-components-externals/index.test.ts @@ -2,11 +2,11 @@ import path from 'path' import { createNextDescribe } from 'e2e-utils' createNextDescribe( - 'externals-app', + 'app-dir - server components externals', { files: __dirname, }, - ({ next }) => { + ({ next, isTurbopack }) => { it('should have externals for those in config.experimental.serverComponentsExternalPackages', async () => { const $ = await next.render$('/') @@ -22,5 +22,14 @@ createNextDescribe( const text = $('#directory').text() expect(text).toBe(path.join(next.testDir, 'node_modules', 'sqlite3')) }) + + // Inspect webpack server bundles + if (!isTurbopack) { + it('should externalize serverComponentsExternalPackages for server rendering layer', async () => { + await next.fetch('/client') + const ssrBundle = await next.readFile('.next/server/app/client/page.js') + expect(ssrBundle).not.toContain('external-package-mark') + }) + } } ) diff --git a/test/e2e/app-dir/externals/next.config.js b/test/e2e/app-dir/server-components-externals/next.config.js similarity index 100% rename from test/e2e/app-dir/externals/next.config.js rename to test/e2e/app-dir/server-components-externals/next.config.js diff --git a/test/e2e/app-dir/externals/node_modules/sqlite3/index.js b/test/e2e/app-dir/server-components-externals/node_modules/external-package/index.js similarity index 53% rename from test/e2e/app-dir/externals/node_modules/sqlite3/index.js rename to test/e2e/app-dir/server-components-externals/node_modules/external-package/index.js index b870f5e9ac..1e197596a6 100644 --- a/test/e2e/app-dir/externals/node_modules/sqlite3/index.js +++ b/test/e2e/app-dir/server-components-externals/node_modules/external-package/index.js @@ -1,3 +1,4 @@ module.exports = { dir: __dirname, + value: 'external-package-mark', } diff --git a/test/e2e/app-dir/externals/node_modules/external-package/package.json b/test/e2e/app-dir/server-components-externals/node_modules/external-package/package.json similarity index 100% rename from test/e2e/app-dir/externals/node_modules/external-package/package.json rename to test/e2e/app-dir/server-components-externals/node_modules/external-package/package.json diff --git a/test/e2e/app-dir/externals/node_modules/external-package/index.js b/test/e2e/app-dir/server-components-externals/node_modules/sqlite3/index.js similarity index 100% rename from test/e2e/app-dir/externals/node_modules/external-package/index.js rename to test/e2e/app-dir/server-components-externals/node_modules/sqlite3/index.js diff --git a/test/e2e/app-dir/externals/node_modules/sqlite3/package.json b/test/e2e/app-dir/server-components-externals/node_modules/sqlite3/package.json similarity index 100% rename from test/e2e/app-dir/externals/node_modules/sqlite3/package.json rename to test/e2e/app-dir/server-components-externals/node_modules/sqlite3/package.json