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).

246 lines (245 loc) 17.4 kB
/** this filter plugin intercepts all entities and makes them sequentially pass through three resolvers, * so that an absolute path to the resource can be acquired in the best possible way (citation needed). * * > [!important] * > you **must** include the {@link resolverPlugin} somewhere in your esbuild build-options, * > otherwise this plugin will not be able to resolve to absolute paths on its own, * > and all efforts of this plugin will pretty much be for naught. * * ## details * * an explanation of the operations performed by each of the three resolvers follows: * * ### 1. initial plugin-data injector * * this resolver simply inserts the user's {@link EntryPluginSetupConfig.initialPluginData} to **all** entry-points which lack an `args.pluginData`. * but when {@link EntryPluginSetupConfig.forceInitialPluginData} is `true`, the entry-points with existing pluginData will be overwritten. * * it is through this resolver that things like {@link CommonPluginData.importMap | import-maps} * and the {@link CommonPluginData.runtimePackage | deno-package-resolver} get inserted into the entry-point resources, * and then seeded into their dependencies. * * ### 2. plugin-data inheritor * * a fundamental problem (design choice?) with esbuild is that resource's `pluginData` **does not** propagate to the child-dependencies, * when esbuild's native resolvers and loaders process the resource. * this problem of stripping away valuable `pluginData` can also occur in external plugins that are not designed mindfully. * * as a consequence, we would not be able to embed import-maps and runtime-package-resolvers that can be picked up by dependencies. * * thus, to counteract this issue, this resolver inspects the `pluginData` of every resolved entity, stores it in a dictionary for book-keeping, * and then, when a dependency-resource comes along with its plugin-data stripped away (i.e. `args.pluginData === undefined`), * this resolver inserts its dependent's (`args.importer`) plugin-data. * in effect, this resolver permits inheritance of plugin-data when it is stripped away. * * ### 3. absolute-path resolution * * finally, we come to the resolver which implicitly calls the namespaced resolvers of the {@link resolverPlugin}, * which is a pipeline that can resolve {@link CommonPluginData.importMap | import-maps}, * {@link CommonPluginData.runtimePackage | deno-packages}, * {@link nodeModulesResolverFactory | node-packages (`node_modules`)}, * and perform generic path-joining, in order to get the absolute path (doesn't have to be filesystem path) to the resource. * * most resolvers of the {@link resolverPlugin} require {@link CommonPluginData | `pluginData`} to be useful. * which is why we have to ensure its availability and inheritance through the two prior resolvers, * in order for the {@link resolverPlugin} to be effective. * * --- * * > [!tip] * > while the placement order of the {@link resolverPlugin} does not matter (invariant behavior), * > placing it at the front would reduce the number of redundant `onResolve` callbacks received by this plugin, * > which then need to be bounced-back to prevent an indefinite `onResolve` recursion. * > * > you may wonder how this plugin does not spiral into an endless recursion of `onResolve` callbacks, * > when it uses the "capture-all" filter that spans throughout all namespaces, meanwhile using `build.resove()`. * > * > that's because upon the interception of a new entity, we insert a unique `symbol` marker into the `pluginData`, * > which when detected again, halts the plugin from processing it further (by returning `undefined`). * > * > moreover, this plugin validates that the `args.namespace` it receives must be one of `[undefined, "", "file"]` * > (see {@link defaultEsbuildNamespaces}), otherwise it will terminate processing it any further. * > this check is put in place to prevent this plugin from treading into the territory of other plugins' namespaces, * > which would potentially ruin their logic and `pluginData`. * * @module */ import { bind_map_get, bind_map_has, bind_map_set, DEBUG, isString, pathToPosixPath, resolveAsUrl } from "../../deps.js"; import { RuntimePackage } from "../../packageman/base.js"; import { DenoPackage } from "../../packageman/deno.js"; import { defaultEsbuildNamespaces, PLUGIN_NAMESPACE } from "../typedefs.js"; const defaultEntryPluginSetup = { filters: [/.*/], initialPluginData: undefined, forceInitialPluginData: false, enableInheritPluginData: true, acceptNamespaces: defaultEsbuildNamespaces, }; /** this filter plugin intercepts all entities, injects and propagates plugin-data (if the entity is an entry-point, or a dependency with no plugin-data), * and then makes them go through the {@link resolverPlugin} set of resolvers, in order to obtain the absolute path to the resource. * * for an explanation, see detailed comments of this submodule: {@link "plugins/filters/entry"}. */ export const entryPluginSetup = (config) => { const { filters, initialPluginData: _initialPluginData, forceInitialPluginData, enableInheritPluginData, acceptNamespaces: _acceptNamespaces } = { ...defaultEntryPluginSetup, ...config }, acceptNamespaces = new Set([..._acceptNamespaces, PLUGIN_NAMESPACE.LOADER_HTTP]), // contains a record of **all** resources that have passed through the `inheritPluginDataInjector`, // so that dependency resources which have been stripped out of their `pluginData` can inherit it back from their `importer`'s saved `pluginData`. // the keys are resolved paths (posix enforced), and the values are the plugin-data of the resolved resource. importerPluginDataRecord = new Map(), importerPluginDataRecord_get = bind_map_get(importerPluginDataRecord), importerPluginDataRecord_set = bind_map_set(importerPluginDataRecord), importerPluginDataRecord_has = bind_map_has(importerPluginDataRecord), ALREADY_CAPTURED_BY_INITIAL = Symbol(DEBUG.MINIFY ? "" : "[oazmi-entry]: already captured by initial-data-injector"), ALREADY_CAPTURED_BY_INHERITOR = Symbol(DEBUG.MINIFY ? "" : "[oazmi-entry]: already captured by inherit-data-injector"), ALREADY_CAPTURED_BY_RESOLVER = Symbol(DEBUG.MINIFY ? "" : "[oazmi-entry]: already captured by absolute-path-resolver"); return async (build) => { const { runtimePackage: initialRuntimePackage, ...rest_initialPluginData } = _initialPluginData ?? {}, initialPluginData = rest_initialPluginData, initialPluginDataExists = _initialPluginData !== undefined; build.onStart(async () => { // here we resolve the path to the "deno.json" file if `initialRuntimePackage` is either a url or a local path. // to resolve non-url-based file paths, we'll use the namespace of {@link resolverPlugin} // to carry out the path resolution inside of the {@link resolveRuntimePackage} function. initialPluginData.runtimePackage = await resolveRuntimePackage(build, initialRuntimePackage); }); /** this resolver simply inserts the user's {@link initialPluginData} to **all** entry-points which lack an `args.pluginData`. * but when {@link forceInitialPluginData} is `true`, the entry-points with existing pluginData will be overwritten. */ const initialPluginDataInjector = async (args) => { const { path, pluginData, ...rest_args } = args, { kind, namespace } = rest_args; // if the entity is not an entry-point, then skip it. if (kind !== "entry-point") { return; } // if the plugin marker already exists for this entity, then we've already processed it once, // therefore we should return `undefined` so that we don't end in an infinite onResolve recursion. // this way, the next resolver registered to esbuild (or its native resolver) will take up the task for resolving this entity. if ((pluginData ?? {})[ALREADY_CAPTURED_BY_INITIAL]) { return; } // since all namespaces are captured by the `onResolve` options, // we skip processing any resource with a namespace not in the `acceptNamespaces` list. if (!acceptNamespaces.has(namespace)) { return; } // if there is an existing non-empty plugin data and `forceInitialPluginData` is not enabled (default), then skip this entity if (pluginData !== undefined && !forceInitialPluginData) { return; } const merged_pluginData = forceInitialPluginData === "merge" ? { ...initialPluginData, ...pluginData, [ALREADY_CAPTURED_BY_INITIAL]: true } : { ...initialPluginData, [ALREADY_CAPTURED_BY_INITIAL]: true }; // below, we implicitly (hope to) call the `inheritPluginDataInjector`, so long as no other "capture-all" plugin exists before this plugin. const resolved_result = await build.resolve(path, { ...rest_args, pluginData: merged_pluginData }); // if esbuild's native resolver had resolved the `path`, then the `merged_pluginData` that we just inserted WILL be lost. // i.e. `resolved_result.pluginData === undefined` if esbuild's native resolver took care of the path-resolution. // in such cases, we would like to re-insert our `merged_pluginData` again, before returning the result resolved_result.pluginData ??= merged_pluginData; // NOTICE: we intentionally do not remove the `ALREADY_CAPTURED_BY_INITIAL` marker from the result's plugin-data. // even though it is practically impossible for this resource to somehow end up back inside this resolver, // we still keep it around for safety measures. return resolved_result; }; /** this resolver ensures that the incoming resources have some non-undefined `pluginData`, * otherwise it will insert the `pluginData` of its parent `importer` resource. * (i.e. dependency resources will inherit their parent importer's `pluginData` if they are lacking one) * * moreover, internally, a call to `build.resolver()` is made to resolve the path of the current resource. * however, if the resolved result is lacking a `pluginData` * (possibly due to being resolved by esbuild's native resolver, which strips away `pluginData`), * then the `pluginData` _prior_ to the path-resolution will be re-inserted into the result. */ const inheritPluginDataInjector = async (args) => { const { path, pluginData, ...rest_args } = args, { importer = "", namespace } = rest_args; if ((pluginData ?? {})[ALREADY_CAPTURED_BY_INHERITOR]) { return; } if (!acceptNamespaces.has(namespace)) { return; } // if `pluginData` is missing (and an `importer` exists), then inherit it from the parent `importer`, and retry (via recursion). if ((pluginData === undefined || pluginData === null) && importer !== "") { const parentPluginData = importerPluginDataRecord_get(pathToPosixPath(importer)); return parentPluginData ? inheritPluginDataInjector({ ...rest_args, path, pluginData: parentPluginData }) : undefined; } const prior_pluginData = { ...pluginData, [ALREADY_CAPTURED_BY_INHERITOR]: true }, // below, we implicitly (hope to) call the `absolutePathResolver`, so long as no other "capture-all" plugin exists before this plugin. resolved_result = await build.resolve(path, { ...rest_args, pluginData: prior_pluginData }), resolved_pluginData = { // if esbuild's native resolver had resolved the `path`, then the `prior_pluginData` WILL be lost, and we will need to re-insert it. ...(resolved_result.pluginData ?? prior_pluginData), // we must also disable the `ALREADY_CAPTURED_BY_INHERITOR` marker, since the `resolved_result` is ready to go to the loader, // however, we don't want the dependencies (which will inherit the `pluginData`) to have their capture marker set to `true`, // since they haven't actually been captured by this resolver yet. [ALREADY_CAPTURED_BY_INHERITOR]: false, }; // finally (if the `useInheritPluginData` option is not explicitly disabled), // we save the resolved plugin data to the global record, so that its dependencies can inherit it when needed. // TODO: consider the scenario where the same `path` is processed, // leading up to the same `resolved_result.path` that already exists in `importerPluginDataRecord`. // should we still update the record with a potentially new and different `resolved_pluginData`?, or should we abstain from that? // currently, I only accept the first plugin-data, and no more re-writes. if (resolved_pluginData.resolverConfig?.useInheritPluginData !== false) { const resolved_path = pathToPosixPath(resolved_result.path); if (!importerPluginDataRecord_has(resolved_path)) { importerPluginDataRecord_set(pathToPosixPath(resolved_result.path), resolved_pluginData); } } resolved_result.pluginData = resolved_pluginData; return resolved_result; }; /** by this point, our resource will have acquired any `pluginData` that was intended for it. * so all that is left to be done is to obtain the absolute-path to the resource, * by implicitly resolving it though the {@link resolverPlugin}'s namespace. * * once we obtain the resolved absolute-path, we run it though `build.resolve` again (but in the resource's original namespace this time), * so that whichever plugin that this resource was intended for (including esbuild's native resolver) * will receive it gracefully, and resolve it in their own accord. */ const absolutePathResolver = async (args) => { if ((args.pluginData ?? {})[ALREADY_CAPTURED_BY_RESOLVER]) { return; } if (!acceptNamespaces.has(args.namespace)) { return; } const { path, namespace: original_ns, ...rest_args } = args, abs_result = await build.resolve(path, { ...rest_args, namespace: PLUGIN_NAMESPACE.RESOLVER_PIPELINE }); const { path: abs_path, pluginData: abs_pluginData = {}, namespace: _0 } = abs_result, next_pluginData = { ...abs_pluginData, [ALREADY_CAPTURED_BY_RESOLVER]: true }, resolved_result = await build.resolve(abs_path, { ...rest_args, namespace: original_ns, pluginData: next_pluginData }); resolved_result.pluginData = { // if esbuild's native resolver had resolved the `path`, then the `next_pluginData` WILL be lost, and we will need to re-insert it. ...(resolved_result.pluginData ?? next_pluginData), // we must also disable the `ALREADY_CAPTURED_BY_RESOLVER` marker, since the `resolved_result` is ready to go to the loader, // however, we don't want the dependencies (which will inherit the `pluginData`) to have their capture marker set to `true`, // since they haven't actually been captured by this resolver yet. [ALREADY_CAPTURED_BY_RESOLVER]: false, }; return resolved_result; }; for (const filter of filters) { if (initialPluginDataExists) { build.onResolve({ filter }, initialPluginDataInjector); } if (enableInheritPluginData) { build.onResolve({ filter }, inheritPluginDataInjector); } build.onResolve({ filter }, absolutePathResolver); } }; }; /** {@inheritDoc entryPluginSetup} */ export const entryPlugin = (config) => { return { name: "oazmi-entry", setup: entryPluginSetup(config), }; }; /** a utility function that resolves the path/url to your runtime-package json file (such as "deno.json"), * then creates a {@link DenoPackage} instance of out of it (which is needed for the initial `pluginData`, and then inherited by the dependencies). */ const resolveRuntimePackage = async (build, initialRuntimePackage) => { const denoPackageJson_exists = initialRuntimePackage !== undefined, denoPackageJson_isRuntimePackage = initialRuntimePackage instanceof RuntimePackage, denoPackageJson_url = (!denoPackageJson_exists || denoPackageJson_isRuntimePackage) ? undefined : isString(initialRuntimePackage) ? resolveAsUrl((await build.resolve(initialRuntimePackage, { kind: "entry-point", namespace: PLUGIN_NAMESPACE.RESOLVER_PIPELINE, pluginData: { resolverConfig: { useNodeModules: false } }, })).path) : initialRuntimePackage; const denoPackage = !denoPackageJson_exists ? undefined : denoPackageJson_isRuntimePackage ? initialRuntimePackage : await DenoPackage.fromUrl(denoPackageJson_url); return denoPackage; };