Skip to content

Commit

Permalink
feat: inline import maps + stacked plugin support (#47)
Browse files Browse the repository at this point in the history
This commit splits the loader plugin into two - the sync resolver, and
the loader. This allows inserting other esbuild plugins between these
two.

Additionally a `configPath` option has been added which can be used to
specify a deno.json file that can have an inline import map, or can have
a referenced import map that will be loaded.
  • Loading branch information
lucacasonato committed Mar 13, 2023
1 parent 8031f71 commit a51bece
Show file tree
Hide file tree
Showing 19 changed files with 708 additions and 191 deletions.
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,73 @@ import * as esbuild from "https://deno.land/x/esbuild@v0.17.11/mod.js";
// permitted, such as Deno Deploy, or when running without `--allow-run`.
// import * as esbuild from "https://deno.land/x/esbuild@v0.17.11/wasm.js";

import { denoPlugin } from "https://deno.land/x/esbuild_deno_loader@0.6.0/mod.ts";
import { denoPlugins } from "https://deno.land/x/esbuild_deno_loader@0.6.0/mod.ts";

const result = await esbuild.build({
plugins: [denoPlugin()],
plugins: [..denoPlugins()],
entryPoints: ["https://deno.land/std@0.173.0/hash/sha1.ts"],
outfile: "./dist/sha1.esm.js",
bundle: true,
format: "esm",
});

console.log(result.outputFiles);

esbuild.stop();
```

## Documentation

The Deno integration for Deno consists of two separate plugins (that are however
most commonly used together):

1. The resolver, which resolves specifiers within a file relative to the file
itself (absolutization), taking into account import maps.
2. The loader, which takes a fully resolved specifier, and attempts to load it.
If the loader encounters redirects, these are processed until a final module
is found.

Most commonly these two plugins are used together, chained directly after each
other using the `denoPlugins()` function. This function returns an array of
`esbuild.Plugin` instances, which can be spread directly into the `plugins`
array of the esbuild build options.

In depth documentation for each of the plugins, and the `denoPlugins()` function
can be found in the
[generated docs](https://deno.land/x/esbuild_deno_loader/mod.ts).

### Using with other plugins

For some use-cases these plugins should be manually instantiated. For example if
you want to add your own loader plugins that handles specific file extensions or
URL schemes, you should insert these plugins between the Deno resolver, and Deno
loader.

**In most cases, the `denoResolverPlugin` should be the first plugin in the
plugin array.**

The resolver performs initial resolution on the path. This includes making
relative specifiers absolute and processing import maps. It will then send the
fully resolved specifiers back into esbuild's resolver stack to be processed by
other plugins. In the second path, the representation of the module is a fully
qualified URL. The `namespace` of the second resolve pass is the scheme of the
URL. The `path` is the remainder of the URL. The second resolve pass does not
have a `resolveDir` property, as the URL is fully qualified already.

The `denoLoaderPlugin` registers resolvers that are hit in the secondary resolve
pass for the schemes `http`, `https`, `data`, and `file`.

The output of the second resolve pass is then passed to the loader stack. The
loader stack is responsible for loading the module. Just like in the resolver
stack, the `namespace` of the loader stack is the scheme of the URL, and the
`path` is the remainder of the URL.

The `denoLoaderPlugin` registers loaders that are hit in the secondary resolve
pass for the schemes `http`, `https`, `data`, and `file`.

The examples directory contains an example for how to integrate with custom
plugins. The `examples/custom_scheme_plugin.ts` example shows how to add a
plugin that handles a custom scheme.

## Permissions

This plugins requires the following permissions:
Expand Down
3 changes: 3 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"tasks": {
"test": "deno test -A",
"update": "deno run --allow-read=./ --allow-net --allow-write=./ https://deno.land/x/deno_outdated@0.2.4/cli.ts"
},
"imports": {
"a": "https://deno.land/x/a/mod.ts"
}
}
7 changes: 6 additions & 1 deletion deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ export {
toFileUrl,
} from "https://deno.land/std@0.173.0/path/mod.ts";
export { basename, extname } from "https://deno.land/std@0.173.0/path/mod.ts";
export * as JSONC from "https://deno.land/std@0.173.0/encoding/jsonc.ts";
export {
resolveImportMap,
resolveModuleSpecifier,
} from "https://deno.land/x/importmap@0.2.1/mod.ts";
export type { ImportMap } from "https://deno.land/x/importmap@0.2.1/mod.ts";
export type {
ImportMap,
Scopes,
SpecifierMap,
} from "https://deno.land/x/importmap@0.2.1/mod.ts";
4 changes: 2 additions & 2 deletions examples/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as esbuild from "https://deno.land/x/esbuild@v0.17.11/mod.js";
import { denoPlugin } from "../mod.ts";
import { denoPlugins } from "../mod.ts";

await esbuild.build({
plugins: [denoPlugin()],
plugins: [...denoPlugins()],
entryPoints: ["https://deno.land/std@0.173.0/bytes/mod.ts"],
outfile: "./dist/bytes.esm.js",
bundle: true,
Expand Down
39 changes: 39 additions & 0 deletions examples/custom_scheme_plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as esbuild from "https://deno.land/x/esbuild@v0.17.11/mod.js";
import { denoPlugins } from "../mod.ts";

import { get } from "https://deno.land/x/emoji@0.2.1/mod.ts";

const EMOJI_PLUGIN: esbuild.Plugin = {
name: "emoji",
setup(build) {
build.onResolve({ filter: /.*/, namespace: "emoji" }, (args) => {
return { path: args.path, namespace: "emoji" };
});

build.onLoad({ filter: /.*/, namespace: "emoji" }, (args) => {
return {
contents: `export default "${get(args.path)}";`,
loader: "ts",
};
});
},
};

const importMap = {
imports: {
"wave-emoji": "emoji:waving_hand",
},
};
const importMapURL = `data:application/json,${JSON.stringify(importMap)}`;

const res = await esbuild.build({
plugins: [...denoPlugins({ importMapURL }), EMOJI_PLUGIN],
entryPoints: ["wave-emoji"],
write: false,
});
console.log(res.outputFiles[0].text); // export default "\u{1F44B}";
const { default: emoji } = await import(
"data:text/javascript," + res.outputFiles[0].text
);
console.log(emoji); // 👋
esbuild.stop();
163 changes: 51 additions & 112 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -1,124 +1,63 @@
import { esbuild } from "./deps.ts";

import {
esbuild,
fromFileUrl,
denoResolverPlugin,
type DenoResolverPluginOptions,
type ImportMap,
type Scopes,
type SpecifierMap,
} from "./src/plugin_deno_resolver.ts";
export {
denoResolverPlugin,
DenoResolverPluginOptions,
ImportMap,
resolveImportMap,
resolveModuleSpecifier,
toFileUrl,
} from "./deps.ts";
import { NativeLoader } from "./src/native_loader.ts";
import { PortableLoader } from "./src/portable_loader.ts";
import { Loader } from "./src/shared.ts";
Scopes,
SpecifierMap,
};

export interface DenoPluginOptions {
/**
* Specify the URL to an import map to use when resolving import specifiers.
* The URL must be fetchable with `fetch`.
*/
importMapURL?: URL;
import {
DEFAULT_LOADER,
denoLoaderPlugin,
type DenoLoaderPluginOptions,
} from "./src/plugin_deno_loader.ts";
export { DEFAULT_LOADER, denoLoaderPlugin, DenoLoaderPluginOptions };

export {
type EsbuildResolution,
esbuildResolutionToURL,
urlToEsbuildResolution,
} from "./src/shared.ts";

export interface DenoPluginsOptions {
/**
* Specify which loader to use. By default this will use the `native` loader,
* unless the `--allow-run` permission has not been given.
*
* - `native`: Shells out to the Deno execuatble under the hood to load
* files. Requires --allow-read and --allow-run.
* - `portable`: Do module downloading and caching with only Web APIs.
* Requires --allow-read and/or --allow-net.
* See {@link denoLoaderPlugin} for more information on the different loaders.
*/
loader?: "native" | "portable";
}

/** The default loader to use. */
export const DEFAULT_LOADER: "native" | "portable" =
await Deno.permissions.query({ name: "run" })
.then((res) => res.state !== "granted")
? "portable"
: "native";

export function denoPlugin(options: DenoPluginOptions = {}): esbuild.Plugin {
const loader = options.loader ?? DEFAULT_LOADER;
return {
name: "deno",
setup(build) {
let loaderImpl: Loader;
let importMap: ImportMap | null = null;

build.onStart(async function onStart() {
if (options.importMapURL !== undefined) {
const resp = await fetch(options.importMapURL.href);
const txt = await resp.text();
importMap = resolveImportMap(JSON.parse(txt), options.importMapURL);
} else {
importMap = null;
}
switch (loader) {
case "native":
loaderImpl = new NativeLoader({
importMapURL: options.importMapURL,
});
break;
case "portable":
loaderImpl = new PortableLoader();
}
});

build.onResolve({ filter: /.*/ }, async function onResolve(
args: esbuild.OnResolveArgs,
): Promise<esbuild.OnResolveResult | null | undefined> {
// Resolve to an absolute specifier using import map and referrer.
const resolveDir = args.resolveDir
? `${toFileUrl(args.resolveDir).href}/`
: "";
const referrer = args.importer
? `${args.namespace}:${args.importer}`
: resolveDir;
let resolved: URL;
if (importMap !== null) {
const res = resolveModuleSpecifier(
args.path,
importMap,
new URL(referrer) || undefined,
);
resolved = new URL(res);
} else {
resolved = new URL(args.path, referrer);
}

// Once we have an absolute path, let the loader resolver figure out
// what to do with it.
const res = await loaderImpl.resolve(resolved);

switch (res.kind) {
case "esm": {
const { specifier } = res;
if (specifier.protocol === "file:") {
const path = fromFileUrl(specifier);
return { path, namespace: "file" };
} else {
const path = specifier.href.slice(specifier.protocol.length);
return { path, namespace: specifier.protocol.slice(0, -1) };
}
}
}
});
/**
* Specify the path to a deno.json config file to use. This is equivalent to
* the `--config` flag to the Deno executable. This path must be absolute.
*/
configPath?: string;
/**
* Specify a URL to an import map file to use when resolving import
* specifiers. This is equivalent to the `--import-map` flag to the Deno
* executable. This URL may be remote or a local file URL.
*
* If this option is not specified, the deno.json config file is consulted to
* determine what import map to use, if any.
*/
importMapURL?: string;
}

function onLoad(
args: esbuild.OnLoadArgs,
): Promise<esbuild.OnLoadResult | null> {
let specifier;
if (args.namespace === "file") {
specifier = toFileUrl(args.path).href;
} else {
specifier = `${args.namespace}:${args.path}`;
}
return loaderImpl.loadEsm(specifier);
}
// TODO(lucacasonato): once https://github.com/evanw/esbuild/pull/2968 is fixed, remove the catch all "file" handler
// build.onLoad({ filter: /.*\.json/, namespace: "file" }, onLoad);
build.onLoad({ filter: /.*/, namespace: "file" }, onLoad);
build.onLoad({ filter: /.*/, namespace: "http" }, onLoad);
build.onLoad({ filter: /.*/, namespace: "https" }, onLoad);
build.onLoad({ filter: /.*/, namespace: "data" }, onLoad);
},
};
export function denoPlugins(
opts: DenoLoaderPluginOptions = {},
): esbuild.Plugin[] {
return [
denoResolverPlugin(opts),
denoLoaderPlugin(opts),
];
}

0 comments on commit a51bece

Please sign in to comment.