Repository: netlify/zip-it-and-ship-it
Pull request #302 (2021-01-20 12:26:57)

feat: allow to configure Node.js resolve paths
An initial attempt at fixing https://github.com/netlify/pod-the-builder/issues/116

Probably needs some cleanup and more testing, but would like to get some feedback on the approach.

~~We can also just resolve from `.netlify/plugins/node_modules` instead of having a configurable array (since that should be the only `external` additional path).
Also, we could provide a default `nodeResolvePaths` value so consumers don't have to add `.netlify/plugins/node_modules` manually.~~

Updated the PR to look for `.netlify/plugins/node_modules` up from the `srcDir` instead of it being configurable.

## TODO
- [x] More testing - specifically with the Next.js plugin version that created the `jimp` [issue](https://github.com/netlify/netlify-plugin-nextjs/issues/85)
- [x] ~~Support configuring `nodeResolvePaths` argument in the CLI~~
- [x] ~~Update docs~~

erezrokah: FYI this works. See image loaded in https://60097eb6ee8655dae9885441--erez-test-next-bundling.netlify.app/test.
That site was create by forcing `@netlify/build` to use version `1.0.6` of the plugin and telling `zisi` to resolve from `.netlify/plugins/node_modules`.

I also had to fix this line
https://github.com/netlify/next-on-netlify/blob/b4c85b9544e3608dda6b9e623c970d5f8c0ed50c/lib/templates/imageFunction.js#L10
in the generated function since `process.env.URL` gives you the URL of the main site so it didn't work for deploy previews.

I'm getting the host from the event request `event.headers.host` and using that to construct the image URL. We probably want to use `DEPLOY_URL` (see https://docs.netlify.com/configure-builds/environment-variables/#deploy-urls-and-metadata), but since I'm testing locally that didn't work for me.
erezrokah: Following an internal discussion updated the PR so it doesn't require changes in `@netlify/build` or `js-client`.

Patch:
diff --git a/src/main.js b/src/main.js
index 96699d7..35d59c7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,7 @@
 const { join, basename, extname } = require('path')
 
 const cpFile = require('cp-file')
+const findUp = require('find-up')
 const makeDir = require('make-dir')
 const pMap = require('p-map')
 
@@ -9,6 +10,10 @@ const { listNodeFiles } = require('./node_dependencies')
 const { zipBinary } = require('./runtime')
 const { zipNodeJs } = require('./zip_node')
 
+const AUTO_PLUGINS_DIR = '.netlify/plugins/'
+
+const getPluginsModulesPath = (srcDir) => findUp(`${AUTO_PLUGINS_DIR}node_modules`, { cwd: srcDir, type: 'directory' })
+
 // Zip `srcFolder/*` (Node.js or Go files) to `destFolder/*.zip` so it can be
 // used by AWS Lambda
 // TODO: remove `skipGo` option in next major release
@@ -17,24 +22,34 @@ const zipFunctions = async function (
   destFolder,
   { parallelLimit = DEFAULT_PARALLEL_LIMIT, skipGo, zipGo } = {},
 ) {
-  const srcPaths = await getSrcPaths(srcFolder)
-
-  const zipped = await pMap(srcPaths, (srcPath) => zipFunction(srcPath, destFolder, { skipGo, zipGo }), {
-    concurrency: parallelLimit,
-  })
+  const [srcPaths, pluginsModulesPath] = await Promise.all([getSrcPaths(srcFolder), getPluginsModulesPath(srcFolder)])
+
+  const zipped = await pMap(
+    srcPaths,
+    (srcPath) => zipFunction(srcPath, destFolder, { skipGo, zipGo, pluginsModulesPath }),
+    {
+      concurrency: parallelLimit,
+    },
+  )
   return zipped.filter(Boolean)
 }
 
 const DEFAULT_PARALLEL_LIMIT = 5
 
-const zipFunction = async function (srcPath, destFolder, { skipGo = true, zipGo = !skipGo } = {}) {
+const zipFunction = async function (
+  srcPath,
+  destFolder,
+  { skipGo = true, zipGo = !skipGo, pluginsModulesPath: defaultModulesPath } = {},
+) {
   const { runtime, filename, extension, srcDir, stat, mainFile } = await getFunctionInfo(srcPath)
 
   if (runtime === undefined) {
     return
   }
 
-  const srcFiles = await getSrcFiles({ runtime, stat, mainFile, extension, srcPath, srcDir })
+  const pluginsModulesPath =
+    defaultModulesPath === undefined ? await getPluginsModulesPath(srcPath) : defaultModulesPath
+  const srcFiles = await getSrcFiles({ runtime, stat, mainFile, extension, srcPath, srcDir, pluginsModulesPath })
 
   await makeDir(destFolder)
 
@@ -48,11 +63,20 @@ const zipFunction = async function (srcPath, destFolder, { skipGo = true, zipGo
     stat,
     zipGo,
     runtime,
+    pluginsModulesPath,
   })
   return { path: destPath, runtime }
 }
 
-const zipJsFunction = async function ({ srcPath, destFolder, mainFile, filename, extension, srcFiles }) {
+const zipJsFunction = async function ({
+  srcPath,
+  destFolder,
+  mainFile,
+  filename,
+  extension,
+  srcFiles,
+  pluginsModulesPath,
+}) {
   if (extension === '.zip') {
     const destPath = join(destFolder, filename)
     await cpFile(srcPath, destPath)
@@ -60,7 +84,7 @@ const zipJsFunction = async function ({ srcPath, destFolder, mainFile, filename,
   }
 
   const destPath = join(destFolder, `${basename(filename, '.js')}.zip`)
-  await zipNodeJs(srcFiles, destPath, filename, mainFile)
+  await zipNodeJs({ srcFiles, destPath, filename, mainFile, pluginsModulesPath })
   return destPath
 }
 
@@ -102,8 +126,13 @@ const listFunctions = async function (srcFolder) {
 
 // List all Netlify Functions files for a specific directory
 const listFunctionsFiles = async function (srcFolder) {
-  const functionInfos = await getFunctionInfos(srcFolder)
-  const listedFunctionsFiles = await Promise.all(functionInfos.map(getListedFunctionFiles))
+  const [functionInfos, pluginsModulesPath] = await Promise.all([
+    getFunctionInfos(srcFolder),
+    getPluginsModulesPath(srcFolder),
+  ])
+  const listedFunctionsFiles = await Promise.all(
+    functionInfos.map((info) => getListedFunctionFiles(info, { pluginsModulesPath })),
+  )
   return [].concat(...listedFunctionsFiles)
 }
 
@@ -111,14 +140,25 @@ const getListedFunction = function ({ runtime, name, mainFile, extension }) {
   return { name, mainFile, runtime, extension }
 }
 
-const getListedFunctionFiles = async function ({ runtime, name, stat, mainFile, extension, srcPath, srcDir }) {
-  const srcFiles = await getSrcFiles({ runtime, stat, mainFile, extension, srcPath, srcDir })
+const getListedFunctionFiles = async function (
+  { runtime, name, stat, mainFile, extension, srcPath, srcDir },
+  { pluginsModulesPath },
+) {
+  const srcFiles = await getSrcFiles({
+    runtime,
+    stat,
+    mainFile,
+    extension,
+    srcPath,
+    srcDir,
+    pluginsModulesPath,
+  })
   return srcFiles.map((srcFile) => ({ srcFile, name, mainFile, runtime, extension: extname(srcFile) }))
 }
 
-const getSrcFiles = function ({ runtime, stat, mainFile, extension, srcPath, srcDir }) {
+const getSrcFiles = function ({ runtime, stat, mainFile, extension, srcPath, srcDir, pluginsModulesPath }) {
   if (runtime === 'js' && extension === '.js') {
-    return listNodeFiles(srcPath, mainFile, srcDir, stat)
+    return listNodeFiles({ srcPath, mainFile, srcDir, stat, pluginsModulesPath })
   }
 
   return [srcPath]
diff --git a/src/node_dependencies/index.js b/src/node_dependencies/index.js
index a229bec..b3d9c71 100644
--- a/src/node_dependencies/index.js
+++ b/src/node_dependencies/index.js
@@ -1,7 +1,5 @@
 const { dirname, basename, normalize } = require('path')
-const { promisify } = require('util')
 
-const glob = require('glob')
 const { not: notJunk } = require('junk')
 const precinct = require('precinct')
 const requirePackageName = require('require-package-name')
@@ -10,14 +8,17 @@ const { getNestedDependencies, handleModuleNotFound } = require('./nested')
 const { getPackageJson } = require('./package_json')
 const { getPublishedFiles } = require('./published')
 const { resolvePathPreserveSymlinks, resolvePackage } = require('./resolve')
-
-const pGlob = promisify(glob)
+const { getSideFiles } = require('./side_files')
+const { getTreeFiles } = require('./tree_files')
 
 // Retrieve the paths to the Node.js files to zip.
 // We only include the files actually needed by the function because AWS Lambda
 // has a size limit for the zipped file. It also makes cold starts faster.
-const listNodeFiles = async function (srcPath, mainFile, srcDir, stat) {
-  const [treeFiles, depFiles] = await Promise.all([getTreeFiles(srcPath, stat), getDependencies(mainFile, srcDir)])
+const listNodeFiles = async function ({ srcPath, mainFile, srcDir, stat, pluginsModulesPath }) {
+  const [treeFiles, depFiles] = await Promise.all([
+    getTreeFiles(srcPath, stat),
+    getDependencies(mainFile, srcDir, pluginsModulesPath),
+  ])
   const files = [...treeFiles, ...depFiles].map(normalize)
   const uniqueFiles = [...new Set(files)]
 
@@ -28,39 +29,26 @@ const listNodeFiles = async function (srcPath, mainFile, srcDir, stat) {
   return filteredFiles
 }
 
-// When using a directory, we include all its descendants except `node_modules`
-const getTreeFiles = function (srcPath, stat) {
-  if (!stat.isDirectory()) {
-    return [srcPath]
-  }
-
-  return pGlob(`${srcPath}/**`, {
-    ignore: `${srcPath}/**/node_modules/**`,
-    nodir: true,
-    absolute: true,
-  })
-}
-
 // Remove temporary files like *~, *.swp, etc.
 const isNotJunk = function (file) {
   return notJunk(basename(file))
 }
 
 // Retrieve all the files recursively required by a Node.js file
-const getDependencies = async function (mainFile, srcDir) {
+const getDependencies = async function (mainFile, srcDir, pluginsModulesPath) {
   const packageJson = await getPackageJson(srcDir)
 
   const state = { localFiles: new Set(), modulePaths: new Set() }
 
   try {
-    return await getFileDependencies({ path: mainFile, packageJson, state })
+    return await getFileDependencies({ path: mainFile, packageJson, state, pluginsModulesPath })
   } catch (error) {
     error.message = `In file "${mainFile}"\n${error.message}`
     throw error
   }
 }
 
-const getFileDependencies = async function ({ path, packageJson, state, treeShakeNext }) {
+const getFileDependencies = async function ({ path, packageJson, state, treeShakeNext, pluginsModulesPath }) {
   if (state.localFiles.has(path)) {
     return []
   }
@@ -74,18 +62,34 @@ const getFileDependencies = async function ({ path, packageJson, state, treeShak
   const dependencies = precinct.paperwork(path, { includeCore: false })
 
   const depsPaths = await Promise.all(
-    dependencies.map((dependency) => getImportDependencies({ dependency, basedir, packageJson, state, treeShakeNext })),
+    dependencies.map((dependency) =>
+      getImportDependencies({ dependency, basedir, packageJson, state, treeShakeNext, pluginsModulesPath }),
+    ),
   )
   return [].concat(...depsPaths)
 }
 
-const getImportDependencies = function ({ dependency, basedir, packageJson, state, treeShakeNext }) {
+const getImportDependencies = function ({
+  dependency,
+  basedir,
+  packageJson,
+  state,
+  treeShakeNext,
+  pluginsModulesPath,
+}) {
   const shouldTreeShakeNext = treeShakeNext || isNextOnNetlify(dependency)
   if (shouldTreeShake(dependency, shouldTreeShakeNext)) {
-    return getTreeShakedDependencies({ dependency, basedir, packageJson, state, treeShakeNext: shouldTreeShakeNext })
+    return getTreeShakedDependencies({
+      dependency,
+      basedir,
+      packageJson,
+      state,
+      treeShakeNext: shouldTreeShakeNext,
+      pluginsModulesPath,
+    })
   }
 
-  return getAllDependencies({ dependency, basedir, state, packageJson })
+  return getAllDependencies({ dependency, basedir, state, packageJson, pluginsModulesPath })
 }
 
 const isNextOnNetlify = function (dependency) {
@@ -108,15 +112,22 @@ const shouldTreeShake = function (dependency, treeShakeNext) {
 const LOCAL_IMPORT_REGEXP = /^(\.|\/)/
 
 // When a file requires another one, we apply the top-level logic recursively
-const getTreeShakedDependencies = async function ({ dependency, basedir, packageJson, state, treeShakeNext }) {
-  const path = await resolvePathPreserveSymlinks(dependency, basedir)
-  const depsPath = await getFileDependencies({ path, packageJson, state, treeShakeNext })
+const getTreeShakedDependencies = async function ({
+  dependency,
+  basedir,
+  packageJson,
+  state,
+  treeShakeNext,
+  pluginsModulesPath,
+}) {
+  const path = await resolvePathPreserveSymlinks(dependency, [basedir, pluginsModulesPath].filter(Boolean))
+  const depsPath = await getFileDependencies({ path, packageJson, state, treeShakeNext, pluginsModulesPath })
   return [path, ...depsPath]
 }
 
 // When a file requires a module, we find its path inside `node_modules` and
 // use all its published files. We also recurse on the module's dependencies.
-const getAllDependencies = async function ({ dependency, basedir, state, packageJson }) {
+const getAllDependencies = async function ({ dependency, basedir, state, packageJson, pluginsModulesPath }) {
   const moduleName = getModuleName(dependency)
 
   // Happens when doing require("@scope") (not "@scope/name") or other oddities
@@ -126,7 +137,7 @@ const getAllDependencies = async function ({ dependency, basedir, state, package
   }
 
   try {
-    return await getModuleNameDependencies(moduleName, basedir, state)
+    return await getModuleNameDependencies({ moduleName, basedir, state, pluginsModulesPath })
   } catch (error) {
     return handleModuleNotFound({ error, moduleName, packageJson })
   }
@@ -142,13 +153,13 @@ const getModuleName = function (dependency) {
 // Windows path normalization
 const BACKSLASH_REGEXP = /\\/g
 
-const getModuleNameDependencies = async function (moduleName, basedir, state) {
+const getModuleNameDependencies = async function ({ moduleName, basedir, state, pluginsModulesPath }) {
   if (isExcludedModule(moduleName)) {
     return []
   }
 
   // Find the Node.js module directory path
-  const packagePath = await resolvePackage(moduleName, basedir)
+  const packagePath = await resolvePackage(moduleName, [basedir, pluginsModulesPath].filter(Boolean))
 
   if (packagePath === undefined) {
     return []
@@ -169,7 +180,7 @@ const getModuleNameDependencies = async function (moduleName, basedir, state) {
   const [publishedFiles, sideFiles, depsPaths] = await Promise.all([
     getPublishedFiles(modulePath),
     getSideFiles(modulePath, moduleName),
-    getNestedModules(modulePath, state, packageJson),
+    getNestedModules({ modulePath, state, packageJson, pluginsModulesPath }),
   ])
   return [...publishedFiles, ...sideFiles, ...depsPaths]
 }
@@ -179,26 +190,13 @@ const isExcludedModule = function (moduleName) {
 }
 const EXCLUDED_MODULES = new Set(['aws-sdk'])
 
-// Some modules generate source files on `postinstall` that are not located
-// inside the module's directory itself.
-const getSideFiles = function (modulePath, moduleName) {
-  const sideFiles = SIDE_FILES[moduleName]
-  if (sideFiles === undefined) {
-    return []
-  }
-
-  return getPublishedFiles(`${modulePath}/${sideFiles}`)
-}
-
-const SIDE_FILES = {
-  '@prisma/client': '../../.prisma',
-}
-
-const getNestedModules = async function (modulePath, state, packageJson) {
+const getNestedModules = async function ({ modulePath, state, packageJson, pluginsModulesPath }) {
   const dependencies = getNestedDependencies(packageJson)
 
   const depsPaths = await Promise.all(
-    dependencies.map((dependency) => getAllDependencies({ dependency, basedir: modulePath, state, packageJson })),
+    dependencies.map((dependency) =>
+      getAllDependencies({ dependency, basedir: modulePath, state, packageJson, pluginsModulesPath }),
+    ),
   )
   return [].concat(...depsPaths)
 }
diff --git a/src/node_dependencies/resolve.js b/src/node_dependencies/resolve.js
index 6f39b52..f2b2d81 100644
--- a/src/node_dependencies/resolve.js
+++ b/src/node_dependencies/resolve.js
@@ -19,18 +19,18 @@ const { lt: ltVersion } = require('semver')
 // However it does not give helpful error messages.
 //   https://github.com/browserify/resolve/issues/223
 // So, on errors, we fallback to `require.resolve()`
-const resolvePackage = async function (moduleName, basedir) {
+const resolvePackage = async function (moduleName, baseDirs) {
   try {
-    return await resolvePathPreserveSymlinks(`${moduleName}/package.json`, basedir)
+    return await resolvePathPreserveSymlinks(`${moduleName}/package.json`, baseDirs)
   } catch (error) {
     if (ltVersion(nodeVersion, REQUEST_RESOLVE_MIN_VERSION)) {
       throw error
     }
 
     try {
-      return resolvePathFollowSymlinks(`${moduleName}/package.json`, basedir)
+      return resolvePathFollowSymlinks(`${moduleName}/package.json`, baseDirs)
     } catch (error_) {
-      return await resolvePackageFallback(moduleName, basedir, error_)
+      return await resolvePackageFallback(moduleName, baseDirs, error_)
     }
   }
 }
@@ -42,7 +42,7 @@ const REQUEST_RESOLVE_MIN_VERSION = '8.9.0'
 // We need to use `new Promise()` due to a bug with `utils.promisify()` on
 // `resolve`:
 //   https://github.com/browserify/resolve/issues/151#issuecomment-368210310
-const resolvePathPreserveSymlinks = function (path, basedir) {
+const resolvePathPreserveSymlinksForDir = function (path, basedir) {
   return new Promise((resolve, reject) => {
     resolveLib(path, { basedir, preserveSymlinks: true }, (error, resolvedLocation) => {
       if (error) {
@@ -54,8 +54,28 @@ const resolvePathPreserveSymlinks = function (path, basedir) {
   })
 }
 
-const resolvePathFollowSymlinks = function (path, basedir) {
-  return require.resolve(path, { paths: [basedir] })
+// the resolve library has a `paths` option but it's not the same as multiple basedirs
+// see https://github.com/browserify/resolve/issues/188#issuecomment-679010477
+// we return the first resolved location or the first error if all failed
+const resolvePathPreserveSymlinks = async function (path, baseDirs) {
+  // eslint-disable-next-line fp/no-let
+  let firstError
+  // eslint-disable-next-line fp/no-loops
+  for (const basedir of baseDirs) {
+    try {
+      // eslint-disable-next-line no-await-in-loop
+      return await resolvePathPreserveSymlinksForDir(path, basedir)
+    } catch (error) {
+      // eslint-disable-next-line fp/no-mutation
+      firstError = firstError || error
+    }
+  }
+
+  throw firstError
+}
+
+const resolvePathFollowSymlinks = function (path, baseDirs) {
+  return require.resolve(path, { paths: baseDirs })
 }
 
 // `require.resolve()` on a module's specific file (like `package.json`)
@@ -64,10 +84,10 @@ const resolvePathFollowSymlinks = function (path, basedir) {
 // It looks for the first directory up from a package's `main` file that:
 //   - is named like the package
 //   - has a `package.json`
-// Theoritically, this might not the root `package.json`, but this is very
+// Theoretically, this might not the root `package.json`, but this is very
 // unlikely, and we don't have any better alternative.
-const resolvePackageFallback = async function (moduleName, basedir, error) {
-  const mainFilePath = resolvePathFollowSymlinks(moduleName, basedir)
+const resolvePackageFallback = async function (moduleName, baseDirs, error) {
+  const mainFilePath = resolvePathFollowSymlinks(moduleName, baseDirs)
   const packagePath = await findUp(isPackageDir.bind(null, moduleName), { cwd: mainFilePath, type: 'directory' })
   if (packagePath === undefined) {
     throw error
diff --git a/src/node_dependencies/side_files.js b/src/node_dependencies/side_files.js
new file mode 100644
index 0000000..f4d12ea
--- /dev/null
+++ b/src/node_dependencies/side_files.js
@@ -0,0 +1,18 @@
+const { getPublishedFiles } = require('./published')
+
+// Some modules generate source files on `postinstall` that are not located
+// inside the module's directory itself.
+const getSideFiles = function (modulePath, moduleName) {
+  const sideFiles = SIDE_FILES[moduleName]
+  if (sideFiles === undefined) {
+    return []
+  }
+
+  return getPublishedFiles(`${modulePath}/${sideFiles}`)
+}
+
+const SIDE_FILES = {
+  '@prisma/client': '../../.prisma',
+}
+
+module.exports = { getSideFiles }
diff --git a/src/node_dependencies/tree_files.js b/src/node_dependencies/tree_files.js
new file mode 100644
index 0000000..220be7e
--- /dev/null
+++ b/src/node_dependencies/tree_files.js
@@ -0,0 +1,20 @@
+const { promisify } = require('util')
+
+const glob = require('glob')
+
+const pGlob = promisify(glob)
+
+// When using a directory, we include all its descendants except `node_modules`
+const getTreeFiles = function (srcPath, stat) {
+  if (!stat.isDirectory()) {
+    return [srcPath]
+  }
+
+  return pGlob(`${srcPath}/**`, {
+    ignore: `${srcPath}/**/node_modules/**`,
+    nodir: true,
+    absolute: true,
+  })
+}
+
+module.exports = { getTreeFiles }
diff --git a/src/zip_node.js b/src/zip_node.js
index eedcde9..2fe4837 100644
--- a/src/zip_node.js
+++ b/src/zip_node.js
@@ -11,7 +11,7 @@ const { startZip, addZipFile, addZipContent, endZip } = require('./archive')
 const pStat = promisify(fs.stat)
 
 // Zip a Node.js function file
-const zipNodeJs = async function (srcFiles, destPath, filename, mainFile) {
+const zipNodeJs = async function ({ srcFiles, destPath, filename, mainFile, pluginsModulesPath }) {
   const { archive, output } = startZip(destPath)
 
   const dirnames = srcFiles.map(dirname)
@@ -24,7 +24,7 @@ const zipNodeJs = async function (srcFiles, destPath, filename, mainFile) {
   // We ensure this is not async, so that the archive's checksum is
   // deterministic. Otherwise it depends on the order the files were added.
   srcFilesInfos.forEach(({ srcFile, stat }) => {
-    zipJsFile({ srcFile, commonPrefix, archive, stat })
+    zipJsFile({ srcFile, commonPrefix, pluginsModulesPath, archive, stat })
   })
 
   await endZip(archive, output)
@@ -43,19 +43,21 @@ const addStat = async function (srcFile) {
   return { srcFile, stat }
 }
 
-const zipJsFile = function ({ srcFile, commonPrefix, archive, stat }) {
-  const filename = normalizeFilePath(srcFile, commonPrefix)
+const zipJsFile = function ({ srcFile, commonPrefix, pluginsModulesPath, archive, stat }) {
+  const filename = normalizeFilePath(srcFile, commonPrefix, pluginsModulesPath)
   addZipFile(archive, srcFile, filename, stat)
 }
 
 // `adm-zip` and `require()` expect Unix paths.
 // We remove the common path prefix.
 // With files on different Windows drives, we remove the drive letter.
-const normalizeFilePath = function (path, commonPrefix) {
+const normalizeFilePath = function (path, commonPrefix, pluginsModulesPath) {
   const pathA = normalize(path)
-  const pathB = pathA.replace(commonPrefix, `${ZIP_ROOT_DIR}${sep}`)
-  const pathC = unixify(pathB)
-  return pathC
+  const pathB =
+    pluginsModulesPath === undefined ? pathA : pathA.replace(pluginsModulesPath, `${ZIP_ROOT_DIR}${sep}node_modules`)
+  const pathC = pathB.replace(commonPrefix, `${ZIP_ROOT_DIR}${sep}`)
+  const pathD = unixify(pathC)
+  return pathD
 }
 
 const ZIP_ROOT_DIR = 'src'


Test patch:
diff --git a/tests/fixtures/node-module-next-image/.netlify/plugins/node_modules/next/image.js b/tests/fixtures/node-module-next-image/.netlify/plugins/node_modules/next/image.js
new file mode 100644
index 0000000..33c1891
--- /dev/null
+++ b/tests/fixtures/node-module-next-image/.netlify/plugins/node_modules/next/image.js
@@ -0,0 +1,1 @@
+module.exports = true
diff --git a/tests/fixtures/node-module-next-image/function/function.js b/tests/fixtures/node-module-next-image/function/function.js
new file mode 100644
index 0000000..41d8840
--- /dev/null
+++ b/tests/fixtures/node-module-next-image/function/function.js
@@ -0,0 +1,4 @@
+// eslint-disable-next-line import/no-unassigned-import
+require('./renderNextPage')
+
+module.exports = true
diff --git a/tests/fixtures/node-module-next-image/function/renderNextPage.js b/tests/fixtures/node-module-next-image/function/renderNextPage.js
new file mode 100644
index 0000000..06ce292
--- /dev/null
+++ b/tests/fixtures/node-module-next-image/function/renderNextPage.js
@@ -0,0 +1,5 @@
+/* eslint-disable unicorn/filename-case, import/no-unassigned-import, import/no-unresolved */
+require('next/image')
+
+module.exports = true
+/* eslint-enable unicorn/filename-case, import/no-unassigned-import, import/no-unresolved */
diff --git a/tests/fixtures/node-module-next-image/package.json b/tests/fixtures/node-module-next-image/package.json
new file mode 100644
index 0000000..7d26412
--- /dev/null
+++ b/tests/fixtures/node-module-next-image/package.json
@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "next": "^10.0.0"
+  }
+}
diff --git a/tests/main.js b/tests/main.js
index d87526c..79a1e0a 100644
--- a/tests/main.js
+++ b/tests/main.js
@@ -151,6 +151,10 @@ test('Ignore missing critters dependency for Next.js exact version 10.0.5', asyn
   await zipNode(t, 'node-module-next10-critters-exact')
 })
 
+test('Resolves dependencies from .netlify/plugins/node_modules', async (t) => {
+  await zipNode(t, 'node-module-next-image')
+})
+
 // We persist `package.json` as `package.json.txt` in git. Otherwise ESLint
 // tries to load when linting sibling JavaScript files. In this test, we
 // temporarily rename it to an actual `package.json`.