Reflecting on Module Imports and Exports
This page answers one task: inspect a compiled WebAssembly module from JavaScript — what it needs, what it offers, what metadata it carries — before running any of its code, and use that to validate it, wire it up or reject it.
Prerequisites
- [ ] A compiled
WebAssembly.Module(fromcompile,compileStreamingornew WebAssembly.Module). - [ ] Any current browser, Node, Deno or Bun.
Looking before you run
Compiling a module validates it and translates it to machine code, but runs none of its code. Instantiating it binds imports and then runs its
start function, if it has one. That gap — between “compiled” and “running” — is where reflection lives. Three static methods describe a
WebAssembly.Module without instantiating it: imports(module) lists every import with its module name, field name and kind; exports(module)
lists every export with its name and kind; and customSections(module, name) returns the raw bytes of named custom sections.
Reflection is what lets a host make decisions about a module it did not build. A plugin host can refuse plugins that ask for capabilities it does not offer. A loader can build an import object dynamically from what the module declares. A deployment check can confirm that a new build’s interface matches what the page expects. And none of that requires trusting the module enough to run it.
Step 1 — list imports and exports
const module = await WebAssembly.compileStreaming(fetch("/plugins/sepia.wasm"));
console.table(WebAssembly.Module.imports(module));
console.table(WebAssembly.Module.exports(module));
┌─────────┬────────┬─────────────┬────────────┐
│ (index) │ module │ name │ kind │
├─────────┼────────┼─────────────┼────────────┤
│ 0 │ 'host' │ 'get_pixel' │ 'function' │
│ 1 │ 'host' │ 'set_pixel' │ 'function' │
│ 2 │ 'host' │ 'memory' │ 'memory' │
└─────────┴────────┴─────────────┴────────────┘
┌─────────┬──────────────┬────────────┐
│ (index) │ name │ kind │
├─────────┼──────────────┼────────────┤
│ 0 │ 'api_version'│ 'global' │
│ 1 │ 'apply' │ 'function' │
└─────────┴──────────────┴────────────┘
Kinds are function, table, memory, global and, with the exception-handling proposal, tag. The lists come in the module’s declaration order. Both calls are synchronous and cheap, so they can run on the main thread even for large
modules.
Step 2 — validate a plugin’s interface
A plugin host defines a contract — the imports it provides and the exports it requires — and checks each module against it:
const PROVIDED = new Set(["host.get_pixel", "host.set_pixel", "host.memory", "host.width", "host.height"]);
const REQUIRED = { apply: "function", api_version: "global" };
function checkPlugin(module) {
const problems = [];
for (const { module: m, name, kind } of WebAssembly.Module.imports(module)) {
if (!PROVIDED.has(`${m}.${name}`)) problems.push(`unsupported import ${m}.${name} (${kind})`);
}
const exports = new Map(WebAssembly.Module.exports(module).map((e) => [e.name, e.kind]));
for (const [name, kind] of Object.entries(REQUIRED)) {
if (exports.get(name) !== kind) problems.push(`missing export ${name} (${kind})`);
}
return problems;
}
A module that fails the check is rejected with a precise message, without any of its code having run — the approach behind restricting what a module can import.
Step 3 — build an import object from the declaration
When a host offers a library of functions and each module uses a subset, reflection lets you provide exactly what is asked for:
const LIBRARY = {
host: {
get_pixel: (x, y) => image.get(x, y),
set_pixel: (x, y, c) => image.set(x, y, c),
width: () => image.width,
height: () => image.height,
memory: sharedMemory,
},
};
function importsFor(module) {
const out = {};
for (const { module: m, name } of WebAssembly.Module.imports(module)) {
const value = LIBRARY[m]?.[name];
if (value === undefined) throw new Error(`no provider for ${m}.${name}`);
(out[m] ??= {})[name] = value;
}
return out;
}
const instance = await WebAssembly.instantiate(module, importsFor(module));
The module receives only the functions it declared, which is a small but real reduction in what it can reach, and missing providers fail with a
clear message instead of a LinkError.
Step 4 — read metadata from custom sections
customSections exposes metadata the module carries, such as a build identifier or a plugin manifest embedded as a custom section — see
adding custom sections to a Wasm binary:
const [manifestBytes] = WebAssembly.Module.customSections(module, "plugin_manifest");
const manifest = manifestBytes ? JSON.parse(new TextDecoder().decode(manifestBytes)) : {};
if (manifest.api !== 2) throw new Error(`plugin built for API ${manifest.api}, host supports 2`);
Checking an API version from metadata before instantiation avoids subtle failures from plugins compiled against an older interface.
Step 5 — know what reflection does not tell you
The reflection API reports names and kinds, not types. It will not tell you that get_pixel takes two i32s and returns an i32, or that the
memory import requires at least 16 pages. Those details are in the binary — the type and import sections — and the type reflection proposal adds
a type property to descriptors in engines that implement it. Until that is universal, either agree on signatures as part of a contract and let
instantiation enforce them, or read them from the bytes with a parser such as the one in
parsing a Wasm module header in JavaScript
extended to the import section.
Reflection in a plugin loading pipeline
Put together, these pieces form a loading pipeline that a plugin host can run on every module it is given, in an order that never runs code from a module that has not passed every check. First, fetch and verify the bytes — with an integrity hash for modules you published, or size limits for user uploads. Second, compile, which validates the module and runs nothing. Third, reflect: check the manifest from the custom section for the API version, check imports against what the host provides, and check exports against what the host requires. Fourth, build the import object from the declarations, binding narrow host functions. Only then instantiate, and only after instantiation call the plugin’s entry point — inside a worker or a sandboxed frame if the plugin is untrusted.
Each step rejects a different kind of bad module — corrupted, invalid, incompatible, over-reaching — with a specific message, and each is cheap compared with the cost of running a plugin that should never have been loaded. The pipeline is also where to record which plugins and versions a user has loaded, which makes support questions answerable. The broader architecture is in loading untrusted plugins safely.
Using reflection in build checks
Reflection is useful at build time as well as at runtime. A short Node script that compiles each built module and compares its imports and exports
with a committed expectation catches interface changes the moment they happen — a dependency upgrade that adds an import, an export renamed by a
refactor — and turns them into a reviewed diff rather than a production LinkError:
import { readFileSync } from "node:fs";
const mod = new WebAssembly.Module(readFileSync("dist/app.wasm"));
const actual = JSON.stringify({ imports: WebAssembly.Module.imports(mod), exports: WebAssembly.Module.exports(mod) }, null, 2);
const expected = readFileSync("interface.expected.json", "utf8");
if (actual !== expected) { console.error("module interface changed — update interface.expected.json if intended"); process.exit(1); }
The same expected file documents the module’s interface for anyone integrating it, which is useful on its own, and reviewers see interface changes as an ordinary diff in the pull request.
Expected output
For a conforming plugin, checkPlugin returns an empty array and instantiation proceeds; for one built against a broader host:
[ "unsupported import env.emscripten_fetch (function)", "missing export api_version (global)" ]
Gotchas
- Calling reflection on an Instance. The methods take a
WebAssembly.Module, not an instance. Keep the module object. - Expecting types in descriptors. Without the type reflection proposal there is no
typefield. Get signatures elsewhere. - Custom sections stripped in release builds. A strip step may remove your metadata. Check after the full pipeline.
- Comparing by position. Two builds may order imports differently. Compare sets of
module.namekeys, not arrays index by index. - Trusting names for safety. A module can name an import anything; what matters is what you bind to it.
Performance note
Reflection is cheap once a module is compiled: listing imports and exports of a module with 340 imports took about 0.1 ms in Chrome. The cost is the compilation that must precede it — which, for a module you are going to run anyway, you were paying already.
Frequently Asked Questions
Can I reflect without compiling?
Not with the JavaScript API — reflection needs a Module. For uploaded files you only want to check, parse the import and export sections from
the bytes instead.
Can reflection detect what a module does with its imports? No — it lists what is declared, not how it is used. A module may import a function and never call it. Treat declared imports as the upper bound on what it can do.
Are the arrays in a stable order? Yes — declaration order in the binary, which is stable for a given build.
Does Node support the same methods? Yes, along with Deno and Bun; they are part of the standard JavaScript API for WebAssembly.
How does this relate to the component model?
Components describe their interfaces in WIT with full types, which tools such as wasm-tools component wit print. Core-module reflection is much
more limited.
Related
- Writing an import object by hand — providing what reflection lists.
- Designing a Wasm plugin interface — the contract plugins are checked against.
- Versioning a Wasm plugin API — the version checks in step 4.
- Reading the import and export sections — the same data in the binary.
← Back to Wasm Instantiation Lifecycle