Created
September 11, 2025 16:16
-
-
Save bvisness/66e4b65af09a40b6c3bd4bd316d2d7e4 to your computer and use it in GitHub Desktop.
An esbuild plugin to get WASI 0.1 modules running in the browser.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { createWriteStream } from "node:fs"; | |
| import { access, readFile } from "node:fs/promises"; | |
| import { tmpdir } from "node:os"; | |
| import { isAbsolute, join, relative } from "node:path"; | |
| import { pipeline } from "node:stream/promises"; | |
| import * as esbuild from "esbuild"; | |
| import { componentNew, transpile } from "@bytecodealliance/jco"; | |
| const WASI_ADAPTER_URL = "https://github.com/bytecodealliance/wasmtime/releases/download/v36.0.2/wasi_snapshot_preview1.command.wasm"; | |
| const WASI_ADAPTER_PATH = join(tmpdir(), "wasi_snapshot_preview1.command.wasm"); | |
| // To use, import a WASM module with { type = "wasi-0.1" } like so: | |
| // | |
| // import { loader, instantiate } from "mycomponent.wasm" with { type: "wasi-0.1", instantiation: "async" } | |
| // | |
| // Then instantiate using @bytecodealliance/preview2-shim. | |
| export function wasi01({ fetchPrefix = "" } = {}) { | |
| return { | |
| name: "wasi-0.1", | |
| setup(build) { | |
| const fileContents = new Map(); | |
| // WASM files imported with { type: "wasi-0.1" } will be componentized by | |
| // wasm-tools and transpiled by jco. This step resolves the wasm file to a | |
| // stub JS module that will be generated by this plugin. | |
| build.onResolve({ filter: /\.wasm$/ }, args => { | |
| if (args.with.type === "wasi-0.1") { | |
| if (args.resolveDir === "" && !isAbsolute(path)) { | |
| throw new Error(`Could not resolve an absolute path for ${args.path}`); | |
| } | |
| const absPath = isAbsolute(args.path) ? args.path : join(args.resolveDir, args.path); | |
| return { | |
| path: relative("", absPath), | |
| namespace: "wasi-0.1-module-stub", | |
| pluginData: { | |
| instantiation: args.with.instantiation, | |
| resolveDir: args.resolveDir, | |
| }, | |
| }; | |
| } | |
| }); | |
| // When importing files from within our module stub, we must load their | |
| // contents from fileContents, so resolve them to a different namespace. | |
| build.onResolve({ filter: /.*/ }, args => { | |
| if (args.namespace === "wasi-0.1-module-stub") { | |
| if (fileContents.has(args.path)) { | |
| return { | |
| path: args.path, | |
| namespace: "wasi-0.1-module-contents", | |
| pluginData: { | |
| resolveDir: args.resolveDir, | |
| }, | |
| }; | |
| } | |
| } | |
| }); | |
| // Generates the stub JS module for the WASI module. | |
| build.onLoad({ filter: /.*/, namespace: "wasi-0.1-module-stub" }, async args => { | |
| // Wrap the WASI module in a component, and then transpile it | |
| await downloadIfMissing(WASI_ADAPTER_URL, WASI_ADAPTER_PATH); | |
| const [moduleBytes, adapterBytes] = await Promise.all([ | |
| readFile(args.path), | |
| readFile(WASI_ADAPTER_PATH), | |
| ]); | |
| const componentBytes = await componentNew(moduleBytes, [["wasi_snapshot_preview1", adapterBytes]]); | |
| const { files } = await transpile(componentBytes, { | |
| name: args.path, | |
| minify: false, // esbuild can handle minifying at the end | |
| instantiation: args.pluginData.instantiation, | |
| nodejsCompat: false, | |
| }); | |
| // Save the transpiled contents for later | |
| for (const [filename, contents] of Object.entries(files)) { | |
| if (filename.match(/(\.wasm|\.js)$/)) { | |
| fileContents.set(filename, contents); | |
| } | |
| } | |
| const mainJSPath = `${args.path}.js`; | |
| if (!fileContents.has(mainJSPath)) { | |
| throw new Error(`Didn't see expected file path ${mainJSPath} in jco output`); | |
| } | |
| const wasmFilenames = Array.from(fileContents.keys()) | |
| .filter(filename => filename.endsWith(".wasm")); | |
| const stubContents = ` | |
| ${Array.from(wasmFilenames.entries()) | |
| .map(([i, filename]) => `import mod${i} from "${filename}"`) | |
| .join("\n")} | |
| const moduleMap = new Map(); | |
| ${Array.from(wasmFilenames.entries()) | |
| .map(([i, filename]) => `moduleMap.set("${filename}", mod${i});`) | |
| .join("\n")} | |
| export function loader(path) { | |
| return WebAssembly.compileStreaming(fetch("${fetchPrefix}" + moduleMap.get(path))); | |
| } | |
| export * from "${mainJSPath}"; | |
| `; | |
| return { | |
| contents: stubContents, | |
| resolveDir: args.pluginData.resolveDir, | |
| }; | |
| }); | |
| // Load files imported by the stub module | |
| build.onLoad({ filter: /\.js$/, namespace: "wasi-0.1-module-contents" }, async args => { | |
| return { | |
| contents: fileContents.get(args.path), | |
| loader: "js", | |
| resolveDir: args.pluginData.resolveDir, | |
| }; | |
| }); | |
| build.onLoad({ filter: /\.wasm$/, namespace: "wasi-0.1-module-contents" }, async args => { | |
| return { | |
| contents: fileContents.get(args.path), | |
| loader: "file", | |
| }; | |
| }); | |
| }, | |
| }; | |
| }; | |
| async function downloadIfMissing(url, filename) { | |
| try { | |
| await access(filename); | |
| return; | |
| } catch { } | |
| console.log(`Downloading ${url}...`); | |
| const res = await fetch(url); | |
| if (!res.ok || !res.body) { | |
| throw new Error(`Request failed: ${res.status} ${res.statusText}`); | |
| } | |
| const fileStream = createWriteStream(filename); | |
| await pipeline(res.body, fileStream); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment