add esbuild loader

pull/87/head
Zef Hemel 2022-10-07 14:38:07 +02:00
parent bc82791626
commit 4f122bbb9c
21 changed files with 627 additions and 1 deletions

@ -1 +0,0 @@
Subproject commit 70c34bb6d77b2dd05255f0e4a813bb62239d62d1

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Luca Casonato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,21 @@
# esbuild_deno_loader
Deno module resolution for `esbuild`.
## Example
This example bundles an entrypoint into a single ESM output.
```js
import * as esbuild from "https://deno.land/x/esbuild@v0.14.51/mod.js";
import { denoPlugin } from "https://deno.land/x/esbuild_deno_loader@0.5.2/mod.ts";
await esbuild.build({
plugins: [denoPlugin()],
entryPoints: ["https://deno.land/std@0.150.0/hash/sha1.ts"],
outfile: "./dist/sha1.esm.js",
bundle: true,
format: "esm",
});
esbuild.stop();
```

View File

@ -0,0 +1,13 @@
import type * as esbuild from "https://deno.land/x/esbuild@v0.14.54/mod.d.ts";
export type { esbuild };
export {
fromFileUrl,
resolve,
toFileUrl,
} from "https://deno.land/std@0.150.0/path/mod.ts";
export { basename, extname } from "https://deno.land/std@0.150.0/path/mod.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";

View File

@ -0,0 +1,11 @@
import * as esbuild from "https://deno.land/x/esbuild@v0.14.51/mod.js";
import { denoPlugin } from "https://deno.land/x/esbuild_deno_loader@0.5.2/mod.ts";
await esbuild.build({
plugins: [denoPlugin()],
entryPoints: ["https://deno.land/std@0.150.0/hash/sha1.ts"],
outfile: "./dist/sha1.esm.js",
bundle: true,
format: "esm",
});
esbuild.stop();

View File

@ -0,0 +1,11 @@
test:
deno test -A
lint:
deno lint
fmt:
deno fmt
fmt/check:
deno fmt --check

View File

@ -0,0 +1,110 @@
import {
esbuild,
ImportMap,
resolveImportMap,
resolveModuleSpecifier,
toFileUrl,
} from "./deps.ts";
import { load as nativeLoad } from "./src/native_loader.ts";
import { load as portableLoad } from "./src/portable_loader.ts";
import { ModuleEntry } from "./src/deno.ts";
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;
/**
* Specify which loader to use. By default this will use the `native` loader,
* unless `Deno.run` is not available.
*
* - `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-net.
*/
loader?: "native" | "portable";
}
/** The default loader to use. */
export const DEFAULT_LOADER: "native" | "portable" =
typeof Deno.run === "function" ? "native" : "portable";
export function denoPlugin(options: DenoPluginOptions = {}): esbuild.Plugin {
const loader = options.loader ?? DEFAULT_LOADER;
return {
name: "deno",
setup(build) {
const infoCache = new Map<string, ModuleEntry>();
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;
}
});
build.onResolve(
{ filter: /.*/ },
function onResolve(
args: esbuild.OnResolveArgs,
): esbuild.OnResolveResult | null | undefined {
// console.log("To resolve", args.path);
const resolveDir = args.resolveDir
? `${toFileUrl(args.resolveDir).href}/`
: "";
const referrer = 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);
}
// console.log("Resolved", resolved.href);
if (build.initialOptions.external) {
for (const external of build.initialOptions.external) {
if (resolved.href.startsWith(external)) {
// console.log("Got external", args.path, resolved.href);
return { path: resolved.href, external: true };
}
}
}
if (resolved.href.endsWith(".css")) {
return {
path: resolved.href.substring("file://".length),
};
}
return { path: resolved.href, namespace: "deno" };
},
);
build.onLoad(
{ filter: /.*/ },
function onLoad(
args: esbuild.OnLoadArgs,
): Promise<esbuild.OnLoadResult | null> {
if (args.path.endsWith(".css")) {
return Promise.resolve(null);
}
const url = new URL(args.path);
switch (loader) {
case "native":
return nativeLoad(infoCache, url, options);
case "portable":
return portableLoad(url, options);
}
},
);
},
};
}

View File

@ -0,0 +1,89 @@
// Lifted from https://raw.githubusercontent.com/denoland/deno_graph/89affe43c9d3d5c9165c8089687c107d53ed8fe1/lib/media_type.ts
export type MediaType =
| "JavaScript"
| "Mjs"
| "Cjs"
| "JSX"
| "TypeScript"
| "Mts"
| "Cts"
| "Dts"
| "Dmts"
| "Dcts"
| "TSX"
| "Json"
| "Wasm"
| "TsBuildInfo"
| "SourceMap"
| "Unknown";
export interface InfoOutput {
roots: string[];
modules: ModuleEntry[];
redirects: Record<string, string>;
}
export interface ModuleEntry {
specifier: string;
size: number;
mediaType?: MediaType;
local?: string;
checksum?: string;
emit?: string;
map?: string;
error?: string;
}
interface DenoInfoOptions {
importMap?: string;
}
let tempDir: null | string;
export async function info(
specifier: URL,
options: DenoInfoOptions,
): Promise<InfoOutput> {
const cmd = [
Deno.execPath(),
"info",
"--json",
];
if (options.importMap !== undefined) {
cmd.push("--import-map", options.importMap);
}
cmd.push(specifier.href);
if (!tempDir) {
tempDir = Deno.makeTempDirSync();
}
let proc;
try {
proc = Deno.run({
cmd,
stdout: "piped",
cwd: tempDir,
});
const raw = await proc.output();
const status = await proc.status();
if (!status.success) {
throw new Error(`Failed to call 'deno info' on '${specifier.href}'`);
}
const txt = new TextDecoder().decode(raw);
return JSON.parse(txt);
} finally {
try {
proc?.stdout.close();
} catch (err) {
if (err instanceof Deno.errors.BadResource) {
// ignore the error
} else {
// deno-lint-ignore no-unsafe-finally
throw err;
}
}
proc?.close();
}
}

View File

@ -0,0 +1,65 @@
import { esbuild, fromFileUrl } from "../deps.ts";
import * as deno from "./deno.ts";
import { mediaTypeToLoader, transformRawIntoContent } from "./shared.ts";
export interface LoadOptions {
importMapURL?: URL;
}
export async function load(
infoCache: Map<string, deno.ModuleEntry>,
url: URL,
options: LoadOptions
): Promise<esbuild.OnLoadResult | null> {
switch (url.protocol) {
case "http:":
case "https:":
case "data:":
return await loadFromCLI(infoCache, url, options);
case "file:": {
const res = await loadFromCLI(infoCache, url, options);
res.watchFiles = [fromFileUrl(url.href)];
return res;
}
}
return null;
}
async function loadFromCLI(
infoCache: Map<string, deno.ModuleEntry>,
specifier: URL,
options: LoadOptions
): Promise<esbuild.OnLoadResult> {
const specifierRaw = specifier.href;
if (!infoCache.has(specifierRaw)) {
const { modules, redirects } = await deno.info(specifier, {
importMap: options.importMapURL?.href,
});
for (const module of modules) {
infoCache.set(module.specifier, module);
}
for (const [specifier, redirect] of Object.entries(redirects)) {
const redirected = infoCache.get(redirect);
if (!redirected) {
throw new TypeError("Unreachable.");
}
infoCache.set(specifier, redirected);
}
}
const module = infoCache.get(specifierRaw);
if (!module) {
throw new TypeError("Unreachable.");
}
if (module.error) throw new Error(module.error);
if (!module.local) throw new Error("Module not downloaded yet.");
const mediaType = module.mediaType ?? "Unknown";
const loader = mediaTypeToLoader(mediaType);
const raw = await Deno.readFile(module.local);
const contents = transformRawIntoContent(raw, mediaType);
return { contents, loader };
}

View File

@ -0,0 +1,194 @@
import { esbuild, extname, fromFileUrl } from "../deps.ts";
import * as deno from "./deno.ts";
import { mediaTypeToLoader, transformRawIntoContent } from "./shared.ts";
export interface LoadOptions {
importMapURL?: URL;
}
export async function load(
url: URL,
_options: LoadOptions,
): Promise<esbuild.OnLoadResult | null> {
switch (url.protocol) {
case "http:":
case "https:":
case "data:":
return await loadWithFetch(url);
case "file:": {
const res = await loadWithReadFile(url);
res.watchFiles = [fromFileUrl(url.href)];
return res;
}
}
return null;
}
async function loadWithFetch(
specifier: URL,
): Promise<esbuild.OnLoadResult> {
const specifierRaw = specifier.href;
// TODO(lucacasonato): redirects!
const resp = await fetch(specifierRaw);
if (!resp.ok) {
throw new Error(
`Encountered status code ${resp.status} while fetching ${specifierRaw}.`,
);
}
const contentType = resp.headers.get("content-type");
const mediaType = mapContentType(
new URL(resp.url || specifierRaw),
contentType,
);
const loader = mediaTypeToLoader(mediaType);
const raw = new Uint8Array(await resp.arrayBuffer());
const contents = transformRawIntoContent(raw, mediaType);
return { contents, loader };
}
async function loadWithReadFile(specifier: URL): Promise<esbuild.OnLoadResult> {
const path = fromFileUrl(specifier);
const mediaType = mapContentType(specifier, null);
const loader = mediaTypeToLoader(mediaType);
const raw = await Deno.readFile(path);
const contents = transformRawIntoContent(raw, mediaType);
return { contents, loader };
}
function mapContentType(
specifier: URL,
contentType: string | null,
): deno.MediaType {
if (contentType !== null) {
const contentTypes = contentType.split(";");
const mediaType = contentTypes[0].toLowerCase();
switch (mediaType) {
case "application/typescript":
case "text/typescript":
case "video/vnd.dlna.mpeg-tts":
case "video/mp2t":
case "application/x-typescript":
return mapJsLikeExtension(specifier, "TypeScript");
case "application/javascript":
case "text/javascript":
case "application/ecmascript":
case "text/ecmascript":
case "application/x-javascript":
case "application/node":
return mapJsLikeExtension(specifier, "JavaScript");
case "text/jsx":
return "JSX";
case "text/tsx":
return "TSX";
case "application/json":
case "text/json":
return "Json";
case "application/wasm":
return "Wasm";
case "text/plain":
case "application/octet-stream":
return mediaTypeFromSpecifier(specifier);
default:
return "Unknown";
}
} else {
return mediaTypeFromSpecifier(specifier);
}
}
function mapJsLikeExtension(
specifier: URL,
defaultType: deno.MediaType,
): deno.MediaType {
const path = specifier.pathname;
switch (extname(path)) {
case ".jsx":
return "JSX";
case ".mjs":
return "Mjs";
case ".cjs":
return "Cjs";
case ".tsx":
return "TSX";
case ".ts":
if (path.endsWith(".d.ts")) {
return "Dts";
} else {
return defaultType;
}
case ".mts": {
if (path.endsWith(".d.mts")) {
return "Dmts";
} else {
return defaultType == "JavaScript" ? "Mjs" : "Mts";
}
}
case ".cts": {
if (path.endsWith(".d.cts")) {
return "Dcts";
} else {
return defaultType == "JavaScript" ? "Cjs" : "Cts";
}
}
default:
return defaultType;
}
}
function mediaTypeFromSpecifier(specifier: URL): deno.MediaType {
const path = specifier.pathname;
switch (extname(path)) {
case "":
if (path.endsWith("/.tsbuildinfo")) {
return "TsBuildInfo";
} else {
return "Unknown";
}
case ".ts":
if (path.endsWith(".d.ts")) {
return "Dts";
} else {
return "TypeScript";
}
case ".mts":
if (path.endsWith(".d.mts")) {
return "Dmts";
} else {
return "Mts";
}
case ".cts":
if (path.endsWith(".d.cts")) {
return "Dcts";
} else {
return "Cts";
}
case ".tsx":
return "TSX";
case ".js":
return "JavaScript";
case ".jsx":
return "JSX";
case ".mjs":
return "Mjs";
case ".cjs":
return "Cjs";
case ".json":
return "Json";
case ".wasm":
return "Wasm";
case ".tsbuildinfo":
return "TsBuildInfo";
case ".map":
return "SourceMap";
default:
return "Unknown";
}
}

View File

@ -0,0 +1,40 @@
import { esbuild } from "../deps.ts";
import { MediaType } from "./deno.ts";
export function mediaTypeToLoader(mediaType: MediaType): esbuild.Loader {
switch (mediaType) {
case "JavaScript":
case "Mjs":
return "js";
case "JSX":
return "jsx";
case "TypeScript":
case "Mts":
return "ts";
case "TSX":
return "tsx";
case "Json":
return "js";
default:
throw new Error(`Unhandled media type ${mediaType}.`);
}
}
export function transformRawIntoContent(
raw: Uint8Array,
mediaType: MediaType
): string | Uint8Array {
switch (mediaType) {
case "Json":
return jsonToESM(raw);
default:
return raw;
}
}
function jsonToESM(source: Uint8Array): string {
const sourceString = new TextDecoder().decode(source);
let json = JSON.stringify(JSON.parse(sourceString), null, 2);
json = json.replaceAll(`"__proto__":`, `["__proto__"]:`);
return `export default ${json};`;
}

View File

@ -0,0 +1,6 @@
import * as esbuild from "https://deno.land/x/esbuild@v0.14.51/mod.js";
export { esbuild };
export {
assert,
assertEquals,
} from "https://deno.land/std@0.150.0/testing/asserts.ts";

View File

@ -0,0 +1,6 @@
{
"hello": "world",
"__proto__": {
"sky": "universe"
}
}

View File

@ -0,0 +1 @@
export * from "mod";

View File

@ -0,0 +1,5 @@
{
"imports": {
"mod": "./mod.ts"
}
}

View File

@ -0,0 +1,2 @@
const bool = "asd";
export { bool };

View File

@ -0,0 +1,11 @@
function createElement(fn) {
return fn();
}
const React = { createElement };
function Asd() {
return "foo";
}
export default <Asd />;

View File

@ -0,0 +1,2 @@
const bool = "asd";
export { bool };

View File

@ -0,0 +1,4 @@
let bool: string;
bool = "asd";
bool = "asd2";
export { bool };

View File

@ -0,0 +1,4 @@
let bool: string;
bool = "asd";
bool = "asd2";
export { bool };

View File

@ -0,0 +1,11 @@
function createElement(fn: () => string) {
return fn();
}
const React = { createElement };
function Asd() {
return "foo";
}
export default <Asd />;