From 14c93ed1ff197299ff3f9a5d933d4a93ad9dcc84 Mon Sep 17 00:00:00 2001 From: Luca Casonato Date: Fri, 27 Sep 2024 14:40:36 +0200 Subject: [PATCH] feat: don't error on unrecognized media types in loader This now makes it possible to implement your own loaders for file types like `.css` or `.txt`. --- mod_test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/esbuild_types.ts | 2 +- src/loader_native.ts | 20 +++++++++++++++++--- src/loader_portable.ts | 3 ++- src/plugin_deno_loader.ts | 2 +- src/shared.ts | 8 ++++---- testdata/hello.txt | 1 + 7 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 testdata/hello.txt diff --git a/mod_test.ts b/mod_test.ts index 8353a68..4c13f85 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -884,6 +884,42 @@ Deno.test("custom plugin for scheme with import map", async (t) => { }); }); +const TXT_PLUGIN: esbuildNative.Plugin = { + name: "computed", + setup(build) { + build.onLoad({ filter: /.*\.txt$/, namespace: "file" }, async (args) => { + const url = esbuildResolutionToURL(args); + const file = await Deno.readTextFile(new URL(url)); + return { + contents: `export default ${JSON.stringify(file)};`, + loader: "js", + }; + }); + }, +}; + +Deno.test("txt plugin", async (t) => { + +await testLoader(t, LOADERS, async (esbuild, loader) => { + const res = await esbuild.build({ + ...DEFAULT_OPTS, + plugins: [ + denoResolverPlugin(), + TXT_PLUGIN, + denoLoaderPlugin({ loader }), + ], + entryPoints: ["./testdata/hello.txt"], + }); + assertEquals(res.warnings, []); + assertEquals(res.errors, []); + assertEquals(res.outputFiles.length, 1); + const output = res.outputFiles[0]; + assertEquals(output.path, ""); + const dataURL = `data:application/javascript;base64,${btoa(output.text)}`; + const { default: hello } = await import(dataURL); + assertEquals(hello, "Hello World!"); + }); +}); + Deno.test("uncached data url", async (t) => { await testLoader(t, LOADERS, async (esbuild, loader) => { const configPath = join(Deno.cwd(), "testdata", "config_ref.json"); diff --git a/src/esbuild_types.ts b/src/esbuild_types.ts index 66e59d9..a0b5eac 100644 --- a/src/esbuild_types.ts +++ b/src/esbuild_types.ts @@ -62,7 +62,7 @@ export interface PluginBuild { /** Documentation: https://esbuild.github.io/plugins/#on-load */ onLoad( options: OnLoadOptions, - callback: (args: OnLoadArgs) => Promise | undefined, + callback: (args: OnLoadArgs) => Promise | undefined, ): void; } diff --git a/src/loader_native.ts b/src/loader_native.ts index aaa92a4..7a97132 100644 --- a/src/loader_native.ts +++ b/src/loader_native.ts @@ -8,6 +8,7 @@ import { type Loader, type LoaderResolution, mapContentType, + mediaTypeFromSpecifier, mediaTypeToLoader, parseNpmSpecifier, } from "./shared.ts"; @@ -45,7 +46,15 @@ export class NativeLoader implements Loader { } const entry = await this.#infoCache.get(specifier.href); - if ("error" in entry) throw new Error(entry.error); + if ("error" in entry) { + if ( + specifier.protocol === "file:" && + mediaTypeFromSpecifier(specifier) === "Unknown" + ) { + return { kind: "esm", specifier: new URL(entry.specifier) }; + } + throw new Error(entry.error); + } if (entry.kind === "npm") { // TODO(lucacasonato): remove parsing once https://github.com/denoland/deno/issues/18043 is resolved @@ -66,23 +75,28 @@ export class NativeLoader implements Loader { return { kind: "esm", specifier: new URL(entry.specifier) }; } - async loadEsm(specifier: URL): Promise { + async loadEsm(specifier: URL): Promise { if (specifier.protocol === "data:") { const resp = await fetch(specifier); const contents = new Uint8Array(await resp.arrayBuffer()); const contentType = resp.headers.get("content-type"); const mediaType = mapContentType(specifier, contentType); const loader = mediaTypeToLoader(mediaType); + if (loader === null) return undefined; return { contents, loader }; } const entry = await this.#infoCache.get(specifier.href); - if ("error" in entry) throw new Error(entry.error); + if ( + "error" in entry && specifier.protocol !== "file:" && + mediaTypeFromSpecifier(specifier) !== "Unknown" + ) throw new Error(entry.error); if (!("local" in entry)) { throw new Error("[unreachable] Not an ESM module."); } if (!entry.local) throw new Error("Module not downloaded yet."); const loader = mediaTypeToLoader(entry.mediaType); + if (loader === null) return undefined; let contents = await Deno.readFile(entry.local); const denoCacheMetadata = lastIndexOfNeedle(contents, DENO_CACHE_METADATA); diff --git a/src/loader_portable.ts b/src/loader_portable.ts index 18021d4..d3e6c07 100644 --- a/src/loader_portable.ts +++ b/src/loader_portable.ts @@ -144,7 +144,7 @@ export class PortableLoader implements Loader, Disposable { ); } - async loadEsm(url: URL): Promise { + async loadEsm(url: URL): Promise { let module: Module; switch (url.protocol) { case "file:": { @@ -162,6 +162,7 @@ export class PortableLoader implements Loader, Disposable { } const loader = mediaTypeToLoader(module.mediaType); + if (loader === null) return undefined; const res: esbuild.OnLoadResult = { contents: module.data, loader }; if (url.protocol === "file:") { diff --git a/src/plugin_deno_loader.ts b/src/plugin_deno_loader.ts index de4d06d..de155ed 100644 --- a/src/plugin_deno_loader.ts +++ b/src/plugin_deno_loader.ts @@ -375,7 +375,7 @@ export function denoLoaderPlugin( function onLoad( args: esbuild.OnLoadArgs, - ): Promise | undefined { + ): Promise | undefined { if (args.namespace === "file" && isInNodeModules(args.path)) { // inside node_modules, just let esbuild do it's thing return undefined; diff --git a/src/shared.ts b/src/shared.ts index 758a0f2..137dc19 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -6,7 +6,7 @@ import type * as esbuild from "./esbuild_types.ts"; export interface Loader { resolve(specifier: URL): Promise; - loadEsm(specifier: URL): Promise; + loadEsm(specifier: URL): Promise; packageIdFromNameInPackage?( name: string, @@ -39,7 +39,7 @@ export interface LoaderResolutionNode { path: string; } -export function mediaTypeToLoader(mediaType: MediaType): esbuild.Loader { +export function mediaTypeToLoader(mediaType: MediaType): esbuild.Loader | null { switch (mediaType) { case "JavaScript": case "Mjs": @@ -54,7 +54,7 @@ export function mediaTypeToLoader(mediaType: MediaType): esbuild.Loader { case "Json": return "json"; default: - throw new Error(`Unhandled media type ${mediaType}.`); + return null; } } @@ -242,7 +242,7 @@ function mapJsLikeExtension( } } -function mediaTypeFromSpecifier(specifier: URL): MediaType { +export function mediaTypeFromSpecifier(specifier: URL): MediaType { const path = specifier.pathname; switch (extname(path)) { case "": diff --git a/testdata/hello.txt b/testdata/hello.txt new file mode 100644 index 0000000..c57eff5 --- /dev/null +++ b/testdata/hello.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file