diff --git a/README.md b/README.md index 3e07f4a..27015af 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/src/lib/plugin.ts b/src/lib/plugin.ts index 2675216..908a2a3 100644 --- a/src/lib/plugin.ts +++ b/src/lib/plugin.ts @@ -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; + modules?: string[] | Record; name?: string; namespace?: string; } @@ -56,7 +57,13 @@ const loader = async (args: esbuild.OnLoadArgs): Promise = }; 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`); } @@ -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'; @@ -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 => { - 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; // https://github.com/defunctzombie/package-browser-field-spec if (initialOptions.platform === 'browser') { @@ -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) { @@ -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; @@ -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::(?.+?)::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 }; + }); }, }; }; diff --git a/tests/fixtures/input/errorFallback.ts b/tests/fixtures/input/errorFallback.ts new file mode 100644 index 0000000..8ecc52e --- /dev/null +++ b/tests/fixtures/input/errorFallback.ts @@ -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); diff --git a/tests/fixtures/input/errorModules.ts b/tests/fixtures/input/errorModules.ts new file mode 100644 index 0000000..ba1eee1 --- /dev/null +++ b/tests/fixtures/input/errorModules.ts @@ -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); diff --git a/tests/scenarios/errorFallback.test.ts b/tests/scenarios/errorFallback.test.ts new file mode 100644 index 0000000..f94c6da --- /dev/null +++ b/tests/scenarios/errorFallback.test.ts @@ -0,0 +1,150 @@ +import esbuild, { type BuildOptions, type Message } from 'esbuild'; + +import { buildAbsolutePath, createEsbuildConfig } from '../util'; + +import type { NodePolyfillsOptions } from '../../dist'; + +function createConfig(buildOptions: Omit, pluginOptions?: NodePolyfillsOptions): BuildOptions { + return createEsbuildConfig( + { + format: 'iife', + entryPoints: [buildAbsolutePath('./fixtures/input/errorFallback.ts')], + ...buildOptions, + }, + pluginOptions, + ); +} + +describe('Error Fallback Test', () => { + test('GIVEN a file that imports a node builtins that are defined as errors in the modules config THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: false, + }, + { + fallback: 'error', + modules: { + path: true, + trace_events: true, // This will be a fallback since it's not polyfilled + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "Module \\"node:crypto\\" is not polyfilled, imported by \\"tests/fixtures/input/errorFallback.ts\\"", + "Module \\"node:trace_events\\" is not polyfilled, imported by \\"tests/fixtures/input/errorFallback.ts\\"", + ] + `); + expect(errors).toHaveLength(2); + }); + + test('GIVEN outfile maps to a .mjs file THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: false, + outdir: undefined, + outfile: 'out.mjs', + }, + { + fallback: 'error', + modules: { + path: true, + trace_events: true, // This will be a fallback since it's not polyfilled + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "Module \\"node:crypto\\" is not polyfilled, imported by \\"tests/fixtures/input/errorFallback.ts\\"", + "Module \\"node:trace_events\\" is not polyfilled, imported by \\"tests/fixtures/input/errorFallback.ts\\"", + ] + `); + expect(errors).toHaveLength(2); + }); + + test('GIVEN outExtension maps .js to .mjs THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: false, + outExtension: { + '.js': '.mjs', + }, + }, + { + fallback: 'error', + modules: { + path: true, + trace_events: true, // This will be a fallback since it's not polyfilled + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "Module \\"node:crypto\\" is not polyfilled, imported by \\"tests/fixtures/input/errorFallback.ts\\"", + "Module \\"node:trace_events\\" is not polyfilled, imported by \\"tests/fixtures/input/errorFallback.ts\\"", + ] + `); + expect(errors).toHaveLength(2); + }); + + test('GIVEN write mode is enabled when using error polyfill fallback THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: true, + }, + { + fallback: 'error', + modules: { + path: true, + trace_events: true, // This will be a fallback since it's not polyfilled + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "The \\"write\\" build option must be set to false when using the \\"error\\" polyfill type", + ] + `); + expect(errors).toHaveLength(1); + }); +}); diff --git a/tests/scenarios/errorModules.test.ts b/tests/scenarios/errorModules.test.ts new file mode 100644 index 0000000..f83dbd8 --- /dev/null +++ b/tests/scenarios/errorModules.test.ts @@ -0,0 +1,153 @@ +import esbuild, { type BuildOptions, type Message } from 'esbuild'; + +import { buildAbsolutePath, createEsbuildConfig } from '../util'; + +import type { NodePolyfillsOptions } from '../../dist'; + +function createConfig( + buildOptions: Pick, + pluginOptions?: NodePolyfillsOptions, +): BuildOptions { + return createEsbuildConfig( + { + format: 'iife', + entryPoints: [buildAbsolutePath('./fixtures/input/errorModules.ts')], + ...buildOptions, + }, + pluginOptions, + ); +} + +describe('Error Modules Test', () => { + test('GIVEN a file that imports a node builtins that are defined as errors in the modules config THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: false, + }, + { + modules: { + crypto: 'error', + fs: 'error', + path: true, + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "Module \\"node:crypto\\" is not polyfilled, imported by \\"tests/fixtures/input/errorModules.ts\\"", + "Module \\"node:fs\\" is not polyfilled, imported by \\"tests/fixtures/input/errorModules.ts\\"", + ] + `); + expect(errors).toHaveLength(2); + }); + + test('GIVEN outfile maps to a .mjs file THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: false, + outdir: undefined, + outfile: 'out.mjs', + }, + { + modules: { + crypto: 'error', + fs: 'error', + path: true, + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "Module \\"node:crypto\\" is not polyfilled, imported by \\"tests/fixtures/input/errorModules.ts\\"", + "Module \\"node:fs\\" is not polyfilled, imported by \\"tests/fixtures/input/errorModules.ts\\"", + ] + `); + expect(errors).toHaveLength(2); + }); + + test('GIVEN outExtension maps .js to .mjs THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: false, + outExtension: { + '.js': '.mjs', + }, + }, + { + modules: { + crypto: 'error', + fs: 'error', + path: true, + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "Module \\"node:crypto\\" is not polyfilled, imported by \\"tests/fixtures/input/errorModules.ts\\"", + "Module \\"node:fs\\" is not polyfilled, imported by \\"tests/fixtures/input/errorModules.ts\\"", + ] + `); + expect(errors).toHaveLength(2); + }); + + test('GIVEN write mode is enabled when using error polyfill modules THEN fail the build with appropriate error messages', async () => { + const config = createConfig( + { + write: true, + }, + { + modules: { + crypto: 'error', + fs: 'error', + path: true, + }, + }, + ); + + let errors: Message[] | undefined; + + try { + await esbuild.build(config); + } catch (error) { + // @ts-expect-error error isn't type safe + errors = error.errors; + } + + expect(errors?.map((error) => error.text)).toMatchInlineSnapshot(` + [ + "The \\"write\\" build option must be set to false when using the \\"error\\" polyfill type", + ] + `); + expect(errors).toHaveLength(1); + }); +}); diff --git a/tests/util.ts b/tests/util.ts index 1aa3fee..2f4bc5d 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -7,14 +7,14 @@ import { nodeModulesPolyfillPlugin, type NodePolyfillsOptions } from '../dist/in import type { BuildOptions } from 'esbuild'; export function createEsbuildConfig( - buildOptions: BuildOptions, + buildOptions: Omit, nodePolyfillsOptions?: NodePolyfillsOptions, ): BuildOptions { return { platform: 'node', - ...buildOptions, outdir: buildAbsolutePath('./fixtures/output'), bundle: true, + ...buildOptions, plugins: [nodeModulesPolyfillPlugin(nodePolyfillsOptions)], }; }