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 (from compile, compileStreaming or new 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.

Inspecting a module before instantiation After compilation, which runs no module code, the host calls Module.imports, Module.exports and customSections to learn what the module needs, offers and declares about itself, decides whether to proceed, and only then instantiates. compile validated, no code run Module.imports what it needs Module.exports what it offers customSections metadata decide instantiate or reject

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.

Fixed import objects versus reflection-built ones A fixed import object hands every module the same full set of functions and fails with a LinkError when something is missing. A reflection-built object gives each module only what it declares and reports missing providers by name before instantiation. fixed import object every module gets everything extra capabilities by default missing entries → LinkError simple, broad built from Module.imports each module gets what it declared nothing extra exposed missing provider named up front precise, auditable

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 type field. 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.name keys, 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.

Cost of reflection relative to compilation Time to compile a 1.4 MB module and then to list its imports, exports and one custom section in Chrome on a laptop. milliseconds compileStreaming 74 ms Module.imports + exports 0.1 ms customSections lookup 0.0 ms

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.

← Back to Wasm Instantiation Lifecycle