Skip to content

Commit

Permalink
feat: allow modules to be marked as errors (#152)
Browse files Browse the repository at this point in the history
Co-authored-by: parbez <imranbarbhuiya.fsd@gmail.com>
  • Loading branch information
markdalgleish and imranbarbhuiya authored Aug 24, 2023
1 parent 3946db8 commit c02060d
Show file tree
Hide file tree
Showing 7 changed files with 451 additions and 29 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,38 @@ build({
});
```

Optionally fail the build when certain modules are used (note that the `write` build option must be `false` to support this):

```ts
import { nodeModulesPolyfillPlugin } from 'esbuild-plugins-node-modules-polyfill';
import { build } from 'esbuild';
const buildResult = await build({
write: false,
plugins: [nodeModulesPolyfillPlugin({
modules: {
crypto: 'error',
path: true,
},
})],
});
```

Optionally fail the build when a module is not polyfilled or configured (note that the `write` build option must be `false` to support this):

```ts
import { nodeModulesPolyfillPlugin } from 'esbuild-plugins-node-modules-polyfill';
import { build } from 'esbuild';
const buildResult = await build({
write: false,
plugins: [nodeModulesPolyfillPlugin({
fallback: 'error',
modules: {
path: true,
}
})],
});
```

## Buy me some doughnuts

If you want to support me by donating, you can do so by using any of the following methods. Thank you very much in advance!
Expand Down
127 changes: 100 additions & 27 deletions src/lib/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { builtinModules } from 'node:module';
import path from 'node:path';
import process from 'node:process';

import { loadPackageJSON } from 'local-pkg';

import { getCachedPolyfillContent, getCachedPolyfillPath } from './polyfill.js';
import { escapeRegex, commonJsTemplate, normalizeNodeBuiltinPath } from './utils/util.js';

import type { OnResolveArgs, OnResolveResult, Plugin } from 'esbuild';
import type { OnResolveArgs, OnResolveResult, PartialMessage, Plugin } from 'esbuild';
import type esbuild from 'esbuild';

const NAME = 'node-modules-polyfills';

export interface NodePolyfillsOptions {
fallback?: 'empty' | 'none';
fallback?: 'empty' | 'error' | 'none';
globals?: {
Buffer?: boolean;
process?: boolean;
};
modules?: string[] | Record<string, boolean | 'empty'>;
modules?: string[] | Record<string, boolean | 'empty' | 'error'>;
name?: string;
namespace?: string;
}
Expand Down Expand Up @@ -56,7 +57,13 @@ const loader = async (args: esbuild.OnLoadArgs): Promise<esbuild.OnLoadResult> =
};

export const nodeModulesPolyfillPlugin = (options: NodePolyfillsOptions = {}): Plugin => {
const { globals = {}, modules: modulesOption = builtinModules, fallback, namespace = NAME, name = NAME } = options;
const {
globals = {},
modules: modulesOption = builtinModules,
fallback = 'none',
namespace = NAME,
name = NAME,
} = options;
if (namespace.endsWith('commonjs')) {
throw new Error(`namespace ${namespace} must not end with commonjs`);
}
Expand All @@ -65,16 +72,29 @@ export const nodeModulesPolyfillPlugin = (options: NodePolyfillsOptions = {}): P
throw new Error(`namespace ${namespace} must not end with empty`);
}

if (namespace.endsWith('error')) {
throw new Error(`namespace ${namespace} must not end with error`);
}

const modules = Array.isArray(modulesOption)
? Object.fromEntries(modulesOption.map((mod) => [mod, true]))
: modulesOption;

const commonjsNamespace = `${namespace}-commonjs`;
const emptyNamespace = `${namespace}-empty`;
const errorNamespace = `${namespace}-error`;

const shouldDetectErrorModules = fallback === 'error' || Object.values(modules).includes('error');

return {
name,
setup: ({ onLoad, onResolve, initialOptions }) => {
setup: ({ onLoad, onResolve, onEnd, initialOptions }) => {
if (shouldDetectErrorModules && initialOptions.write !== false) {
throw new Error(`The "write" build option must be set to false when using the "error" polyfill type`);
}

const root = initialOptions.absWorkingDir ?? process.cwd();

// polyfills contain global keyword, it must be defined
if (initialOptions.define && !initialOptions.define.global) {
initialOptions.define.global = 'globalThis';
Expand Down Expand Up @@ -102,24 +122,45 @@ export const nodeModulesPolyfillPlugin = (options: NodePolyfillsOptions = {}): P
};
});

onLoad({ filter: /.*/, namespace: errorNamespace }, (args) => {
return {
loader: 'js',
contents: `module.exports = ${JSON.stringify(
// This encoded string is detected and parsed at the end of the build to report errors
`__POLYFILL_ERROR_START__::MODULE::${args.path}::IMPORTER::${args.pluginData.importer}::__POLYFILL_ERROR_END__`,
)}`,
};
});

onLoad({ filter: /.*/, namespace }, loader);
onLoad({ filter: /.*/, namespace: commonjsNamespace }, loader);

// If we are using empty fallbacks, we need to handle all builtin modules so that we can replace their contents,
// If we are using fallbacks, we need to handle all builtin modules so that we can replace their contents,
// otherwise we only need to handle the modules that are configured (which is everything by default)
const bundledModules =
fallback === 'empty'
? builtinModules
: Object.keys(modules).filter((moduleName) => builtinModules.includes(moduleName));
fallback === 'none'
? Object.keys(modules).filter((moduleName) => builtinModules.includes(moduleName))
: builtinModules;

const filter = new RegExp(`^(?:node:)?(?:${bundledModules.map(escapeRegex).join('|')})$`);

const resolver = async (args: OnResolveArgs): Promise<OnResolveResult | undefined> => {
const emptyResult = {
namespace: emptyNamespace,
path: args.path,
sideEffects: false,
};
const result = {
empty: {
namespace: emptyNamespace,
path: args.path,
sideEffects: false,
},
error: {
namespace: errorNamespace,
path: args.path,
sideEffects: false,
pluginData: {
importer: path.relative(root, args.importer).replace(/\\/g, '/'),
},
},
none: undefined,
} as const satisfies Record<string, OnResolveResult | undefined>;

// https://github.com/defunctzombie/package-browser-field-spec
if (initialOptions.platform === 'browser') {
Expand All @@ -133,7 +174,7 @@ export const nodeModulesPolyfillPlugin = (options: NodePolyfillsOptions = {}): P
// would just return undefined for any browser field value,
// and we can safely switch to this in a major version.
if (browserFieldValue === false) {
return emptyResult;
return result.empty;
}

if (browserFieldValue !== undefined) {
Expand All @@ -142,24 +183,20 @@ export const nodeModulesPolyfillPlugin = (options: NodePolyfillsOptions = {}): P
}

const moduleName = normalizeNodeBuiltinPath(args.path);
const polyfillOption = modules[moduleName];

const fallbackResult =
fallback === 'empty'
? emptyResult // Stub it out with an empty module
: undefined; // Opt out of resolving it entirely so esbuild/other plugins can handle it

if (!modules[moduleName]) {
return fallbackResult;
if (!polyfillOption) {
return result[fallback];
}

if (modules[moduleName] === 'empty') {
return emptyResult;
if (polyfillOption === 'error' || polyfillOption === 'empty') {
return result[polyfillOption];
}

const polyfill = await getCachedPolyfillPath(moduleName).catch(() => null);
const polyfillPath = await getCachedPolyfillPath(moduleName).catch(() => null);

if (!polyfill) {
return fallbackResult;
if (!polyfillPath) {
return result[fallback];
}

const ignoreRequire = args.namespace === commonjsNamespace;
Expand All @@ -173,6 +210,42 @@ export const nodeModulesPolyfillPlugin = (options: NodePolyfillsOptions = {}): P
};

onResolve({ filter }, resolver);

onEnd(({ outputFiles = [] }) => {
// This logic needs to be run when the build is complete because
// we need to check the output files after tree-shaking has been
// performed. If we did this in the onLoad hook, we could throw
// errors for modules that are not even present in the final
// output. This is particularly important when building projects
// that target both server and browser since the browser build
// may not use all of the modules that the server build does. If
// you're only building for the browser, this feature is less
// useful since any unpolyfilled modules will be treated just
// like any other missing module.

if (!shouldDetectErrorModules) return;

const errors: PartialMessage[] = [];

const { outfile, outExtension = {} } = initialOptions;
const jsExtension = outfile ? path.extname(outfile) : outExtension['.js'] || '.js';
const jsFiles = outputFiles.filter((file) => path.extname(file.path) === jsExtension);

for (const file of jsFiles) {
const matches = file.text.matchAll(
/__POLYFILL_ERROR_START__::MODULE::(?<module>.+?)::IMPORTER::(?<importer>.+?)::__POLYFILL_ERROR_END__/g,
);

for (const { groups } of matches) {
errors.push({
pluginName: name,
text: `Module "${groups!.module}" is not polyfilled, imported by "${groups!.importer}"`,
});
}
}

return { errors };
});
},
};
};
7 changes: 7 additions & 0 deletions tests/fixtures/input/errorFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as crypto from 'node:crypto';
import * as path from 'node:path';
import * as trace_events from 'node:trace_events';

console.log(crypto);
console.log(trace_events);
console.log(path);
7 changes: 7 additions & 0 deletions tests/fixtures/input/errorModules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';

console.log(crypto);
console.log(fs);
console.log(path);
Loading

0 comments on commit c02060d

Please sign in to comment.