diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 0a2884603d9b..e0dc11443268 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -221,6 +221,93 @@ let TSCONFIG_WARNED = false export const nextImageLoaderRegex = /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$/i +export async function resolveExternal( + appDir: string, + esmExternalsConfig: NextConfigComplete['experimental']['esmExternals'], + context: string, + request: string, + isEsmRequested: boolean, + getResolve: ( + options: any + ) => ( + resolveContext: string, + resolveRequest: string + ) => Promise<[string | null, boolean]>, + isLocalCallback?: (res: string) => any, + baseResolveCheck = true, + esmResolveOptions: any = NODE_ESM_RESOLVE_OPTIONS, + nodeResolveOptions: any = NODE_RESOLVE_OPTIONS, + baseEsmResolveOptions: any = NODE_BASE_ESM_RESOLVE_OPTIONS, + baseResolveOptions: any = NODE_BASE_RESOLVE_OPTIONS +) { + const esmExternals = !!esmExternalsConfig + const looseEsmExternals = esmExternalsConfig === 'loose' + + let res: string | null = null + let isEsm: boolean = false + + let preferEsmOptions = + esmExternals && isEsmRequested ? [true, false] : [false] + for (const preferEsm of preferEsmOptions) { + const resolve = getResolve( + preferEsm ? esmResolveOptions : nodeResolveOptions + ) + + // Resolve the import with the webpack provided context, this + // ensures we're resolving the correct version when multiple + // exist. + try { + ;[res, isEsm] = await resolve(context, request) + } catch (err) { + res = null + } + + if (!res) { + continue + } + + // ESM externals can only be imported (and not required). + // Make an exception in loose mode. + if (!isEsmRequested && isEsm && !looseEsmExternals) { + continue + } + + if (isLocalCallback) { + return { localRes: isLocalCallback(res) } + } + + // Bundled Node.js code is relocated without its node_modules tree. + // This means we need to make sure its request resolves to the same + // package that'll be available at runtime. If it's not identical, + // we need to bundle the code (even if it _should_ be external). + if (baseResolveCheck) { + let baseRes: string | null + let baseIsEsm: boolean + try { + const baseResolve = getResolve( + isEsm ? baseEsmResolveOptions : baseResolveOptions + ) + ;[baseRes, baseIsEsm] = await baseResolve(appDir, request) + } catch (err) { + baseRes = null + baseIsEsm = false + } + + // Same as above: if the package, when required from the root, + // would be different from what the real resolution would use, we + // cannot externalize it. + // if request is pointing to a symlink it could point to the the same file, + // the resolver will resolve symlinks so this is handled + if (baseRes !== res || isEsm !== baseIsEsm) { + res = null + continue + } + } + break + } + return { res, isEsm } +} + export default async function getBaseWebpackConfig( dir: string, { @@ -695,8 +782,6 @@ export default async function getBaseWebpackConfig( } const crossOrigin = config.crossOrigin - - const esmExternals = !!config.experimental?.esmExternals const looseEsmExternals = config.experimental?.esmExternals === 'loose' async function handleExternals( @@ -712,7 +797,6 @@ export default async function getBaseWebpackConfig( ) { // We need to externalize internal requests for files intended to // not be bundled. - const isLocal: boolean = request.startsWith('.') || // Always check for unix-style path, as webpack sometimes @@ -742,94 +826,51 @@ export default async function getBaseWebpackConfig( // ESM resolving options. const isEsmRequested = dependencyType === 'esm' - let res: string | null = null - let isEsm: boolean = false - - let preferEsmOptions = - esmExternals && isEsmRequested ? [true, false] : [false] - for (const preferEsm of preferEsmOptions) { - const resolve = getResolve( - preferEsm ? NODE_ESM_RESOLVE_OPTIONS : NODE_RESOLVE_OPTIONS - ) - - // Resolve the import with the webpack provided context, this - // ensures we're resolving the correct version when multiple - // exist. - try { - ;[res, isEsm] = await resolve(context, request) - } catch (err) { - res = null - } - - if (!res) { - continue - } - - // ESM externals can only be imported (and not required). - // Make an exception in loose mode. - if (!isEsmRequested && isEsm && !looseEsmExternals) { - continue - } - - if (isLocal) { - // Makes sure dist/shared and dist/server are not bundled - // we need to process shared `router/router` and `dynamic`, - // so that the DefinePlugin can inject process.env values - const isNextExternal = - /next[/\\]dist[/\\](shared|server)[/\\](?!lib[/\\](router[/\\]router|dynamic))/.test( - res - ) - - if (isNextExternal) { - // Generate Next.js external import - const externalRequest = path.posix.join( - 'next', - 'dist', - path - .relative( - // Root of Next.js package: - path.join(__dirname, '..'), - res - ) - // Windows path normalization - .replace(/\\/g, '/') - ) - return `commonjs ${externalRequest}` - } else { - // We don't want to retry local requests - // with other preferEsm options - return - } - } + const isLocalCallback = (localRes: string) => { + // Makes sure dist/shared and dist/server are not bundled + // we need to process shared `router/router` and `dynamic`, + // so that the DefinePlugin can inject process.env values + const isNextExternal = + /next[/\\]dist[/\\](shared|server)[/\\](?!lib[/\\](router[/\\]router|dynamic))/.test( + localRes + ) - // Bundled Node.js code is relocated without its node_modules tree. - // This means we need to make sure its request resolves to the same - // package that'll be available at runtime. If it's not identical, - // we need to bundle the code (even if it _should_ be external). - let baseRes: string | null - let baseIsEsm: boolean - try { - const baseResolve = getResolve( - isEsm ? NODE_BASE_ESM_RESOLVE_OPTIONS : NODE_BASE_RESOLVE_OPTIONS + if (isNextExternal) { + // Generate Next.js external import + const externalRequest = path.posix.join( + 'next', + 'dist', + path + .relative( + // Root of Next.js package: + path.join(__dirname, '..'), + localRes + ) + // Windows path normalization + .replace(/\\/g, '/') ) - ;[baseRes, baseIsEsm] = await baseResolve(dir, request) - } catch (err) { - baseRes = null - baseIsEsm = false + return `commonjs ${externalRequest}` + } else { + // We don't want to retry local requests + // with other preferEsm options + return } + } - // Same as above: if the package, when required from the root, - // would be different from what the real resolution would use, we - // cannot externalize it. - // if request is pointing to a symlink it could point to the the same file, - // the resolver will resolve symlinks so this is handled - if (baseRes !== res || isEsm !== baseIsEsm) { - res = null - continue - } + const resolveResult = await resolveExternal( + dir, + config.experimental.esmExternals, + context, + request, + isEsmRequested, + getResolve, + isLocal ? isLocalCallback : undefined + ) - break + if ('localRes' in resolveResult) { + return resolveResult.localRes } + const { res, isEsm } = resolveResult // If the request cannot be resolved we need to have // webpack "bundle" it so it surfaces the not found error. diff --git a/packages/next/build/webpack/plugins/next-trace-entrypoints-plugin.ts b/packages/next/build/webpack/plugins/next-trace-entrypoints-plugin.ts index f04f46f24add..73ada1b583b5 100644 --- a/packages/next/build/webpack/plugins/next-trace-entrypoints-plugin.ts +++ b/packages/next/build/webpack/plugins/next-trace-entrypoints-plugin.ts @@ -13,6 +13,7 @@ import { nextImageLoaderRegex, NODE_ESM_RESOLVE_OPTIONS, NODE_RESOLVE_OPTIONS, + resolveExternal, } from '../../webpack-config' import { NextConfigComplete } from '../../../server/config-shared' @@ -407,7 +408,7 @@ export class TraceEntryPointsPlugin implements webpack5.WebpackPluginInstance { request: string, job: import('@vercel/nft/out/node-file-trace').Job ) => - new Promise((resolve, reject) => { + new Promise<[string, boolean]>((resolve, reject) => { const context = nodePath.dirname(parent) curResolver.resolve( @@ -419,7 +420,7 @@ export class TraceEntryPointsPlugin implements webpack5.WebpackPluginInstance { missingDependencies: compilation.missingDependencies, contextDependencies: compilation.contextDependencies, }, - async (err: any, result?: string | false, resContext?: any) => { + async (err: any, result?, resContext?) => { if (err) return reject(err) if (!result) { @@ -472,7 +473,7 @@ export class TraceEntryPointsPlugin implements webpack5.WebpackPluginInstance { // we failed to resolve the package.json boundary, // we don't block emitting the initial asset from this } - resolve(result) + resolve([result, options.dependencyType === 'esm']) } ) }) @@ -480,14 +481,24 @@ export class TraceEntryPointsPlugin implements webpack5.WebpackPluginInstance { const CJS_RESOLVE_OPTIONS = { ...NODE_RESOLVE_OPTIONS, + fullySpecified: undefined, modules: undefined, extensions: undefined, } + const BASE_CJS_RESOLVE_OPTIONS = { + ...CJS_RESOLVE_OPTIONS, + alias: false, + } const ESM_RESOLVE_OPTIONS = { ...NODE_ESM_RESOLVE_OPTIONS, + fullySpecified: undefined, modules: undefined, extensions: undefined, } + const BASE_ESM_RESOLVE_OPTIONS = { + ...ESM_RESOLVE_OPTIONS, + alias: false, + } const doResolve = async ( request: string, @@ -500,30 +511,25 @@ export class TraceEntryPointsPlugin implements webpack5.WebpackPluginInstance { `not resolving ${request} as this is handled by next-image-loader` ) } + const context = nodePath.dirname(parent) // When in esm externals mode, and using import, we resolve with // ESM resolving options. - const esmExternals = this.esmExternals - const looseEsmExternals = this.esmExternals === 'loose' - const preferEsm = esmExternals && isEsmRequested - const resolve = getResolve( - preferEsm ? ESM_RESOLVE_OPTIONS : CJS_RESOLVE_OPTIONS + const { res } = await resolveExternal( + this.appDir, + this.esmExternals, + context, + request, + isEsmRequested, + (options) => (_: string, resRequest: string) => { + return getResolve(options)(parent, resRequest, job) + }, + undefined, + undefined, + ESM_RESOLVE_OPTIONS, + CJS_RESOLVE_OPTIONS, + BASE_ESM_RESOLVE_OPTIONS, + BASE_CJS_RESOLVE_OPTIONS ) - // Resolve the import with the webpack provided context, this - // ensures we're resolving the correct version when multiple - // exist. - let res: string = '' - try { - res = await resolve(parent, request, job) - } catch (_) {} - - // If resolving fails, and we can use an alternative way - // try the alternative resolving options. - if (!res && (isEsmRequested || looseEsmExternals)) { - const resolveAlternative = getResolve( - preferEsm ? CJS_RESOLVE_OPTIONS : ESM_RESOLVE_OPTIONS - ) - res = await resolveAlternative(parent, request, job) - } if (!res) { throw new Error(`failed to resolve ${request} from ${parent}`) diff --git a/test/integration/production/test/index.test.js b/test/integration/production/test/index.test.js index 18cebda37c6d..ad9b0689adc5 100644 --- a/test/integration/production/test/index.test.js +++ b/test/integration/production/test/index.test.js @@ -217,12 +217,24 @@ describe('Production Usage', () => { expect(version).toBe(1) expect( - check.tests.every((item) => files.some((file) => item.test(file))) + check.tests.every((item) => { + if (files.some((file) => item.test(file))) { + return true + } + console.error(`Failed to find ${item} in`, files) + return false + }) ).toBe(true) if (sep === '/') { expect( - check.notTests.some((item) => files.some((file) => item.test(file))) + check.notTests.some((item) => { + if (files.some((file) => item.test(file))) { + console.error(`Found unexpected ${item} in`, files) + return true + } + return false + }) ).toBe(false) } }