Skip to content

Commit

Permalink
Share resolve logic for trace and externals (#30499)
Browse files Browse the repository at this point in the history
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
ijjk and kodiakhq[bot] committed Oct 28, 2021
1 parent 7902616 commit 82001f2
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 111 deletions.
211 changes: 126 additions & 85 deletions packages/next/build/webpack-config.ts
Expand Up @@ -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,
{
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Expand Up @@ -13,6 +13,7 @@ import {
nextImageLoaderRegex,
NODE_ESM_RESOLVE_OPTIONS,
NODE_RESOLVE_OPTIONS,
resolveExternal,
} from '../../webpack-config'
import { NextConfigComplete } from '../../../server/config-shared'

Expand Down Expand Up @@ -407,7 +408,7 @@ export class TraceEntryPointsPlugin implements webpack5.WebpackPluginInstance {
request: string,
job: import('@vercel/nft/out/node-file-trace').Job
) =>
new Promise<string>((resolve, reject) => {
new Promise<[string, boolean]>((resolve, reject) => {
const context = nodePath.dirname(parent)

curResolver.resolve(
Expand All @@ -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) {
Expand Down Expand Up @@ -472,22 +473,32 @@ 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'])
}
)
})
}

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,
Expand All @@ -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}`)
Expand Down
16 changes: 14 additions & 2 deletions test/integration/production/test/index.test.js
Expand Up @@ -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)
}
}
Expand Down

0 comments on commit 82001f2

Please sign in to comment.