UNPKG

@oazmi/esbuild-plugin-deno

Version:

A portable non-invasive suite of esbuild plugins for loading http, jsr, npm, and import-maps. Supports Deno, Node, Bun, Web. Alternate to @luca/esbuild-deno-loader , while compatible with other plugins and native esbuild resolvers (e.g. css loader).

308 lines (307 loc) 19.2 kB
/** this submodule contains a namespaced esbuild plugin that resolves paths based on the three strategies: * _runtime-package_ resolution, _import-map_ resolution, and _absolute-path_ resolution. * * > [!note] * > the default namespace you should use in your plugins to call this pipeline of resolvers is {@link PLUGIN_NAMESPACE.RESOLVER_PIPELINE}. * > otherwise, if you want, you can customize the namespace of this plugin by specifying it in the `config.namespace` option. * * below is a summary what the path resolution procedure of this plugin entails, given a certain esbuild input {@link OnResolveArgs | `args`}: * 1. first, if {@link WorkspacePackage | `args.pluginData.runtimePackage`} exists, * then the plugin tries to resolve the input `args.path` through the use of the {@link WorkspacePackage.resolveImport} method. * - if it succeeds (i.e. `args.path` was an alias/import-map specified in the package file), * then the resolved path, along with the original `args.pluginData`, is returned. * - if it fails, then it tries to resolve the input `args.path` against any potential workspace package directories (specified in the `deno.json` config), * through the use of the {@link WorkspacePackage.resolveWorkspaceImport} method. * - otherwise, if workspace resolution fails, then the next path resolution takes place (import-map). * 2. next, if either {@link ImportMap | `args.pluginData.importMap`} exists, * or {@link ImportMapResolverConfig.globalImportMap | `config.importMap.globalImportMap`} exists, * then the plugin will try to resolve the input `args.path` with respect to these import-maps, * through the use of the {@link resolvePathFromImportMap} function. * - if it succeeds (i.e. `args.path` was part of the import-map(s)), * then the resolved path, along with the original `args.pluginData`, is returned. * - otherwise, if it fails, then the next path resolution will take place (node-modules). * 3. up ahead, we let esbuild's native resolver give a shot at resolving `args.path` as a node-module package, * if and only if the path is neither a relative path (i.e. doesn't begin with `"./"` or `"../"`), * nor is it an absolute path (such as "http://example.com/...", or "C:/users/..."). * this is because these scenarios can be handled much quicker by the next path-resolver that follows this one * (since that one does not perform filesystem read/scan operations, unlike esbuild's native resolver). * - if it succeeds (i.e. `args.path` was indeed some package inside some `"./node_modules/"` folder up the directory tree), * then the resolved absolute filesystem path of the resource will be returned. * - otherwise, if it fails, then the next path resolution will take place (path-joining). * > [!tip] * > under the hood, this function uses the {@link nodeModulesResolverFactory} function that initializes a mock build process, * > which lets us indirectly query esbuild for the resolved path to the resource (if any). * 4. finally, when all else has failed and we've made it to here, and `args.path` is a relative path: * - then we'll simply compute the absolute path of the input `args.path` by combining it with `args.importer`, and `args.resolveDir`. * - if neither of those two arguments are non-empty strings, then we will resort to using esbuild's `absWorkingDir` build option. * - if `absWorkingDir` was itself not specified, then we will fallback to the runtime's current-working-directory as the base directory of `args.path`. * - and again, the original `args.pluginData` is returned along with the result, so that the child dependnecies can inherit it. * * @module */ import { array_isArray, DEBUG, defaultResolvePath, ensureEndSlash, ensureFileUrlIsLocalPath, getUriScheme, isAbsolutePath, isCertainlyRelativePath, joinPaths, noop, pathToPosixPath, promise_outside, resolveAsUrl } from "../deps.js"; import { resolvePathFromImportMap } from "../importmap/mod.js"; import { logLogger } from "./funcdefs.js"; import { PLUGIN_NAMESPACE } from "./typedefs.js"; const defaultRuntimePackageResolverConfig = { enabled: true, }, defaultImportMapResolverConfig = { enabled: true, globalImportMap: {}, }, defaultNodeModulesResolverConfig = { enabled: true, }, defaultRelativePathResolverConfig = { enabled: true, resolvePath: defaultResolvePath, isAbsolutePath: isAbsolutePath, }; const defaultResolverPluginSetupConfig = { runtimePackage: defaultRuntimePackageResolverConfig, importMap: defaultImportMapResolverConfig, nodeModules: defaultNodeModulesResolverConfig, relativePath: defaultRelativePathResolverConfig, namespace: PLUGIN_NAMESPACE.RESOLVER_PIPELINE, log: false, }; /** this is a 4-in-1 namespaced-plugin that assists in resolving esbuild paths, * based on {@link CommonPluginData | `pluginData`}, esbuild's native-resolver, and relative path resolver. * * for details on what gets resolved an how, refer to the documentation comments of this submodule ({@link "plugins/resolvers"}). * * the way it is intended to be used is by being called indirectly via `build.resolve`, after specifying the correct namespace of this plugin. * * @example * ```ts * import type { EsbuildPlugin, EsbuildPluginBuild, OnLoadArgs, OnResolveArgs } from "./typedefs.ts" * import { PLUGIN_NAMESPACE } from "./typedefs.ts" * * const THIS_plugins_namespace = PLUGIN_NAMESPACE.RESOLVER_PIPELINE // == "oazmi-resolver-pipeline" * * const myShinyPlugin: EsbuildPlugin = { * name: "my-shiny-plugin", * setup: (build: EsbuildPluginBuild) => { * const everything_shiny_filter = /\.shiny$/ * * build.onResolve({ filter: everything_shiny_filter }, async (args: OnResolveArgs) => { * const { path, ...rest_args } = args * const result = await build.resolve(path, { ...rest_args, namespace: THIS_plugins_namespace }) * if (result) { * const { * path: absolute_path, * pluginData: useful_plugindata, * // there will be a temporary namespace that you MUST drop. * // the reason why it exists is because esbuild forbids the use of the default namespace, * // unless the path is explicitly an absolute filesystem path. * // so paths that begin with "http://", "file://", "jsr:", etc... are all invalidated by esbuild if `namespace = ""`. * namespace: _drop_the_temp_namespace, * ...rest_result * } = result * return { * path: absolute_path, * pluginData: useful_plugindata, * namespace: "my-shiny-namespace", * ...rest_result, * } * } * }) * * build.onLoad({ filter: RegExp(".*"), namespace: "my-shiny-namespace" }, async (args: OnLoadArgs) => { * const { path, pluginData } = args * const contents = "" // load your shiny stuff here * return { contents, loader: "ts", pluginData } * }) * } * } * ``` */ export const resolverPluginSetup = (config) => { const { runtimePackage: _runtimePackageResolverConfig, importMap: _importMapResolverConfig, nodeModules: _nodeModulesResolverConfig, relativePath: _relativePathResolverConfig, namespace: plugin_ns, log, } = { ...defaultResolverPluginSetupConfig, ...config }; const runtimePackageResolverConfig = { ...defaultRuntimePackageResolverConfig, ..._runtimePackageResolverConfig }, importMapResolverConfig = { ...defaultImportMapResolverConfig, ..._importMapResolverConfig }, nodeModulesResolverConfig = { ...defaultNodeModulesResolverConfig, ..._nodeModulesResolverConfig }, relativePathResolverConfig = { ...defaultRelativePathResolverConfig, ..._relativePathResolverConfig }, logFn = log ? (log === true ? logLogger : log) : undefined, // a non-empty string namespace is required if the file is not an absolute path on the filesystem. output_ns = "discard-this-namespace", plugin_filter = /.*/; return async (build) => { // if `build.initialOptions.absWorkingDir` is itself a relative path, then we'll resolve it relative to the current-working-directory, // via the `resolvePath` function inside of the `relativePathResolver`. const absWorkingDir = pathToPosixPath(build.initialOptions.absWorkingDir ?? "./"); const externalResourceSet = new Set(build.initialOptions.external); // before everything, we will ensure that the `path` being resolved is not among the external modules. const externalPathResolver = (externalResourceSet.size <= 0) ? noop : async (args) => { const { path, pluginData = {} } = args, is_external = externalResourceSet.has(path); if (DEBUG.LOG && logFn) { logFn(`[external-path] checking: ${path}` + (!is_external ? "" : `\n>> successfully verified the path to be external`)); } return is_external ? { path, external: true, namespace: output_ns, pluginData: { ...pluginData }, } : undefined; }; // first comes the runtime-package-manager resolver, if it is enabled, that is. const runtimePackageResolver = (runtimePackageResolverConfig.enabled === false) ? noop : async (args) => { if (args.pluginData?.resolverConfig?.useRuntimePackage === false) { return; } const { path, pluginData = {} } = args, runtimePackage = pluginData.runtimePackage, // if the input `path` is an import being performed inside of a package, in addition to not being a relative import, // then use the package manager to resolve the imported path. resolved_result = runtimePackage && !isCertainlyRelativePath(path) ? (runtimePackage.resolveImport(path) ?? runtimePackage.resolveWorkspaceImport(path)) : undefined, [resolved_path, resolved_package] = array_isArray(resolved_result) ? resolved_result : [resolved_result, runtimePackage]; if (DEBUG.LOG && logFn) { logFn(`[runtime-package] resolving: ${path}` + (!resolved_path ? "" : `\n>> successfully resolved to: ${resolved_path}`)); } return resolved_path ? { path: resolved_path, namespace: output_ns, pluginData: { ...pluginData, runtimePackage: resolved_package }, } : undefined; }; // second attempt at resolving the path is made by the import-map and global-import-map resolver (if it is enabled). const { globalImportMap } = importMapResolverConfig; const importMapResolver = (importMapResolverConfig.enabled === false) ? noop : async (args) => { if (args.pluginData?.resolverConfig?.useImportMap === false) { return; } const { path, pluginData = {} } = args, importMap = { ...globalImportMap, ...pluginData.importMap }, // if the input `path` is an import being performed inside of a package, in addition to not being a relative import, // then use the package manager to resolve the imported path. resolved_path = resolvePathFromImportMap(path, importMap); if (DEBUG.LOG && logFn) { logFn(`[import-map] resolving: ${path}` + (!resolved_path ? "" : `\n>> successfully resolved to: ${resolved_path}`)); } return resolved_path ? { path: resolved_path, namespace: output_ns, pluginData: { ...pluginData }, } : undefined; }; // third attempt at resolving the path should be made by esbuild's native `node_modules` resolver. const { resolvePath, isAbsolutePath } = relativePathResolverConfig; const node_modules_resolver = nodeModulesResolverFactory({ absWorkingDir: resolvePath(ensureEndSlash(absWorkingDir)) }, build); const nodeModulesResolver = (nodeModulesResolverConfig.enabled === false) ? noop : async (args) => { const { path, resolveDir, importer, pluginData = {} } = args; if (pluginData.resolverConfig?.useNodeModules === false) { return; } // for the sake of speed, we don't bother esbuild for resolving relative paths, // since the next resolver (`relativePathResolver`) will do this simple task much faster, without reading the filesystem. if ((pluginData.resolverConfig?.useRelativePath !== false) && (isCertainlyRelativePath(path) || isAbsolutePath(path))) { return; } const resolve_dir = resolvePath(ensureEndSlash(resolveDir ? resolveDir : absWorkingDir)), module_path_alias = pathToPosixPath(path), native_results_promise = node_modules_resolver({ importer, path: module_path_alias, resolveDir: resolve_dir, }); const { path: resolved_path, namespace: _0, pluginData: _1, ...rest_results } = await (native_results_promise .catch(() => { return {}; })); if (DEBUG.LOG && logFn) { logFn(`[node-module] resolving: ${path}` + (!resolved_path ? "" : `\n>> successfully resolved to: ${resolved_path}`)); } return resolved_path ? { ...rest_results, path: resolved_path, namespace: output_ns, pluginData: { ...pluginData }, } : undefined; }; // final attempt at resolving the path is made by simply joining potentially relative paths with the `importer` (if enabled). const relativePathResolver = (relativePathResolverConfig.enabled === false) ? noop : async (args) => { if (args.pluginData?.resolverConfig?.useRelativePath === false) { return; } const { path, importer, resolveDir, pluginData = {} } = args, resolve_dir = resolvePath(ensureEndSlash(resolveDir ? resolveDir : absWorkingDir)), dir = isAbsolutePath(importer) ? importer : joinPaths(resolve_dir, importer), resolved_path = resolvePath(path, dir ? dir : undefined); if (DEBUG.LOG && logFn) { logFn(`[absolute-path] resolving: ${path}` + (!resolved_path ? "" : `\n>> successfully resolved to: ${resolved_path}`)); } return { path: resolved_path, namespace: output_ns, pluginData: { ...pluginData }, }; }; build.onResolve({ filter: plugin_filter, namespace: plugin_ns }, externalPathResolver); build.onResolve({ filter: plugin_filter, namespace: plugin_ns }, runtimePackageResolver); build.onResolve({ filter: plugin_filter, namespace: plugin_ns }, importMapResolver); build.onResolve({ filter: plugin_filter, namespace: plugin_ns }, nodeModulesResolver); build.onResolve({ filter: plugin_filter, namespace: plugin_ns }, relativePathResolver); }; }; /** {@inheritDoc resolverPluginSetup} */ export const resolverPlugin = (config) => { return { name: "oazmi-plugindata-resolvers", setup: resolverPluginSetup(config), }; }; /** this factory function creates a path-resolver function that mimics esbuild's native-resolver's behavior. * * the slyish way how it works is that we create a new build process to query esbuild what it would hypothetically do if so and so input arguments were given, * then through a custom inner plugin, we capture esbuild's response (the resolved path), and return it back to the user. * * > [!note] * > the internal resolver function accepts `"file://"` uris for the following list of {@link OnResolveArgs} fields, * > and converts them to local paths for esbuild to understand: * > - `path` * > - `resolveDir` * > - `importer` * > * > with this feature, you won't have to worry about file-uris at all. */ export const nodeModulesResolverFactory = (config, build) => { const { absWorkingDir } = config; const internalPluginSetup = (config) => { return (build) => { const ALREADY_CAPTURED = Symbol(), plugin_ns = "the-void", { resolve, reject, resolveDir, importer = "" } = config, // below, in case the original `importer` had used a "file://" uri, we convert it back into a local path, // otherwise we strip away the importer, since it will not be suitable for usage as `resolveDir`, // since esbuild only understands local filesystem paths - no file/http uris. importer_path_scheme = getUriScheme(importer), importer_dir_as_file_uri = (importer_path_scheme === "local" || importer_path_scheme === "file") ? resolveAsUrl("./", importer).href : undefined, resolve_dir = importer_dir_as_file_uri ?? resolveDir; if (DEBUG.ASSERT && resolve_dir === "") { logLogger(`[nodeModulesResolverFactory]: WARNING! received an empty resolve directory ("args.resolveDir").`, `\n\twe will fallback to esbuild's current-working-directory for filling in the "resolveDir" value,`, `\n\thowever, you must be using the "nodeModulesResolverFactory" function incorrectly to have encountered this situation.`, `\n\tremember, the purpose of this function is to scan for a node-module, starting from a directory that YOU provide.`); } build.onResolve({ filter: /.*/ }, async (args) => { if (args.pluginData?.[ALREADY_CAPTURED] === true) { return; } const { path, external, namespace, sideEffects, suffix } = await build.resolve(ensureFileUrlIsLocalPath(args.path), { kind: "entry-point", resolveDir: ensureFileUrlIsLocalPath(resolve_dir !== "" ? resolve_dir : args.resolveDir), pluginData: { [ALREADY_CAPTURED]: true }, }); resolve({ path: pathToPosixPath(path), external, namespace, sideEffects, suffix }); return { path: "does-not-matter.js", namespace: plugin_ns }; }); build.onLoad({ filter: /.*/, namespace: plugin_ns }, () => ({ contents: "", loader: "empty" })); }; }; return async (args) => { const { path, resolveDir: _resolveDir = "", importer } = args, resolveDir = _resolveDir === "" ? (absWorkingDir ?? "") : _resolveDir, [promise, resolve, reject] = promise_outside(), internalPlugin = { name: "native-esbuild-resolver-capture", setup: internalPluginSetup({ resolve, reject, resolveDir, importer }), }; await (build.esbuild.build({ entryPoints: [path], absWorkingDir: absWorkingDir, bundle: false, minify: false, write: false, outdir: "./temp/", plugins: [internalPlugin], })).catch(() => { reject("esbuild's native resolver failed to resolve the path"); }); return promise; }; };