/** * Copied from https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/deployment-bundle/esbuild-plugins/hybrid-nodejs-compat.ts#L17 */ import type { Plugin, PluginBuild } from "esbuild"; import assert from "node:assert"; import { createRequire } from "node:module"; import nodePath from "pathe"; import { dedent } from "../../util/dedent.ts"; import { NODEJS_MODULES_RE } from "./nodejs-builtin-modules.ts"; const _require = typeof require === "undefined" ? createRequire(import.meta.url) : require; const REQUIRED_NODE_BUILT_IN_NAMESPACE = "node-built-in-modules"; const REQUIRED_UNENV_ALIAS_NAMESPACE = "required-unenv-alias"; /** * ESBuild plugin to apply the unenv preset. * * @returns ESBuild plugin */ export function esbuildPluginHybridNodeCompat({ compatibilityDate, compatibilityFlags, }: { compatibilityDate?: string; compatibilityFlags?: string[]; }): Plugin { return { name: "hybrid-nodejs_compat", async setup(build) { // `unenv` and `@cloudflare/unenv-preset` only publish esm const { defineEnv } = await import("unenv"); const { getCloudflarePreset } = await import("@cloudflare/unenv-preset"); const { alias, inject, external, polyfill } = defineEnv({ presets: [ getCloudflarePreset({ compatibilityDate, compatibilityFlags, }), { alias: { // Force esbuild to use the node implementation of debug instead of unenv's no-op stub. // The alias is processed by handleUnenvAliasedPackages which uses require.resolve(). debug: "debug", }, }, ], npmShims: true, }).env; errorOnServiceWorkerFormat(build); handleRequireCallsToNodeJSBuiltins(build); handleUnenvAliasedPackages(build, alias, external); handleNodeJSGlobals(build, inject, polyfill); }, }; } /** * If we are bundling a "Service Worker" formatted Worker, imports of external modules, * which won't be inlined/bundled by esbuild, are invalid. * * This `onResolve()` handler will error if it identifies node.js external imports. */ function errorOnServiceWorkerFormat(build: PluginBuild) { const paths = new Set(); build.onStart(() => paths.clear()); build.onResolve({ filter: NODEJS_MODULES_RE }, (args) => { paths.add(args.path); return null; }); build.onEnd(() => { if (build.initialOptions.format === "iife" && paths.size > 0) { const pathList = new Intl.ListFormat("en-US").format( Array.from(paths.keys()) .map((p) => `"${p}"`) .sort(), ); return { errors: [ { text: dedent` Unexpected external import of ${pathList}. Your worker has no default export, which means it is assumed to be a Service Worker format Worker. Did you mean to create a ES Module format Worker? If so, try adding \`export default { ... }\` in your entry-point. See https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/. `, }, ], }; } }); } /** * We must convert `require()` calls for Node.js modules to a virtual ES Module that can be imported avoiding the require calls. * We do this by creating a special virtual ES module that re-exports the library in an onLoad handler. * The onLoad handler is triggered by matching the "namespace" added to the resolve. */ function handleRequireCallsToNodeJSBuiltins(build: PluginBuild) { build.onResolve({ filter: NODEJS_MODULES_RE }, (args) => { if (args.kind === "require-call") { return { path: args.path, namespace: REQUIRED_NODE_BUILT_IN_NAMESPACE, }; } }); build.onLoad( { filter: /.*/, namespace: REQUIRED_NODE_BUILT_IN_NAMESPACE }, ({ path }) => { return { contents: dedent` import libDefault from '${path}'; module.exports = libDefault;`, loader: "js", }; }, ); } /** * Handles aliased NPM packages. * * @param build ESBuild PluginBuild. * @param alias Aliases resolved to absolute paths. * @param external external modules. */ function handleUnenvAliasedPackages( build: PluginBuild, alias: Record, external: readonly string[], ) { // esbuild expects alias paths to be absolute const aliasAbsolute: Record = {}; for (const [module, unresolvedAlias] of Object.entries(alias)) { try { aliasAbsolute[module] = _require.resolve(unresolvedAlias); } catch { // this is an alias for package that is not installed in the current app => ignore } } const UNENV_ALIAS_RE = new RegExp( `^(${Object.keys(aliasAbsolute).join("|")})$`, ); build.onResolve({ filter: UNENV_ALIAS_RE }, (args) => { const unresolvedAlias = alias[args.path]; // Convert `require()` calls for NPM packages to a virtual ES Module that can be imported avoiding the require calls. // Note: Does not apply to Node.js packages that are handled in `handleRequireCallsToNodeJSBuiltins` if ( args.kind === "require-call" && (unresolvedAlias.startsWith("unenv/npm/") || unresolvedAlias.startsWith("unenv/mock/")) ) { return { path: args.path, namespace: REQUIRED_UNENV_ALIAS_NAMESPACE, }; } // Resolve the alias to its absolute path and potentially mark it as external return { path: aliasAbsolute[args.path], external: external.includes(unresolvedAlias), }; }); build.onLoad( { filter: /.*/, namespace: REQUIRED_UNENV_ALIAS_NAMESPACE }, ({ path }) => { return { contents: dedent` import * as esm from '${path}'; module.exports = Object.entries(esm) .filter(([k,]) => k !== 'default') .reduce((cjs, [k, value]) => Object.defineProperty(cjs, k, { value, enumerable: true }), "default" in esm ? esm.default : {} );`, loader: "js", }; }, ); } /** * Inject node globals defined in unenv's preset `inject` and `polyfill` properties. * * - an `inject` injects virtual module defining the name on `globalThis` * - a `polyfill` is injected directly */ function handleNodeJSGlobals( build: PluginBuild, inject: Record, polyfill: readonly string[], ) { const UNENV_VIRTUAL_MODULE_RE = /_virtual_unenv_global_polyfill-(.+)$/; const prefix = nodePath.resolve( import.meta.dirname, "_virtual_unenv_global_polyfill-", ); /** * Map of module identifiers to * - `injectedName`: the name injected on `globalThis` * - `exportName`: the export name from the module * - `importName`: the imported name */ const injectsByModule = new Map< string, { injectedName: string; exportName: string; importName: string }[] >(); // Module specifier (i.e. `/unenv/runtime/node/...`) keyed by path (i.e. `/prefix/_virtual_unenv_global_polyfill-...`) const virtualModulePathToSpecifier = new Map(); for (const [injectedName, moduleSpecifier] of Object.entries(inject)) { const [module, exportName, importName] = Array.isArray(moduleSpecifier) ? [moduleSpecifier[0], moduleSpecifier[1], moduleSpecifier[1]] : [moduleSpecifier, "default", "defaultExport"]; if (!injectsByModule.has(module)) { injectsByModule.set(module, []); virtualModulePathToSpecifier.set( prefix + module.replaceAll("/", "-"), module, ); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion injectsByModule.get(module)!.push({ injectedName, exportName, importName }); } build.initialOptions.inject = [ ...(build.initialOptions.inject ?? []), // Inject the virtual modules ...virtualModulePathToSpecifier.keys(), // Inject the polyfills - needs an absolute path ...polyfill.map((m) => _require.resolve(m)), ]; build.onResolve({ filter: UNENV_VIRTUAL_MODULE_RE }, ({ path }) => ({ path, })); build.onLoad({ filter: UNENV_VIRTUAL_MODULE_RE }, ({ path }) => { const module = virtualModulePathToSpecifier.get(path); assert(module, `Expected ${path} to be mapped to a module specifier`); const injects = injectsByModule.get(module); assert(injects, `Expected ${module} to inject values`); const imports = injects.map(({ exportName, importName }) => importName === exportName ? exportName : `${exportName} as ${importName}`, ); return { contents: dedent` import { ${imports.join(", ")} } from "${module}"; ${injects.map(({ injectedName, importName }) => `globalThis.${injectedName} = ${importName};`).join("\n")}`, }; }); }