Repository: netlify/zip-it-and-ship-it
Pull request #521 (2021-06-07 10:46:55)

feat: handle dynamic imports
**- Summary**

This PR takes advantage of the new `onDynamicImport` plugin hook of esbuild to intercept imports with dynamic expressions and include in the bundle the files necessary to make the function work at runtime.

For example, when parsing the following statement, all `.json` files in the `./lang` directory will be included in the bundle so that the `require` call can work at runtime.

```js
require(`./lang/${req.params.lang}.json`)
```

This PR includes support for parsing template literals only. Other expression types will be added in subsequent PRs.

**- Test plan**

Added a new test.

**- A picture of a cute animal (not mandatory but encouraged)**

![istockphoto-962467250-612x612](https://user-images.githubusercontent.com/4162329/121003947-ffbbaf00-c785-11eb-9331-23268169b9ac.jpg)


github-actions[bot]: ## ⏱ Benchmark results

*Comparing with d4bcaf0ab1ab6f984d4658da105936a21011fe66*

### largeDepsEsbuild: 12.7s
 ⬆️ **5.32% increase** vs. d4bcaf0ab1ab6f984d4658da105936a21011fe66
```
^                                                                                  13.3s                  
│                  12.5s                                                            ┌──┐           12.7s  
│          12.1s    ┌──┐                   12.2s            12s            11.7s    |  |   11.9s    ┌──┐  
│           ┌──┐    |  |           11.4s    ┌──┐   11.1s    ┌──┐   11.4s    ┌──┐    |  |    ┌──┐    |▒▒|  
│           |  |    |  |   10.3s    ┌──┐    |  |    ┌──┐    |  |    ┌──┐    |  |    |  |    |  |    |▒▒|  
│           |  |    |  |    ┌──┐    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   9.6s    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   ┌──┐    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
└───┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴──>
    T-12    T-11    T-10    T-9     T-8     T-7     T-6     T-5     T-4     T-3     T-2     T-1      T    
```
<details>
<summary>Legend</summary>

- T-30 (d4bcaf0ab1ab6f984d4658da105936a21011fe66): 12s
- T-29 (92f64a67160ac9eb0301ae738e38d258117f2b06): 10.3s
- T-28 (06bc4f69b05c96899a1efbd953c560219291b568): 11.6s
- T-27 (6c0e5f1059d5e672ea5ee6509150e0896030e745): 12.1s
- T-26 (e4c22192f1f66aae395b00fe8eaf4c886781d723): 11.4s
- T-25 (3ba92859e8fbf0f20abc46912284575c24e18124): 12s
- T-24 (5cdd4d23e88300d026f0152222136a35849c3b7f): 11.5s
- T-23 (df7f3e92e9547649a4c7c0b25d6e317af292c089): 12.7s
- T-22 (08f2a5d49a78e4424bc7478aa3cc8c98d21c5ee7): 9.9s
- T-21 (1e5ba0b8519f8ab643496979d7304d68d749d31e): 9.9s
- T-20 (1507586389e536db74867e3e8d3a7145373adfd3): 10s
- T-19 (6f4ab2998291fc3dd9dfa2d48d38be4d5ab8a901): 12s
- T-18 (d28f357f9dc98d7a0b278b99fee81daaa1e2c434): 12s
- T-17 (0687ba92a59971f75e554b70b407a85540f35616): 9.7s
- T-16 (98d18d9aa251f7f5758f55b3da27ccf066f98cc0): 11.4s
- T-15 (3de38a4f1d3c5c4b64c87a47926926a7812319e4): 11.3s
- T-14 (ab9f1d536555739628655ad674d4c0452ca47218): 11.5s
- T-13 (b248dfa11282ff23bd19001aa1ff66bd38a93d0b): 11.6s
- T-12 (c296d1706906a5d7b820cd7fb625c06453503f0e): 9.6s
- T-11 (62d7be931cf51cfb5cf5dba60a463678d69af4ce): 12.1s
- T-10 (8392f530c234ed8fa2881bad5c1ad7c937d7b588): 12.5s
- T-9 (772c7a216f983bb7d2e763aa0ef8ae0ae4f50f50): 10.3s
- T-8 (7085f74fa0bf71e204eecf269eabdc95ae84aa29): 11.4s
- T-7 (561de179a77b8877ccdf9cadaa3e6aaf54d45e20): 12.2s
- T-6 (e14fc0297b1e79c2a631af7b122b3ab0588525d1): 11.1s
- T-5 (deeadfdf94bc6111b4bda51884d58b05f0b27413): 12s
- T-4 (57e57ab3397b6e4eeac5f787537dfdfdf31c3c72): 11.4s
- T-3 (73bf2466782eb41e3c9fe51f7abcdc4fff80f2a4): 11.7s
- T-2 (5f753f0984aa7184f4880ec0b019bdfbc224cf18): 13.3s
- T-1 (d74715b3bb66b7064bf56f6c01726a8f5bfa8d95): 11.9s
- **T (current commit): 12.7s**</details>
### largeDepsZisi: 1m 9.1s
 ⬆️ **2.94% increase** vs. d4bcaf0ab1ab6f984d4658da105936a21011fe66
```
^                                                                                 1m 12.2s                
│                 1m 9.7s                                                           ┌──┐          1m 9.1s 
│         1m 5.5s   ┌──┐                  1m 5.2s                         1m 5.7s   |  |  1m 5.7s   ┌──┐  
│           ┌──┐    |  |          1m 2.4s   ┌──┐          1m 2.7s 1m 2.9s   ┌──┐    |  |    ┌──┐    |▒▒|  
│           |  |    |  |            ┌──┐    |  |    56s     ┌──┐    ┌──┐    |  |    |  |    |  |    |▒▒|  
│           |  |    |  |   54.6s    |  |    |  |    ┌──┐    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│  51.8s    |  |    |  |    ┌──┐    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   ┌──┐    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
│   |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |  |    |▒▒|  
└───┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴────┴──┴──>
    T-12    T-11    T-10    T-9     T-8     T-7     T-6     T-5     T-4     T-3     T-2     T-1      T    
```
<details>
<summary>Legend</summary>

- T-30 (d4bcaf0ab1ab6f984d4658da105936a21011fe66): 1m 7.1s
- T-29 (92f64a67160ac9eb0301ae738e38d258117f2b06): 54.7s
- T-28 (06bc4f69b05c96899a1efbd953c560219291b568): 1m 4.4s
- T-27 (6c0e5f1059d5e672ea5ee6509150e0896030e745): 1m 3.6s
- T-26 (e4c22192f1f66aae395b00fe8eaf4c886781d723): 59.3s
- T-25 (3ba92859e8fbf0f20abc46912284575c24e18124): 1m 2.7s
- T-24 (5cdd4d23e88300d026f0152222136a35849c3b7f): 1m 2.4s
- T-23 (df7f3e92e9547649a4c7c0b25d6e317af292c089): 1m 6.3s
- T-22 (08f2a5d49a78e4424bc7478aa3cc8c98d21c5ee7): 52.2s
- T-21 (1e5ba0b8519f8ab643496979d7304d68d749d31e): 52.8s
- T-20 (1507586389e536db74867e3e8d3a7145373adfd3): 54.8s
- T-19 (6f4ab2998291fc3dd9dfa2d48d38be4d5ab8a901): 1m 5.4s
- T-18 (d28f357f9dc98d7a0b278b99fee81daaa1e2c434): 1m 5.9s
- T-17 (0687ba92a59971f75e554b70b407a85540f35616): 52.2s
- T-16 (98d18d9aa251f7f5758f55b3da27ccf066f98cc0): 1m 0.6s
- T-15 (3de38a4f1d3c5c4b64c87a47926926a7812319e4): 1m 0.1s
- T-14 (ab9f1d536555739628655ad674d4c0452ca47218): 1m 0.3s
- T-13 (b248dfa11282ff23bd19001aa1ff66bd38a93d0b): 1m 1.8s
- T-12 (c296d1706906a5d7b820cd7fb625c06453503f0e): 51.8s
- T-11 (62d7be931cf51cfb5cf5dba60a463678d69af4ce): 1m 5.5s
- T-10 (8392f530c234ed8fa2881bad5c1ad7c937d7b588): 1m 9.7s
- T-9 (772c7a216f983bb7d2e763aa0ef8ae0ae4f50f50): 54.6s
- T-8 (7085f74fa0bf71e204eecf269eabdc95ae84aa29): 1m 2.4s
- T-7 (561de179a77b8877ccdf9cadaa3e6aaf54d45e20): 1m 5.2s
- T-6 (e14fc0297b1e79c2a631af7b122b3ab0588525d1): 56s
- T-5 (deeadfdf94bc6111b4bda51884d58b05f0b27413): 1m 2.7s
- T-4 (57e57ab3397b6e4eeac5f787537dfdfdf31c3c72): 1m 2.9s
- T-3 (73bf2466782eb41e3c9fe51f7abcdc4fff80f2a4): 1m 5.7s
- T-2 (5f753f0984aa7184f4880ec0b019bdfbc224cf18): 1m 12.2s
- T-1 (d74715b3bb66b7064bf56f6c01726a8f5bfa8d95): 1m 5.7s
- **T (current commit): 1m 9.1s**</details>
<!--delta:benchmark@{}-->

Patch:
diff --git a/package-lock.json b/package-lock.json
index 707f5f4..b5b1e86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
       "license": "MIT",
       "dependencies": {
         "@netlify/esbuild": "^0.13.6",
+        "acorn": "^8.4.0",
         "archiver": "^5.3.0",
         "array-flat-polyfill": "^1.0.1",
         "common-path-prefix": "^3.0.0",
@@ -1173,7 +1174,6 @@
       "version": "8.4.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.4.0.tgz",
       "integrity": "sha512-ULr0LDaEqQrMFGyQ3bhJkLsbtrQ8QibAseGZeaSUiT/6zb9IvIkomWHJIvgvwad+hinRAgsI51JcWk2yvwyL+w==",
-      "dev": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -12525,8 +12525,7 @@
     "acorn": {
       "version": "8.4.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.4.0.tgz",
-      "integrity": "sha512-ULr0LDaEqQrMFGyQ3bhJkLsbtrQ8QibAseGZeaSUiT/6zb9IvIkomWHJIvgvwad+hinRAgsI51JcWk2yvwyL+w==",
-      "dev": true
+      "integrity": "sha512-ULr0LDaEqQrMFGyQ3bhJkLsbtrQ8QibAseGZeaSUiT/6zb9IvIkomWHJIvgvwad+hinRAgsI51JcWk2yvwyL+w=="
     },
     "acorn-jsx": {
       "version": "5.3.1",
diff --git a/package.json b/package.json
index a80d7c8..00c8dc1 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
   },
   "dependencies": {
     "@netlify/esbuild": "^0.13.6",
+    "acorn": "^8.4.0",
     "archiver": "^5.3.0",
     "array-flat-polyfill": "^1.0.1",
     "common-path-prefix": "^3.0.0",
diff --git a/src/runtimes/node/bundler.js b/src/runtimes/node/bundler.js
index ec87272..e346b5f 100644
--- a/src/runtimes/node/bundler.js
+++ b/src/runtimes/node/bundler.js
@@ -1,13 +1,13 @@
-const { basename, dirname, extname, join, resolve } = require('path')
+const { basename, dirname, extname, resolve, join } = require('path')
 const process = require('process')
 
 const esbuild = require('@netlify/esbuild')
 const semver = require('semver')
 
-const { safeUnlink } = require('../../utils/fs')
+const { getPathWithExtension, safeUnlink } = require('../../utils/fs')
 
 const { getBundlerTarget } = require('./bundler_target')
-const { getDynamicImportsPlugin } = require('./dynamic_imports')
+const { getDynamicImportsPlugin } = require('./dynamic_imports/plugin')
 const { getNativeModulesPlugin } = require('./native_modules/plugin')
 
 // Maximum number of log messages that an esbuild instance will produce. This
@@ -15,6 +15,10 @@ const { getNativeModulesPlugin } = require('./native_modules/plugin')
 // sent in the Go<>Node IPC channel.
 const ESBUILD_LOG_LIMIT = 10
 
+// When resolving imports with no extension (e.g. require('./foo')), these are
+// the extensions that esbuild will look for, in this order.
+const RESOLVE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.json']
+
 const supportsAsyncAPI = semver.satisfies(process.version, '>=9.x')
 
 // esbuild's async build API throws on Node 8.x, so we switch to the sync
@@ -22,11 +26,10 @@ const supportsAsyncAPI = semver.satisfies(process.version, '>=9.x')
 // eslint-disable-next-line node/no-sync
 const buildFunction = supportsAsyncAPI ? esbuild.build : esbuild.buildSync
 
-// eslint-disable-next-line max-statements
 const bundleJsFile = async function ({
   additionalModulePaths,
+  basePath,
   config,
-  destFilename,
   destFolder,
   externalModules = [],
   ignoredModules = [],
@@ -36,20 +39,39 @@ const bundleJsFile = async function ({
 }) {
   // De-duping external and ignored modules.
   const external = [...new Set([...externalModules, ...ignoredModules])]
-  const jsFilename = `${basename(destFilename, extname(destFilename))}.js`
-  const bundlePath = join(destFolder, jsFilename)
+
+  // To be populated by the native modules plugin with the names, versions and
+  // paths of any Node modules with native dependencies.
   const nativeNodeModules = {}
+
+  // To be populated by the dynamic imports plugin with the names of the Node
+  // modules that include imports with dynamic expressions.
   const nodeModulesWithDynamicImports = new Set()
+
+  // To be populated by the dynamic imports plugin with any paths (in a glob
+  // format) to be included in the bundle in order to make a dynamic import
+  // work at runtime.
+  const dynamicImportsIncludedPaths = new Set()
+
+  // The list of esbuild plugins to enable for this build.
   const plugins = [
     getNativeModulesPlugin(nativeNodeModules),
-    getDynamicImportsPlugin({ moduleNames: nodeModulesWithDynamicImports, srcDir }),
+    getDynamicImportsPlugin({
+      basePath,
+      includedPaths: dynamicImportsIncludedPaths,
+      moduleNames: nodeModulesWithDynamicImports,
+      srcDir,
+    }),
   ]
+
+  // The version of ECMAScript to use as the build target. This will determine
+  // whether certain features are transpiled down or left untransformed.
   const nodeTarget = getBundlerTarget(config.nodeVersion)
 
-  // esbuild will format `sources` relative to the sourcemap file, which is a
-  // sibling of `bundlePath`. We use `sourceRoot` to establish that relation.
-  // They are URLs, so even on Windows they should use forward slashes.
-  const sourceRoot = dirname(bundlePath).replace(/\\/g, '/')
+  // esbuild will format `sources` relative to the sourcemap file, which lives
+  // in `destFolder`. We use `sourceRoot` to establish that relation. They are
+  // URLs, not paths, so even on Windows they should use forward slashes.
+  const sourceRoot = destFolder.replace(/\\/g, '/')
 
   try {
     const { metafile, warnings } = await buildFunction({
@@ -60,25 +82,29 @@ const bundleJsFile = async function ({
       logLimit: ESBUILD_LOG_LIMIT,
       metafile: true,
       nodePaths: additionalModulePaths,
-      outfile: bundlePath,
+      outdir: destFolder,
       platform: 'node',
       plugins: supportsAsyncAPI ? plugins : [],
-      resolveExtensions: ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.json'],
+      resolveExtensions: RESOLVE_EXTENSIONS,
       sourcemap: Boolean(config.nodeSourcemap),
       sourceRoot,
       target: [nodeTarget],
     })
-    const sourcemapPath = getSourcemapPath(metafile.outputs)
+    const bundlePaths = getBundlePaths({
+      destFolder,
+      outputs: metafile.outputs,
+      srcFile,
+    })
     const inputs = Object.keys(metafile.inputs).map((path) => resolve(path))
-    const cleanTempFiles = getCleanupFunction(bundlePath, sourcemapPath)
+    const cleanTempFiles = getCleanupFunction([...bundlePaths.keys()])
 
     return {
-      bundlePath,
+      additionalPaths: [...dynamicImportsIncludedPaths],
+      bundlePaths,
       cleanTempFiles,
       inputs,
       nativeNodeModules,
       nodeModulesWithDynamicImports: [...nodeModulesWithDynamicImports],
-      sourcemapPath,
       warnings,
     }
   } catch (error) {
@@ -88,18 +114,40 @@ const bundleJsFile = async function ({
   }
 }
 
-const getCleanupFunction =
-  (...paths) =>
-  async () => {
-    await Promise.all(paths.filter(Boolean).map(safeUnlink))
-  }
+// Takes the `outputs` object produced by esbuild and returns a Map with the
+// absolute paths of the generated files as keys, and the paths that those
+// files should take in the generated bundle as values. This is compatible
+// with the `aliases` format used upstream.
+const getBundlePaths = ({ destFolder, outputs, srcFile }) => {
+  const bundleFilename = `${basename(srcFile, extname(srcFile))}.js`
+  const mainFileDirectory = dirname(srcFile)
+  const bundlePaths = new Map()
 
-const getSourcemapPath = (outputs) => {
-  const relativePath = Object.keys(outputs).find((path) => extname(path) === '.map')
+  // The paths returned by esbuild are relative to the current directory, which
+  // is a problem on Windows if the target directory is in a different drive
+  // letter. To get around that, instead of using `path.resolve`, we compute
+  // the absolute path by joining `destFolder` with the `basename` of each
+  // entry of the `outputs` object.
+  Object.entries(outputs).forEach(([path, output]) => {
+    const filename = basename(path)
+    const extension = extname(path)
+    const absolutePath = join(destFolder, filename)
 
-  if (relativePath) {
-    return resolve(relativePath)
-  }
+    if (output.entryPoint && basename(output.entryPoint) === basename(srcFile)) {
+      // Ensuring the main file has a `.js` extension.
+      const normalizedSrcFile = getPathWithExtension(srcFile, '.js')
+
+      bundlePaths.set(absolutePath, normalizedSrcFile)
+    } else if (extension === '.js' || filename === `${bundleFilename}.map`) {
+      bundlePaths.set(absolutePath, join(mainFileDirectory, filename))
+    }
+  })
+
+  return bundlePaths
+}
+
+const getCleanupFunction = (paths) => async () => {
+  await Promise.all(paths.filter(Boolean).map(safeUnlink))
 }
 
 module.exports = { bundleJsFile, ESBUILD_LOG_LIMIT }
diff --git a/src/runtimes/node/dynamic_imports.js b/src/runtimes/node/dynamic_imports.js
deleted file mode 100644
index ebf5a1d..0000000
--- a/src/runtimes/node/dynamic_imports.js
+++ /dev/null
@@ -1,57 +0,0 @@
-const { basename, join } = require('path')
-
-const findUp = require('find-up')
-const readPackageJson = require('read-package-json-fast')
-
-const getDynamicImportsPlugin = ({ moduleNames, srcDir }) => ({
-  name: 'dynamic-imports',
-  setup(build) {
-    const cache = new Map()
-
-    build.onDynamicImport({}, async (args) => {
-      try {
-        const packageName = await getPackageNameCached({ cache, resolveDirectory: args.resolveDir, srcDir })
-
-        if (packageName !== undefined) {
-          moduleNames.add(packageName)
-        }
-      } catch (_) {
-        // no-op
-      }
-    })
-  },
-})
-
-const getPackageName = async ({ resolveDirectory, srcDir }) => {
-  const packageJsonPath = await findUp(
-    async (directory) => {
-      // We stop traversing if we're about to leave the boundaries of the
-      // function directory or any node_modules directory.
-      if (directory === srcDir || basename(directory) === 'node_modules') {
-        return findUp.stop
-      }
-
-      const path = join(directory, 'package.json')
-      const hasPackageJson = await findUp.exists(path)
-
-      return hasPackageJson ? path : false
-    },
-    { cwd: resolveDirectory },
-  )
-
-  if (packageJsonPath !== undefined) {
-    const { name } = await readPackageJson(packageJsonPath)
-
-    return name
-  }
-}
-
-const getPackageNameCached = ({ cache, resolveDirectory, srcDir }) => {
-  if (!cache.has(resolveDirectory)) {
-    cache.set(resolveDirectory, getPackageName({ resolveDirectory, srcDir }))
-  }
-
-  return cache.get(resolveDirectory)
-}
-
-module.exports = { getDynamicImportsPlugin }
diff --git a/src/runtimes/node/dynamic_imports/parser.js b/src/runtimes/node/dynamic_imports/parser.js
new file mode 100644
index 0000000..888e33c
--- /dev/null
+++ b/src/runtimes/node/dynamic_imports/parser.js
@@ -0,0 +1,140 @@
+const { join, relative, resolve } = require('path')
+
+const acorn = require('acorn')
+
+const ECMA_VERSION = 2021
+const GLOB_WILDCARD = '*'
+
+// Transforms an array of glob nodes into a glob string including an absolute
+// path.
+//
+// Example: ["./files/", "*", ".json"] => "/home/ntl/files/*.json"
+const getAbsoluteGlob = ({ basePath, globNodes, resolveDir }) => {
+  if (!validateGlobNodes(globNodes)) {
+    return null
+  }
+
+  const globStarIndex = globNodes.indexOf(GLOB_WILDCARD)
+  const [staticPath, dynamicPath] =
+    globStarIndex === -1
+      ? [globNodes.join(''), '']
+      : [globNodes.slice(0, globStarIndex).join(''), globNodes.slice(globStarIndex).join('')]
+  const absoluteStaticPath = resolve(resolveDir, staticPath)
+  const relativeStaticPath = relative(basePath, absoluteStaticPath)
+  const absoluteGlob = join(relativeStaticPath, dynamicPath)
+
+  return absoluteGlob
+}
+
+// Tries to parse an expression, returning an object with:
+// - `includedPathsGlob`: A glob with the files to be included in the bundle
+// - `type`: The expression type (e.g. "require", "import")
+const parseExpression = ({ basePath, expression, resolveDir }) => {
+  const { body } = acorn.parse(expression, { ecmaVersion: ECMA_VERSION })
+  const { callee } = body[0].expression
+
+  if (callee.name === 'require') {
+    const includedPathsGlob = parseRequire({ basePath, expression: body[0].expression, resolveDir })
+
+    if (includedPathsGlob) {
+      return {
+        includedPathsGlob,
+        type: callee.name,
+      }
+    }
+  }
+}
+
+// Parses a `require()` and returns a glob string with an absolute path.
+const parseRequire = ({ basePath, expression, resolveDir }) => {
+  const { arguments: args } = expression
+  const argType = args[0].type
+
+  if (argType === 'BinaryExpression') {
+    try {
+      const globNodes = parseBinaryExpression(args[0])
+
+      return getAbsoluteGlob({ basePath, globNodes, resolveDir })
+    } catch (_) {
+      // no-op
+    }
+  }
+
+  if (argType === 'TemplateLiteral') {
+    const globNodes = parseTemplateLiteral(args[0])
+
+    return getAbsoluteGlob({ basePath, globNodes, resolveDir })
+  }
+}
+
+// Transforms a binary expression AST node into an array of glob nodes, where
+// static parts will be left untouched and identifiers will be replaced by
+// `GLOB_WILDCARD`.
+//
+// Example: './files/' + lang + '.json' => ["./files/", "*", ".json"]
+const parseBinaryExpression = (expression) => {
+  const { left, operator, right } = expression
+
+  if (operator !== '+') {
+    throw new Error('Expression operator not supported')
+  }
+
+  const operands = [left, right].flatMap((operand) => {
+    switch (operand.type) {
+      case 'BinaryExpression':
+        return parseBinaryExpression(operand)
+
+      case 'CallExpression':
+      case 'Identifier':
+        return GLOB_WILDCARD
+
+      case 'Literal':
+        return operand.value
+
+      default:
+        throw new Error('Expression member not supported')
+    }
+  })
+
+  return operands
+}
+
+// Transforms a template literal AST node into an array of glob nodes, where
+// static parts will be left untouched and identifiers will be replaced by
+// `GLOB_WILDCARD`.
+//
+// Example: `./files/${lang}.json` => ["./files/", "*", ".json"]
+const parseTemplateLiteral = (expression) => {
+  const { expressions, quasis } = expression
+  const parts = [...expressions, ...quasis].sort((partA, partB) => partA.start - partB.start)
+  const globNodes = parts.map(({ type, value }) => {
+    switch (type) {
+      case 'TemplateElement':
+        return value.cooked
+
+      case 'CallExpression':
+      case 'Identifier':
+        return GLOB_WILDCARD
+
+      default:
+        return null
+    }
+  })
+
+  return globNodes.every(Boolean) ? globNodes : null
+}
+
+// For our purposes, we consider a glob to be valid if all the nodes are
+// strings and the first node is static (i.e. not a wildcard character).
+const validateGlobNodes = (globNodes) => {
+  if (!globNodes) {
+    return false
+  }
+
+  const hasStrings = globNodes.every((node) => typeof node === 'string')
+  const hasStaticHead = globNodes[0] !== GLOB_WILDCARD
+
+  return hasStrings && hasStaticHead
+}
+
+module.exports = { parseExpression }
diff --git a/src/runtimes/node/dynamic_imports/plugin.js b/src/runtimes/node/dynamic_imports/plugin.js
new file mode 100644
index 0000000..77c1d13
--- /dev/null
+++ b/src/runtimes/node/dynamic_imports/plugin.js
@@ -0,0 +1,114 @@
+const { basename, join, relative } = require('path')
+
+const findUp = require('find-up')
+const readPackageJson = require('read-package-json-fast')
+const unixify = require('unixify')
+
+const { parseExpression } = require('./parser')
+
+// This plugin intercepts module imports using dynamic expressions and does a
+// couple of things with them. First of all, it figures out whether the call
+// is being made from within a Node module, and if so it adds the name of the
+// module to `moduleNames`, so that we can warn the user of potential runtime
+// issues. Secondly, it parses the dynamic expressions and tries to include in
+// the bundle all the files that are possibly needed to make the import work at
+// runtime. This is not always possible, but we do our best.
+const getDynamicImportsPlugin = ({ basePath, includedPaths, moduleNames, srcDir }) => ({
+  name: 'dynamic-imports',
+  setup(build) {
+    const cache = new Map()
+
+    build.onDynamicImport({}, async (args) => {
+      const { expression, resolveDir } = args
+
+      await registerModuleWithDynamicImports({ cache, moduleNames, resolveDir, srcDir })
+
+      // Don't attempt to parse the expression if the base path isn't defined,
+      // since we won't be able to generate the globs for the included paths.
+      if (!basePath) {
+        return
+      }
+
+      const { includedPathsGlob, type: expressionType } = parseExpression({ basePath, expression, resolveDir }) || {}
+
+      if (!includedPathsGlob) {
+        return
+      }
+
+      // The parser has found a glob of paths that should be included in the
+      // bundle to make this import work, so we add it to `includedPaths`.
+      includedPaths.add(includedPathsGlob)
+
+      // Create the shim that will handle the import at runtime.
+      const contents = getShimContents({ expressionType, resolveDir, srcDir })
+
+      if (!contents) {
+        return
+      }
+
+      return {
+        contents,
+      }
+    })
+  },
+})
+
+const getPackageName = async ({ resolveDir, srcDir }) => {
+  const packageJsonPath = await findUp(
+    async (directory) => {
+      // We stop traversing if we're about to leave the boundaries of the
+      // function directory or any node_modules directory.
+      if (directory === srcDir || basename(directory) === 'node_modules') {
+        return findUp.stop
+      }
+
+      const path = join(directory, 'package.json')
+      const hasPackageJson = await findUp.exists(path)
+
+      return hasPackageJson ? path : false
+    },
+    { cwd: resolveDir },
+  )
+
+  if (packageJsonPath !== undefined) {
+    const { name } = await readPackageJson(packageJsonPath)
+
+    return name
+  }
+}
+
+const getPackageNameCached = ({ cache, resolveDir, srcDir }) => {
+  if (!cache.has(resolveDir)) {
+    cache.set(resolveDir, getPackageName({ resolveDir, srcDir }))
+  }
+
+  return cache.get(resolveDir)
+}
+
+const getShimContents = ({ expressionType, resolveDir, srcDir }) => {
+  // The shim needs to modify the path of the import, since originally it was
+  // relative to wherever the importer sat in the file tree (i.e. anywhere in
+  // the user space or inside `node_modules`), but at runtime paths must be
+  // relative to the main bundle file, since esbuild will flatten everything
+  // into a single file.
+  const relativeResolveDir = relative(srcDir, resolveDir)
+  const requireArg = relativeResolveDir ? `\`./${unixify(relativeResolveDir)}/$\{args}\`` : 'args'
+
+  if (expressionType === 'require') {
+    return `module.exports = args => require(${requireArg})`
+  }
+}
+
+const registerModuleWithDynamicImports = async ({ cache, moduleNames, resolveDir, srcDir }) => {
+  try {
+    const packageName = await getPackageNameCached({ cache, resolveDir, srcDir })
+
+    if (packageName !== undefined) {
+      moduleNames.add(packageName)
+    }
+  } catch (_) {
+    // no-op
+  }
+}
+
+module.exports = { getDynamicImportsPlugin }
diff --git a/src/runtimes/node/zip_esbuild.js b/src/runtimes/node/zip_esbuild.js
index cef4550..50f9f26 100644
--- a/src/runtimes/node/zip_esbuild.js
+++ b/src/runtimes/node/zip_esbuild.js
@@ -1,26 +1,14 @@
-const { dirname, format, join, normalize, relative, parse } = require('path')
+const { dirname, normalize } = require('path')
 
 const { getExternalAndIgnoredModulesFromSpecialCases } = require('../../node_dependencies')
 const { JS_BUNDLER_ESBUILD } = require('../../utils/consts')
+const { getPathWithExtension } = require('../../utils/fs')
 const { zipNodeJs } = require('../../zip_node')
 
 const { getBasePath } = require('./base_path')
 const { bundleJsFile } = require('./bundler')
 const { getSrcFilesAndExternalModules } = require('./src_files')
 
-const getAliases = ({ bundlePath, mainFile, sourcemapPath, srcDir }) => {
-  const aliases = new Map([[bundlePath, mainFile]])
-
-  if (sourcemapPath !== undefined) {
-    const bundleDirectory = dirname(bundlePath)
-    const relativeSourcemapPath = relative(bundleDirectory, sourcemapPath)
-
-    aliases.set(sourcemapPath, join(srcDir, relativeSourcemapPath))
-  }
-
-  return aliases
-}
-
 const getFunctionBasePath = ({ basePathFromConfig, mainFile, supportingSrcFiles }) => {
   // If there is a base path defined in the config, we use that.
   if (basePathFromConfig !== undefined) {
@@ -63,15 +51,16 @@ const zipEsbuild = async ({
 }) => {
   const { externalModules, ignoredModules } = await getExternalAndIgnoredModules({ config, srcDir })
   const {
-    bundlePath,
+    additionalPaths,
+    bundlePaths,
     cleanTempFiles,
     inputs,
     nativeNodeModules = {},
     nodeModulesWithDynamicImports,
-    sourcemapPath,
     warnings,
   } = await bundleJsFile({
     additionalModulePaths: pluginsModulesPath ? [pluginsModulesPath] : [],
+    basePath,
     config,
     destFilename: filename,
     destFolder,
@@ -85,7 +74,7 @@ const zipEsbuild = async ({
   const { paths: srcFiles } = await getSrcFilesAndExternalModules({
     externalNodeModules: [...externalModules, ...Object.keys(nativeNodeModules)],
     bundler: JS_BUNDLER_ESBUILD,
-    includedFiles: config.includedFiles,
+    includedFiles: [...(config.includedFiles || []), ...additionalPaths],
     includedFilesBasePath: config.includedFilesBasePath || basePath,
     mainFile,
     srcPath,
@@ -98,18 +87,12 @@ const zipEsbuild = async ({
   // path of the original, pre-bundling function file. We'll add the actual
   // bundled file further below.
   const supportingSrcFiles = srcFiles.filter((path) => path !== mainFile)
-
-  // Normalizing the main file so that it has a .js extension.
-  const normalizedMainFile = format({ ...parse(mainFile), base: undefined, ext: '.js' })
-
-  // We're adding the bundled file to the zip, but we want it to have the same
-  // name and path as the original, unbundled file. For this, we use an alias.
-  const aliases = getAliases({ bundlePath, mainFile: normalizedMainFile, sourcemapPath, srcDir })
+  const normalizedMainFile = getPathWithExtension(mainFile, '.js')
   const functionBasePath = getFunctionBasePath({ basePathFromConfig: basePath, mainFile, supportingSrcFiles })
 
   try {
     const path = await zipNodeJs({
-      aliases,
+      aliases: bundlePaths,
       archiveFormat,
       basePath: functionBasePath,
       destFolder,
@@ -118,7 +101,7 @@ const zipEsbuild = async ({
       filename,
       mainFile: normalizedMainFile,
       pluginsModulesPath,
-      srcFiles: [...supportingSrcFiles, bundlePath, ...(sourcemapPath ? [sourcemapPath] : [])],
+      srcFiles: [...supportingSrcFiles, ...bundlePaths.keys()],
     })
 
     return {
diff --git a/src/utils/fs.js b/src/utils/fs.js
index 6ee7d4b..5ccff0e 100644
--- a/src/utils/fs.js
+++ b/src/utils/fs.js
@@ -1,10 +1,12 @@
 const { readdir, unlink } = require('fs')
-const { join } = require('path')
+const { format, join, parse } = require('path')
 const { promisify } = require('util')
 
 const pReaddir = promisify(readdir)
 const pUnlink = promisify(unlink)
 
+const getPathWithExtension = (path, extension) => format({ ...parse(path), base: undefined, ext: extension })
+
 const safeUnlink = async (path) => {
   try {
     await pUnlink(path)
@@ -21,4 +23,4 @@ const listFunctionsDirectory = async function (srcFolder) {
   }
 }
 
-module.exports = { listFunctionsDirectory, safeUnlink }
+module.exports = { getPathWithExtension, listFunctionsDirectory, safeUnlink }


Test patch:
diff --git a/tests/fixtures/node-module-dynamic-import-2/function.js b/tests/fixtures/node-module-dynamic-import-2/function.js
new file mode 100644
index 0000000..437adba
--- /dev/null
+++ b/tests/fixtures/node-module-dynamic-import-2/function.js
@@ -0,0 +1,4 @@
+const id = (arg) => arg
+
+// eslint-disable-next-line import/no-dynamic-require, node/global-require, prefer-template
+module.exports = (lang) => [require('./lang/' + lang + '.json'), require('./lang/' + id(lang) + '.json')]
diff --git a/tests/fixtures/node-module-dynamic-import-2/lang/en.json b/tests/fixtures/node-module-dynamic-import-2/lang/en.json
new file mode 100644
index 0000000..35de6c6
--- /dev/null
+++ b/tests/fixtures/node-module-dynamic-import-2/lang/en.json
@@ -0,0 +1,1 @@
+["yes", "no"]
diff --git a/tests/fixtures/node-module-dynamic-import-2/lang/pt.json b/tests/fixtures/node-module-dynamic-import-2/lang/pt.json
new file mode 100644
index 0000000..940b222
--- /dev/null
+++ b/tests/fixtures/node-module-dynamic-import-2/lang/pt.json
@@ -0,0 +1,1 @@
+["sim", "não"]
diff --git a/tests/fixtures/node-module-dynamic-import-2/package.json b/tests/fixtures/node-module-dynamic-import-2/package.json
new file mode 100644
index 0000000..d0d4274
--- /dev/null
+++ b/tests/fixtures/node-module-dynamic-import-2/package.json
@@ -0,0 +1,4 @@
+{
+  "name": "node-module-dynamic-import-2",
+  "version": "1.0.0"
+}
diff --git a/tests/fixtures/node-module-dynamic-import/node_modules/@org/test/index.js b/tests/fixtures/node-module-dynamic-import/node_modules/@org/test/index.js
index 88247c6..62c9432 100644
--- a/tests/fixtures/node-module-dynamic-import/node_modules/@org/test/index.js
+++ b/tests/fixtures/node-module-dynamic-import/node_modules/@org/test/index.js
@@ -1,8 +1,9 @@
-const number = 'one'
 const test2 = require('test-two')
 
-module.exports = () => {
-  test2()
+module.exports = (number) => {
+  try {
+    test2()
+  } catch (_) {}
 
   const file = require(`./files/${number}.json`)
 
diff --git a/tests/helpers/main.js b/tests/helpers/main.js
index d35135f..bc5724c 100644
--- a/tests/helpers/main.js
+++ b/tests/helpers/main.js
@@ -1,4 +1,5 @@
 const { dirname, join, resolve } = require('path')
+const { env } = require('process')
 const { promisify } = require('util')
 
 const AdmZip = require('adm-zip')
@@ -31,6 +32,11 @@ const zipFixture = async function (t, fixture, { length, fixtureDir, opts = {} }
   const { path: tmpDir } = await getTmpDir({
     prefix: `zip-it-test-bundler-${bundlerString}`,
   })
+
+  if (env.ZISI_KEEP_TEMP_DIRS !== undefined) {
+    console.log(tmpDir)
+  }
+
   const { files } = await zipCheckFunctions(t, fixture, { length, fixtureDir, tmpDir, opts })
   return { files, tmpDir }
 }
diff --git a/tests/main.js b/tests/main.js
index 96785a7..0f61250 100644
--- a/tests/main.js
+++ b/tests/main.js
@@ -1236,6 +1236,38 @@ test('Returns an empty list of modules with dynamic imports if the modules are m
   t.is(files[0].nodeModulesWithDynamicImports.length, 0)
 })
 
+test('Adds a runtime shim and includes the files needed for dynamic imports using a template literal', async (t) => {
+  const fixtureName = 'node-module-dynamic-import'
+  const { files, tmpDir } = await zipNode(t, fixtureName, {
+    opts: { basePath: join(FIXTURES_DIR, fixtureName), config: { '*': { nodeBundler: ESBUILD } } },
+  })
+
+  // eslint-disable-next-line import/no-dynamic-require, node/global-require
+  const func = require(`${tmpDir}/function.js`)
+
+  t.true(func('one'))
+  t.throws(() => func('two'))
+  t.is(files[0].nodeModulesWithDynamicImports.length, 2)
+  t.true(files[0].nodeModulesWithDynamicImports.includes('@org/test'))
+  t.true(files[0].nodeModulesWithDynamicImports.includes('test-two'))
+})
+
+test('Adds a runtime shim and includes the files needed for dynamic imports using an expression built with the `+` operator', async (t) => {
+  const fixtureName = 'node-module-dynamic-import-2'
+  const { tmpDir } = await zipNode(t, fixtureName, {
+    opts: { basePath: join(FIXTURES_DIR, fixtureName), config: { '*': { nodeBundler: ESBUILD } } },
+  })
+
+  // eslint-disable-next-line import/no-dynamic-require, node/global-require
+  const func = require(`${tmpDir}/function.js`)
+
+  t.deepEqual(func('en')[0], ['yes', 'no'])
+  t.deepEqual(func('en')[1], ['yes', 'no'])
+  t.deepEqual(func('pt')[0], ['sim', 'não'])
+  t.deepEqual(func('pt')[1], ['sim', 'não'])
+  t.throws(() => func('fr'))
+})
+
 test('Uses the default Node bundler if no configuration object is supplied', async (t) => {
   const { files, tmpDir } = await zipNode(t, 'local-node-module')
   const requires = await getRequires({ filePath: resolve(tmpDir, 'src/function.js') })