Versioning a Wasm Plugin API

This guide answers one task: change a published plugin interface — add a capability, alter a payload, rename an entry point — without breaking modules that were compiled against the previous version and which you cannot recompile.

Prerequisites

  • [ ] A published interface with at least one external plugin.
  • [ ] A host that checks a version before calling anything.
  • [ ] A way to tell plugin authors that something changed.
  • [ ] Acceptance that removing something is a multi-month process, not a release.

The version export, and checking it first

Every plugin exports an integer the host reads before the first real call. This single convention makes every later decision possible, and adding it after the fact is impossible for modules already in the wild.

const v = instance.exports.abi_version?.();
if (typeof v !== 'number') throw new Error('plugin does not declare an ABI version');
if (v < MIN_SUPPORTED || v > CURRENT) {
  throw new Error(`plugin ABI ${v} unsupported (host supports ${MIN_SUPPORTED}–${CURRENT})`);
}

Refusing clearly is the point. A host that runs a version-2 plugin against a version-3 contract will misread its output, and the resulting bug appears far from the cause — usually as corrupted data rather than an error.

Use a single integer rather than semantic versioning. The host either understands a contract or does not, and a plain increment is unambiguous in a way that “compatible with 2.x” is not once several independent changes accumulate.

A host supports a window, not a point The host accepts a range of ABI versions so older plugins keep working while new ones use newer features. Versions below the minimum are refused with a clear message, and versions above the current one are refused as too new. ABI 1 too old — refused ABI 2 supported ABI 3 supported — current ABI 4 too new — refused the host's supported window Moving the lower edge of the window is the breaking change, and it should happen on an announced schedule rather than in a routine release.

Add capabilities, never change them

The safe change is additive. A new host function that older plugins simply never call costs them nothing, because an import they do not declare is an import they do not need.

const hostFns = {
  log: …, config_len: …, read_config: …,   // ABI 1
  now_ms: …,                                // added in ABI 2 — old plugins ignore it
  emit_metric: …,                           // added in ABI 3
};

Supplying more functions than a plugin imports is always fine; supplying fewer is a link error. So the host can offer the union of every version’s imports and let each module take what it declared, which means one import object serves every supported version.

Changing an existing function is the unsafe move, and it includes changes that feel harmless: altering the meaning of a parameter, tightening a validation, changing what a return value signifies. All of them break modules that were compiled against the old meaning and will run happily against the new one, producing wrong results rather than errors.

Optional exports, detected rather than declared

The mirror image is the host wanting to call something new on the plugin. A plugin built against ABI 2 does not export flush, so calling it unconditionally throws.

const flush = instance.exports.flush;
if (typeof flush === 'function') flush();          // ABI 3+ only

Feature detection on the exports table is more robust than branching on the declared version, because it survives a plugin that implements a newer export while declaring an older version — which happens when an author updates their template partially. Use the version for the contract’s semantics and the exports table for what is actually callable.

Changing a payload format

Payload changes are where most breakage happens, because the format is invisible to the module loader. Three strategies, from cheapest to most involved.

Add optional fields. If the payload is JSON or another self-describing format, new fields that old plugins ignore and new plugins read cost nothing. Most changes fit here if the format was chosen with this in mind.

Version the payload alongside the ABI. The host encodes the payload in the format matching the plugin’s declared version, which means maintaining two encoders for a period. This is the honest option for a genuine format change.

Translate at the boundary. Keep one internal representation and convert to the plugin’s format on the way in and out. The conversion lives in one place, ages well, and can be deleted along with the old version.

function encodeInput(doc, abi) {
  if (abi >= 3) return encodeV3(doc);
  return encodeV2(doc);            // one function to delete when ABI 2 support ends
}

Running two interface versions side by side

For a change that cannot be additive, the workable approach is to support both contracts for a defined period. The host branches once, at the boundary, and everything downstream is shared.

Keep the branch shallow: one function that adapts input, one that adapts output, and a single place where the version is read. A version check scattered through a dozen call sites becomes permanent, because nobody can be confident they found them all when the time comes to remove it.

Announce the window at the start. “ABI 2 is supported until the March release” gives authors something to plan around, and it gives you a date on which the adapter code genuinely gets deleted rather than accumulating forever.

A deprecation that actually finishes The new version ships alongside the old, authors are warned at load time during an overlap period, and the old contract is removed on an announced date. Without the date, the overlap becomes permanent. ABI 3 ships ABI 2 still supported overlap, with warnings load logs a deprecation notice announced date: ABI 2 refused adapter code deleted Track how many installed plugins still declare the old version — if the number is not falling during the overlap, the warning is not reaching anyone.

Expected output

A host that logs version handling makes the state of the ecosystem visible:

loaded  acme-formatter    abi 3  (current)
loaded  legacy-exporter   abi 2  DEPRECATED — support ends 2027-03-01
refused old-thing         abi 1  (host supports 2–3)
summary 14 plugins: 11 at abi 3, 3 at abi 2, 0 at abi 1

That summary line is the metric that tells you whether a deprecation is progressing. Publish it to whatever dashboard you use, and treat a flat count of old-version plugins as a signal to contact authors directly rather than to extend the deadline again.

Testing every supported version

A contract you claim to support needs a test that proves it. Keep a compiled fixture module for each supported ABI version in the repository, built once and checked in as bytes rather than rebuilt from source — the point is to test against what old plugins actually look like, not against what your current toolchain would produce today.

const FIXTURES = [
  { file: 'fixtures/abi2-minimal.wasm', abi: 2, expects: 'legacy payload' },
  { file: 'fixtures/abi3-minimal.wasm', abi: 3, expects: 'current payload' },
  { file: 'fixtures/abi1-minimal.wasm', abi: 1, expects: 'rejected' },
];

for (const f of FIXTURES) {
  test(`abi ${f.abi}`, async () => {
    const bytes = await readFile(f.file);
    if (f.expects === 'rejected') {
      await expect(loadPlugin(bytes, policy)).rejects.toThrow(/unsupported/);
    } else {
      const out = await runPlugin(bytes, fixtureInput);
      expect(out).toMatchSnapshot();
    }
  });
}

These fixtures are also the thing that makes deleting an old version safe. When the deprecation date arrives, removing the adapter code and the corresponding fixture together is a single change whose blast radius is visible in the diff — and the remaining tests prove that nothing else depended on it.

Keep one more fixture that is deliberately malformed for its declared version: it exports abi_version returning 3 but is missing an export that version 3 requires. Asserting that the host rejects it keeps the strictness from eroding, which it otherwise does the first time someone hits the check while debugging something unrelated.

Which changes break which plugins Adding an optional field breaks nothing. Adding a required one breaks every existing plugin. A new versioned entry point lets both generations run side by side. optional field added safe old plugins ignore it and keep working unchanged required field added every existing plugin fails, usually with an unhelpful error new versioned entry point both generations load the host dispatches on what the plugin exports Have the host report the version it speaks and the plugin report what it implements; negotiate at load. Deprecate on a published schedule — silently dropping an entry point is the same as breaking it.

Gotchas

  • No version export in version 1. The mistake that cannot be fixed later. Add it before the first external plugin.
  • Semantic versioning on the ABI. Invites arguments about compatibility that a plain integer avoids.
  • Changing a function’s meaning instead of adding one. Silent wrong results everywhere.
  • Version checks scattered through the host. Keep the branch at one boundary.
  • Deprecation with no date. Becomes permanent support for a contract nobody remembers.
  • Assuming plugin authors read release notes. Warn at load time, in a place the author will see when they next run their plugin.

Performance note

Version handling costs nothing measurable: one export call at load, and a branch per invocation at the adapter boundary. Maintaining two payload encoders costs developer time rather than runtime, which is the right trade — the alternative, breaking existing plugins, costs everyone’s time including yours in support.

Frequently Asked Questions

How long should the overlap be? Long enough for an author who checks in occasionally to notice and act — a few months for a hobbyist ecosystem, longer for a commercial one where plugins are maintained on a release schedule. Shorter than that and you are effectively breaking them with extra steps.

Can the host upgrade an old plugin automatically? Not in general, because you cannot recompile someone else’s source. What you can do is translate payloads and synthesise missing exports with defaults, which covers many small changes and is exactly what the adapter boundary is for.

What if a plugin declares a version it does not implement? Refuse it as soon as you detect the discrepancy — a missing required export for its declared version is a malformed plugin. Being strict here prevents a class of bug that would otherwise surface as inexplicable behaviour at runtime.

← Back to Plugin Systems & Extensibility