Skip to content

Instantly share code, notes, and snippets.

@oxc
Created April 27, 2023 13:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save oxc/790596f1fe663c24e18ef47252e5a537 to your computer and use it in GitHub Desktop.
Save oxc/790596f1fe663c24e18ef47252e5a537 to your computer and use it in GitHub Desktop.
esbuild plugin to mark only (some) top-level node_modules as external
/**
* @typedef {Object} NodeModulesLayerPluginOptions
* @property {string[]} [layerModules]
*/
const recursionFlag = Symbol.for('$NodeModulesLayerPlugin_recursion')
/**
* This is an esbuild plugin which marks all imports that are included in a NodeModulesLayer as external.
*
* It ensures that only the "main" version will be marked as external, and other versions that have been installed
* into nested node_modules folders will be included in the bundle.
*
* Consider the following example:
*
* We have a module "uuid" included as version 8 in our package.json, it is included in the node_modules layer.
* We have some module (e.g. "request") that has a dependency on the "uuid" module in version 3.
*
* Those two dependencies (and in fact the versions) are not compatible, so they get deployed by yarn install like this:
* ```
* node_modules/
* uuid/ (version 8)
* request/
* index.js
* node_modules/
* uuid/ (version 3)
* ```
*
* If we were just to pass --external:uuid to esbuild, it would replace all imports of uuid with a require('uuid') call.
* This would work correctly for users of uuid 8, but imports within the request module would also get the version 8,
* instead of version 3 which they depend on.
*
* To solve this, this module marks only those imports as external, that resolve to a top-level node_modules folder.
* If the path an import resolves to contains a second node_modules folder, it is not marked as external.
*
* In the above example, this would mean that imports of 'uuid' in our own code would be marked as external, but imports
* of 'uuid' within the request module would be bundled into the output.
*
* @type (options: NodeModulesLayerPluginOptions) => import("esbuild").Plugin
*/
export const nodeModulesLayerPlugin = ({ layerModules }) => ({
name: 'node-modules-layer',
setup(build) {
build.onResolve({ filter: new RegExp(`^(${layerModules.join('|')})(/|$)`), namespace: 'file' }, async args => {
if (args.pluginData?.[recursionFlag]) {
return;
}
const { path, ...resolveArgs } = args;
const resolved = await build.resolve(path, { ...resolveArgs, pluginData: {
...args.pluginData,
[recursionFlag]: true
}});
if (resolved.external) {
return resolved;
}
const nodeModulesOffset = resolved.path.indexOf('/node_modules/');
if (nodeModulesOffset === -1) {
return resolved;
}
const isNestedNodeModules = resolved.path.includes('/node_modules/', nodeModulesOffset + 14);
if (isNestedNodeModules) {
// we don't want to mark nested node_modules as external, because then the top-level node_modules version
// would be used, which is different. There is a reason that the nested node_modules is there.
return {
...resolved,
warnings: [
...resolved.warnings,
{
text: `The import "${path}" resolves to a module nested inside another node_modules folder, and will not be marked as external.`,
pluginName: 'node-modules-layer',
}
],
};
}
return {
path,
external: true,
}
});
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment