Skip to content

refactor(tools)!: replace webassemblyjs with a built-in wasm parser - #104

Merged
pi0 merged 3 commits into
mainfrom
feat/builtin-wasm-parser
Jul 19, 2026
Merged

pi0 merged 3 commits into
mainfrom
feat/builtin-wasm-parser

Conversation

@pi0x

@pi0x pi0x commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

parseWasm only needs a module's imports and exports โ€” the plugin uses them to generate the ESM facade (runtime/binding.ts emits one export const per export; runtime/imports.ts resolves each import module to a real JS module).

To get those two lists we pulled in @webassemblyjs/wasm-parser, which came with real baggage:

  • a 245 kB pre-bundled lib/wasm-parser.mjs checked into the repo
  • an esbuild start hook in build.config.ts to regenerate it
  • four resolutions pins
  • patches/@webassemblyjs__helper-wasm-bytecode.patch, which bolted missing opcodes (bulk-memory, saturating truncation, exception handling) onto its instruction table so modern binaries would not fail to parse

That last one is the telling part: we were maintaining an opcode table to decode instructions we never look at.

Approach

Wasm sections are length-prefixed, so extracting the module interface only requires decoding the type (1), import (2), export (7) and name custom sections โ€” everything else is stepped over by size. Because instructions are never decoded, the opcode-coverage treadmill the patch worked around stops existing. (This is specifically about instruction decoding โ€” see the EH note below.)

The guiding rule in the reader: anything it cannot model must raise rather than be guessed at. A misparse silently reports the wrong import/export names into generated code, whereas a throw is caught at src/plugin/index.ts:50, warned about, and falls back to a plain WebAssembly.Module.

Results

before after
dist/tools/index.mjs 201 kB (26.8 kB gz) 5.99 kB (1.35 kB gz)
total dist 414 kB 24 kB

Also runtime-agnostic now โ€” the old bundle was built with platform: "node".

Verification

pnpm test (lint + typecheck + vitest) is green: 41 tests pass, parser at 97% statement / 100% function coverage.

The existing rand.wasm inline snapshot passes byte-identical and unmodified, including name-section id resolution.

This branch was reviewed by an independent adversarial pass, which validated the parser against wasm-objdump -x as an oracle across every .wasm on the machine:

  • new parser: 249 binaries, 249 exact matches, 0 mismatches on import/export names โ€” including emscripten+SIMD, wasm-bindgen with externref, dylink/PIC tree-sitter modules, and WASI ripgrep
  • old parser on the same corpus: 139 of 252 threw (Unexpected instruction: 0xfd, tried to decode an illegal bytecode: 0x19)

So this is a substantial real-world robustness win, not only a size win.

Second commit: four silent-misparse bugs found in review

The first commit had four cases that returned wrong data instead of failing. All are fixed in d3a8869, each with a regression test:

  1. Exported tags took on another function's identity. Unknown export kinds fell back to "Func", and the tag index was then resolved through the function name map โ€” so (export "myTag" (tag $t)) could be reported with an unrelated function's name. Adds the Tag kind; unknown kinds now throw.
  2. The version field was never checked. A WebAssembly Component (jco / WASI 0.2 output) decoded as a core module and returned {imports:[],exports:[]} with no error, so the plugin's warning never fired.
  3. TextDecoder stripped a leading U+FEFF from names โ€” legal in a wasm name, and it corrupted exactly the fields the plugin consumes. Fixed with ignoreBOM: true.
  4. Typed references (ref ht) / (ref null ht) desynced the reader. These are two bytes; reading one shifted every following read. A brute force found 1944 variants that parsed "successfully" with wrong names. Now rejected explicitly.

Plus: vector reads bounded by section length, name subsections bounded by the name section, varuint limited to the 5 bytes a u32 can occupy, and tag imports supported.

Tests now enable threads/memory64/exceptions/multi-memory in wabt, hand-assemble binaries for shapes wabt cannot express (GC type forms, typed refs, BOM names, malformed sections), and cross-check the interface against WebAssembly.Module.imports/exports rather than only against recorded snapshots.

Reviewer notes

API change. ModuleExport["type"] widens from "Func" | "Memory" to an exported ExternalKind = "Func" | "Table" | "Memory" | "Global" | "Tag". Additive for consumers reading the field; breaks only an exhaustive switch with a never check. Probably warrants a minor bump.

EH support is partial, not complete. Tag imports and exports parse, but this parser models the module interface only โ€” it makes no claim about full exception-handling support.

๐Ÿค– Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a lightweight WebAssembly reader focused on module interface imports/exports.
    • Added support for Buffer, ArrayBuffer, and Uint8Array inputs.
    • Extended parsing to cover additional export kinds (including Tag) and better debug-name handling, including non-ASCII names.
  • Bug Fixes
    • Improved robustness for modules with imports, optional names, and unsupported sections; improved error reporting that includes the module context.
    • Expanded opcode-to-symbol mappings for additional instruction/control-flow and illegal/edge cases.
  • Documentation
    • Updated parseWasm README documentation to reflect its dependency-free, interface-only behavior.
  • Tests
    • Significantly expanded parseWasm test coverage, including additional edge cases and malformed inputs.

`parseWasm` only needs a module's imports and exports to generate the ESM
facade, but pulled in `@webassemblyjs/wasm-parser` to get them. That was
vendored as a 245 kB pre-bundled `lib/wasm-parser.mjs` (regenerated by an
esbuild build hook), four `resolutions` pins, and a patch adding missing
opcodes (bulk-memory, saturating truncation, exception handling) so modern
binaries would not fail to parse.

Wasm sections are length prefixed, so extracting the module interface only
requires decoding the type, import, export and `name` sections and stepping
over everything else by size. The new reader never decodes instructions, so
the opcode coverage problem the patch worked around no longer exists.

Removes lib/, the esbuild build hook, the `@webassemblyjs/wasm-parser` and
now unused `esbuild` devDependencies, the `resolutions` pins,
pnpm-workspace.yaml and the patch.

dist/tools/index.mjs: 201 kB -> 5.99 kB (26.8 kB -> 1.35 kB min+gzip)
Total dist: 414 kB -> 24 kB

`ModuleExport["type"]` is widened to a new exported `ExternalKind`, adding
the `Table` and `Global` kinds that were previously unrepresented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

Review Change Stack

๐Ÿ“ Walkthrough

Walkthrough

parseWasm now uses an internal WebAssembly binary reader to extract interface imports, exports, function signatures, and optional names. Tests cover additional binaries and WAT inputs, while the previous external parser, bundling hook, dependencies, resolutions, and workspace patch configuration are removed.

Changes

WebAssembly interface parser

Layer / File(s) Summary
Binary parser and interface extraction
src/tools/parser.ts
parseWasm decodes selected WebAssembly sections directly, supports Uint8Array, maps export kinds including tags, resolves optional function names, and wraps parse errors with module context.
Parser fixtures and behavior coverage
test/parse.test.ts
Tests cover compiled WAT modules, imports and exports, names, input types, proposal-related forms, unsupported sections, fixtures, malformed binaries, and error causes.
Parser dependency and build cleanup
README.md, build.config.ts, package.json, pnpm-workspace.yaml, patches/@webassemblyjs__helper-wasm-bytecode.patch, lib/wasm-parser.in.mjs
Removed the external parser export and related build/dependency wiring, updated opcode patch contents, and documented the dependency-free parser.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant parseWasm
  participant WasmReader
  participant ParseResult
  Caller->>parseWasm: provide WebAssembly bytes
  parseWasm->>WasmReader: read type, import, export, and name sections
  WasmReader-->>parseWasm: decoded interface data
  parseWasm->>ParseResult: assemble modules
  ParseResult-->>Caller: return ParseResult
Loading

Possibly related issues

  • Issue 92: The opcode mapping updates include the missing 0xfc saturating truncation instructions described by the issue.
๐Ÿšฅ Pre-merge checks | โœ… 5
โœ… Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage โœ… Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check โœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check โœ… Passed Check skipped because no linked issues were found for this pull request.
Description Check โœ… Passed Check skipped - CodeRabbitโ€™s high-level summary is enabled.
Title check โœ… Passed The title accurately summarizes the main change: replacing webassemblyjs with a built-in wasm parser.
โœจ Finishing Touches
๐Ÿ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
๐Ÿงช Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/builtin-wasm-parser

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

โค๏ธ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

๐Ÿค– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tools/parser.ts`:
- Around line 168-208: Thread each sectionโ€™s declared end offset from _parseWasm
into readImportSection and readExportSection, and bound their entry loops so
parsing fails or stops when reader.pos reaches the section end instead of
spilling into the next section. In readFunctionNames, apply the same
subsection-end check within the inner name-entry loop before the existing
position reset, preserving exact subsection consumption validation. Align these
readers with readTypeSectionโ€™s bounded parsing behavior.
- Around line 210-226: Update readExportSection to validate kind against
EXTERNAL_KINDS and throw for unknown values, matching readImportSection; remove
the "Func" fallback while preserving valid export parsing.
๐Ÿช„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

โ„น๏ธ Review info
โš™๏ธ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6daebd4c-d4ff-4c21-ba36-b6b644e4153f

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between 942e549 and bd85206.

โ›” Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
๐Ÿ“’ Files selected for processing (9)
  • README.md
  • build.config.ts
  • lib/wasm-parser.in.mjs
  • lib/wasm-parser.mjs
  • package.json
  • patches/@webassemblyjs__helper-wasm-bytecode.patch
  • pnpm-workspace.yaml
  • src/tools/parser.ts
  • test/parse.test.ts
๐Ÿ’ค Files with no reviewable changes (5)
  • pnpm-workspace.yaml
  • lib/wasm-parser.in.mjs
  • patches/@webassemblyjs__helper-wasm-bytecode.patch
  • build.config.ts
  • package.json

Comment thread src/tools/parser.ts Outdated
Comment on lines +168 to +208
function readImportSection(reader: WasmReader, types: FuncType[]) {
const imports: ModuleImport[] = [];
const length = reader.varuint();
for (let i = 0; i < length; i++) {
const module = reader.name();
const name = reader.name();
const kind = reader.u8();
const entry: ModuleImport = { module, name };
switch (kind) {
case 0: {
// func
const type = types[reader.varuint()];
entry.returnType = type?.results[0];
entry.params = type?.params.map((type) => ({ type }));
break;
}
case 1: {
// table
reader.u8(); // reftype
reader.limits();
break;
}
case 2: {
// memory
reader.limits();
break;
}
case 3: {
// global
reader.u8(); // valtype
reader.u8(); // mutability
break;
}
default: {
throw new Error(`unsupported import kind: ${kind}`);
}
}
imports.push(entry);
}
return imports;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐ŸŽฏ Functional Correctness | ๐ŸŸก Minor | โšก Quick win

Import/export/name readers don't verify they consumed exactly their section's declared bytes.

readTypeSection bails via reader.pos >= end when the declared count overruns the actual data, but readImportSection has no end parameter at all, and readFunctionNames's inner subsection reads aren't checked against subsectionEnd either (it just force-resets reader.pos = subsectionEnd afterward). A corrupted/malformed count field in any of these sections lets reads silently spill into the next section's bytes โ€” bounded by total buffer length, so no crash, but producing confidently-wrong parsed imports/exports/names instead of a clear parse failure.

โ™ป๏ธ Suggested fix: assert full consumption after each section loop
-function readImportSection(reader: WasmReader, types: FuncType[]) {
+function readImportSection(reader: WasmReader, types: FuncType[], end: number) {
   const imports: ModuleImport[] = [];
   const length = reader.varuint();
   for (let i = 0; i < length; i++) {
     ...
   }
+  if (reader.pos !== end) {
+    throw new Error("import section length does not match its declared size");
+  }
   return imports;
 }

Apply the analogous check (with end threaded from the call sites in _parseWasm) to readExportSection and to the subsection loop in readFunctionNames.

Also applies to: 227-247

๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tools/parser.ts` around lines 168 - 208, Thread each sectionโ€™s declared
end offset from _parseWasm into readImportSection and readExportSection, and
bound their entry loops so parsing fails or stops when reader.pos reaches the
section end instead of spilling into the next section. In readFunctionNames,
apply the same subsection-end check within the inner name-entry loop before the
existing position reset, preserving exact subsection consumption validation.
Align these readers with readTypeSectionโ€™s bounded parsing behavior.

Comment thread src/tools/parser.ts Resolved
pi0 and others added 2 commits July 19, 2026 16:42
Independent review found four cases where the new parser returned wrong
data instead of failing. Silent misparses are worse than errors here: the
plugin already warns and falls back to a plain `WebAssembly.Module` when
`parseWasm` throws, so an error is reported while wrong names are not.

- Validate the version field. Components share the wasm magic but use a
  different layout, and previously decoded as a core module with no
  imports or exports and no error at all.
- Stop mapping unknown export kinds to `Func`. An exported tag was
  reported as a function and then resolved through the function name map,
  taking on the name of whichever function shared its index. Adds the
  `Tag` kind and rejects anything still unknown.
- Pass `ignoreBOM` to `TextDecoder`. U+FEFF is a legal character in a wasm
  name, and a leading one was being stripped from the module and field
  names the plugin consumes.
- Reject the typed reference forms `(ref ht)` and `(ref null ht)`. These
  are two bytes wide, so reading one byte desynced the reader and renamed
  every following import.

Also bounds vector reads by their section length, bounds name subsections
by the name section, limits `varuint` to the 5 bytes a u32 can occupy, and
supports tag imports.

Tests add threads, memory64, exceptions and multi-memory to the wabt
feature set, and hand assemble binaries for shapes wabt cannot express.
The interface is now also cross checked against `WebAssembly.Module`
rather than only against recorded snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-validation found the bounds checks were applied too broadly. A
malformed `name` section made `parseWasm` reject modules the engine
accepts, even though custom sections carry no semantics (spec 5.5.3) and
the function names only ever feed `ModuleExport.id`.

Custom sections are now best effort, like the type section: unreadable
names leave their exports with a numeric id, and a custom section whose
own name cannot be read is simply left unidentified. The interface
sections stay strict, since a misparse there is silently wrong.

The test named for the vector bounds check never reached it. It used a
bare `toThrow()` and passed on an unrelated `unsupported export kind`
error, leaving the guard uncovered. It now uses a matcher and bytes whose
trailing custom section decodes as a syntactically valid export entry, so
only the section length check can reject it.

Adds coverage for the u32 upper bound, unknown import kinds, unknown
value types, and both custom section degradation paths, and extends the
`WebAssembly.Module` cross-check to compare export kinds so the
`ExternalKind` mapping is checked against the engine.

parser.ts coverage: 99.4% statements, 98.6% branches, 100% functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pi0 pi0 changed the title feat(tools): replace webassemblyjs with a built-in wasm parser feat(tools)!: replace webassemblyjs with a built-in wasm parser Jul 19, 2026
@pi0 pi0 changed the title feat(tools)!: replace webassemblyjs with a built-in wasm parser refactor(tools)!: replace webassemblyjs with a built-in wasm parser Jul 19, 2026
@pi0
pi0 merged commit 108e107 into main Jul 19, 2026
4 checks passed
@pi0
pi0 deleted the feat/builtin-wasm-parser branch July 19, 2026 18:16
@pi0x pi0x mentioned this pull request Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants