TypeScript glue for a Rust WebAssembly module on Bun load it once, guard the boundary, ship one executable
Pick the wasm-pack target that survives Bun, load the module once with initSync, wrap the raw class in a typed, disposable API that rejects bad input before it reaches WebAssembly, and ship server and module as a single executable.
Versions: Bun 1.4.4 (macOS arm64) and 1.4.2 (Linux x64), wasm-pack 0.15.0, wasm-bindgen 0.2.129, Rust edition 2024, TypeScript 7.0.2, @types/bun 1.4.4.
The problem
A team has a fuzzy name matcher written in Rust. Given a list of names and a misspelled query such as hoper, it returns Grace Hopper. They want to call it from an HTTP server written in TypeScript on Bun, without running a second Rust service next to it.
Compiling Rust to WebAssembly (a compact binary code format that JavaScript runtimes can run next to your JavaScript) makes this possible. The tool wasm-pack builds the Rust crate and produces a folder of files, but that raises questions the tool does not answer for you.
- Which of its output styles actually works under Bun?
- Who reads the
.wasmbytes from disk, and when? - What happens when a caller passes something Rust did not expect, such as a negative limit or a number where a string belongs?
- Who frees the search index that lives inside WebAssembly memory?
- Will the server still work after it is compiled into one executable and copied to a machine that has no source folder?
Each of these fails in a different way, and several fail only after you have shipped. The small server below meets each failure with real output first, then the fix.
The idea in plain words
Keep one small TypeScript module, the glue, between your server and the code that wasm-bindgen generates. The glue loads the .wasm bytes once, at import time, from a path that Bun knows how to embed in an executable. It exposes a narrow typed API and checks every argument before the argument crosses into WebAssembly. It also owns the lifetime of the Rust object, so only one place ever frees it. Everything else in the server imports the glue and never touches the generated package directly.
Concept A: the Rust side and what wasm-pack generates
Definitions. A WebAssembly module is the compiled .wasm file. An instance is that module loaded with its own memory, ready to call. The instance's linear memory is one growable block of bytes. WebAssembly functions only take and return numbers, so to pass a string the caller copies the bytes into linear memory and passes numbers that say where they are. Flanagan describes WebAssembly as a low-level bytecode format that integrates with JavaScript interpreters, and notes that it can export functions that JavaScript programs call (JavaScript: The Definitive Guide, 7th ed., §15.15.4, p. 984). wasm-bindgen is the tool that writes the string-copying code for you. Glue code is the generated JavaScript that does the copying and wraps the exports in friendlier functions and classes.
Why this matters. Without a tool like wasm-bindgen you would write the calling convention by hand. The Rust book shows the manual route for exposing a function to other languages: mark it extern and add #[unsafe(no_mangle)] so the compiler keeps the function's name (The Rust Programming Language, ch. 20, "Calling Rust Functions from Other Languages", pp. 600-601). That gives you a function taking plain numbers, and you would still need your own code to move text and lists in and out. #[wasm_bindgen] generates both sides of that work from the Rust signature, so that you write fn search(&self, query: &str, limit: usize) -> Vec<String> and call it as a normal method.
The crate
Start with the build file. The [lib] section asks Cargo for two kinds of library, and the dependency is pinned to one exact version so rebuilds produce the same glue.
[package]
name = "fuzzy-index"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "=0.2.129"
cdylib produces a C-style dynamic library, which for the WebAssembly target is the .wasm file itself. rlib is an ordinary Rust library, which lets cargo test link the crate and run its unit tests natively. Drop either one and you lose either the module or the tests. The = in the version string means "exactly this version", not "this or newer".
Now the Rust source.
//! A fuzzy name matcher compiled to WebAssembly and called from TypeScript on a Bun server.
use wasm_bindgen::prelude::*;
/// Version string baked in at compile time, so the page can show which build loaded.
#[wasm_bindgen]
pub fn version() -> String {
format!("fuzzy-index {}", env!("CARGO_PKG_VERSION"))
}
/// Lowercase and keep only letters, digits and single spaces.
#[wasm_bindgen]
pub fn normalize(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for word in input.split_whitespace() {
let cleaned: String = word
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
if cleaned.is_empty() {
continue;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(&cleaned);
}
out
}
/// Levenshtein distance counted in Unicode scalar values, not bytes.
#[wasm_bindgen]
pub fn edit_distance(a: &str, b: &str) -> u32 {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<u32> = (0..=b.len() as u32).collect();
let mut cur = vec![0u32; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i as u32 + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// A search index that lives in WebAssembly memory. JavaScript holds a handle to it.
#[wasm_bindgen]
pub struct FuzzyIndex {
names: Vec<String>,
keys: Vec<String>,
}
#[wasm_bindgen]
impl FuzzyIndex {
/// Build an index. An empty list is an error, which JavaScript sees as a thrown exception.
#[wasm_bindgen(constructor)]
pub fn new(names: Vec<String>) -> Result<FuzzyIndex, JsError> {
if names.is_empty() {
return Err(JsError::new("FuzzyIndex needs at least one name"));
}
let keys = names.iter().map(|n| normalize(n)).collect();
Ok(FuzzyIndex { names, keys })
}
/// Number of names in the index (a JavaScript getter, not a method).
#[wasm_bindgen(getter)]
pub fn len(&self) -> usize {
self.names.len()
}
/// Best matches first. A word that starts with the query counts as distance 0;
/// otherwise a word may be at most one edit away per three query characters.
pub fn search(&self, query: &str, limit: usize) -> Vec<String> {
let q = normalize(query);
if q.is_empty() {
return Vec::new();
}
let max_edits = (q.chars().count() as u32 / 3).max(1);
let mut scored: Vec<(u32, usize)> = self
.keys
.iter()
.enumerate()
.map(|(i, key)| {
let prefix = key.split(' ').any(|w| w.starts_with(&q));
let best = key
.split(' ')
.map(|w| edit_distance(&q, w))
.min()
.unwrap_or(u32::MAX);
(if prefix { 0 } else { best }, i)
})
.filter(|(d, _)| *d <= max_edits)
.collect();
scored.sort();
scored
.into_iter()
.take(limit)
.map(|(_, i)| self.names[i].clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_punctuation_and_case() {
assert_eq!(normalize(" Ada LOVELACE!! "), "ada lovelace");
assert_eq!(normalize("O'Brien, Flann"), "obrien flann");
}
#[test]
fn distance_counts_chars_not_bytes() {
assert_eq!(edit_distance("kitten", "sitting"), 3);
assert_eq!(edit_distance("café", "cafe"), 1);
}
#[test]
fn search_ranks_prefix_then_typo() {
let idx = FuzzyIndex::new(vec![
"Grace Hopper".into(),
"Ada Lovelace".into(),
"Alan Turing".into(),
])
.unwrap();
assert_eq!(idx.search("ada", 5), vec!["Ada Lovelace"]);
assert_eq!(idx.search("turnig", 5), vec!["Alan Turing"]);
assert_eq!(idx.len(), 3);
}
}
The three free functions are the matcher's parts. version returns a string fixed at compile time. normalize lowercases a string and strips punctuation. edit_distance is the Levenshtein distance, the minimum number of single-character insertions, deletions and substitutions that turn one string into another. It keeps two rolling arrays instead of a full table, and it counts Unicode characters rather than bytes, so café versus cafe is distance 1.
FuzzyIndex is the exported struct. Its data stays inside WebAssembly memory, and JavaScript only holds a handle, a small JavaScript object that points at it. The constructor returns Result<FuzzyIndex, JsError>. wasm-bindgen's documentation says that when a function returns Err(error), an exception is thrown in JavaScript with that error, and Ok(val) is converted and handed back. So an empty list becomes a thrown Error on the JavaScript side instead of a silent bad object.
len is marked getter, so JavaScript reads index.len without parentheses. search takes a usize, Rust's unsigned integer type for sizes and counts. It can never be negative, and that fact becomes important in Concept C. Results are sorted by (distance, index), a prefix match counts as distance 0, and take(limit) cuts the list. The three native Rust tests at the bottom pass under cargo test, which is why the crate keeps rlib.
What wasm-pack generates
wasm-pack drives the build: it compiles the crate, runs wasm-bindgen, and optimizes the result. Its --target flag, the target, selects what kind of JavaScript it emits. The wasm-pack book says the target "will customize the JS that is emitted and how the WebAssembly files are instantiated and loaded." The lab builds with:
$ cd wasm-rs && wasm-pack build --target web --out-dir ../pkg --no-pack
[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
Finished `release` profile [optimized] target(s) in 4.72s
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: ✨ Done in 4.94s
[INFO]: 📦 Your wasm pkg is ready to publish at <lab>/pkg.
exit=0
--out-dir ../pkg puts the output next to the TypeScript source, and --no-pack skips writing a package.json into it. The log shows a release build followed by wasm-opt, a size optimizer for WebAssembly, so the .wasm is small. Four files matter in the output folder:
3124 fuzzy_index.d.ts
12240 fuzzy_index.js
37643 fuzzy_index_bg.wasm
1088 fuzzy_index_bg.wasm.d.ts
fuzzy_index.js is the glue and fuzzy_index_bg.wasm is the module. fuzzy_index.d.ts holds the TypeScript types for the glue's classes and functions. The fourth file, fuzzy_index_bg.wasm.d.ts, describes the raw exports of the .wasm file. Its first lines show why the glue is needed, because every raw export takes and returns plain numbers:
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const __wbg_fuzzyindex_free: (a: number, b: number) => void;
export const edit_distance: (a: number, b: number, c: number, d: number) => number;
export const fuzzyindex_len: (a: number) => number;
edit_distance takes four numbers, not two strings: a pointer and a length for each string. The generated JavaScript copies each string into memory and passes those numbers. That second .d.ts file will matter again in Concept B, because it is the source of a surprising type error.
Choosing a target under Bun
The wasm-bindgen guide lists the targets: bundler is "suitable for loading in bundlers like Webpack", web is "directly loadable in a web browser", and nodejs is "loadable via require as a Node.js CommonJS module". The guide also says the default bundler output assumes the .wasm file is "natively an ES module", and that no JavaScript implementation does this natively, so you need a bundler. Bun runs a script without bundling it, so the lab tested each target with a tiny probe, using the same crate built into a separate folder per target. Start with bundler:
import { FuzzyIndex, version } from "../pkg-bundler/fuzzy_index.js";
const idx = new FuzzyIndex(["Grace Hopper", "Ada Lovelace"]);
console.log("bundler:", version(), idx.search("hoper", 5));
1 | /* @ts-self-types="./fuzzy_index.d.ts" */
2 | import * as wasm from "./fuzzy_index_bg.wasm";
3 | import { __wbg_set_wasm } from "./fuzzy_index_bg.js";
4 |
5 | __wbg_set_wasm(wasm);
6 | wasm.__wbindgen_start();
^
TypeError: wasm.__wbindgen_start is not a function. (In 'wasm.__wbindgen_start()', 'wasm.__wbindgen_start' is undefined)
at <lab>/pkg-bundler/fuzzy_index.js:6:6
Bun v1.4.4 (macOS arm64)
exit=1
The generated glue does import * as wasm from "./fuzzy_index_bg.wasm" and expects wasm to be the module's exports. Bun's file loader does not do that. The Bun docs say the file loader "resolves the import as a path/URL to the imported file" and, at runtime, resolves to the file's absolute path on disk. A second probe prints what Bun actually hands back:
import * as wasm from "../pkg-bundler/fuzzy_index_bg.wasm";
console.log(Object.keys(wasm), typeof (wasm as any).default, (wasm as any).default);
[ "__esModule", "default" ] string <lab>/pkg-bundler/fuzzy_index_bg.wasm
The namespace has two keys, __esModule and default, and default is a path string. There are no WebAssembly exports on it, so wasm.__wbindgen_start is undefined. This explains the failure above and rules out the bundler target here, because its design needs a tool that turns .wasm imports into exports.
The other two targets ran on macOS:
import { FuzzyIndex, version } from "../pkg-nodejs/fuzzy_index.js";
const idx = new FuzzyIndex(["Grace Hopper", "Ada Lovelace"]);
console.log("nodejs:", version(), idx.search("hoper", 5));
import init, { FuzzyIndex, version } from "../pkg/fuzzy_index.js";
await init();
const idx = new FuzzyIndex(["Grace Hopper", "Ada Lovelace"]);
console.log("web + init():", version(), idx.search("hoper", 5));
nodejs: fuzzy-index 0.1.0 [ "Grace Hopper" ]
exit=0
web + init(): fuzzy-index 0.1.0 [ "Grace Hopper" ]
exit=0
Both print the right answer. The web target has one more requirement: you must initialize it before calling anything. The probe that skips init fails on Linux:
import { version } from "../pkg/fuzzy_index.js";
console.log("web, no init:", version());
TypeError: undefined is not an object (evaluating 'wasm.__wbindgen_free')
Bun v1.4.2 (Linux x64)
exit=1
The glue keeps its instance in a module-level variable named wasm, and that variable stays undefined until an init function runs. The first export that touches it fails with a TypeError (JavaScript's error for using a value in a way its type does not allow, here reading a property of undefined).
The lesson uses the web target with initSync. It produces ES modules, so it imports like the rest of a TypeScript project. Its loading step is something you control, so you can choose where the .wasm bytes come from. That choice is what lets the same code run from source and from a compiled executable. The nodejs target also runs under Bun, but it hides its loading step inside the glue, and pitfall d shows how that breaks after compiling.
Common mistake: picking the target the docs call the default. wasm-pack build with no --target uses bundler, and under plain Bun it fails with __wbindgen_start is not a function. Spot it by reading the first lines of pkg/fuzzy_index.js: an import * as wasm from "./...wasm" line means the glue expects a bundler.
Concept B: loading the module once with initSync and a file import
Definitions. initSync is a function exported by the web-target glue. It takes the .wasm bytes, compiles them into a module, creates an instance, and wires the instance into the glue, all synchronously. An import attribute is the with { type: "..." } clause on an import, which tells the runtime how to treat the file. The Bun docs say that to pick a loader explicitly you use the type import attribute. With type: "file", the file loader makes the import return a path string to the file instead of its contents.
**Why not the default init().** The default init function, when given no argument, builds new URL('fuzzy_index_bg.wasm', import.meta.url) and then calls fetch on it. Flanagan explains that import.meta.url is the URL a module was loaded from, intended for referring to "images, data files, or other resources that are stored in the same directory as (or relative to) the module" (§10.3.7, p. 477). That works from source, where the .wasm file sits next to the glue. It breaks in a compiled executable, where nothing has told Bun that the .wasm file is part of the program. Pitfall c shows this. So the glue reads the bytes itself, from a path Bun knows about, and hands them to initSync.
Here are the relevant lines from the generated glue (wasm-bindgen 0.2.129):
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
The first line is the guard: if wasm is already set, the function returns at once. That makes the load idempotent, so any number of files can import the glue and the module still loads once. Next, initSync accepts an object with a module key, and the older bare-argument form only triggers a warning. If the value is not already a WebAssembly.Module, it is treated as bytes and compiled with new WebAssembly.Module(module). The new WebAssembly.Instance(module, imports) call then creates the instance, and __wbg_finalize_init stores it in the module-level wasm variable that every later call uses. Because ES modules are evaluated once and then cached, the glue file executes its load a single time per process even if five files import it.
The default init is the contrast. It picks its own location and fetches it:
if (module_or_path === undefined) {
module_or_path = new URL('fuzzy_index_bg.wasm', import.meta.url);
}
The glue file, top part
Here is src/fuzzy.ts in full. This section covers the top, and Concept C covers the class.
// TypeScript glue between a Bun program and the fuzzy-index WebAssembly module.
// Importing this file loads the module once; callers never see initSync or free().
import { initSync, FuzzyIndex, version } from "../pkg/fuzzy_index.js";
import wasmFile from "../pkg/fuzzy_index_bg.wasm" with { type: "file" };
// Bun's file loader makes the default export a path string: absolute on disk,
// a /$bunfs/ path inside a compiled executable. TypeScript instead reads the
// sibling fuzzy_index_bg.wasm.d.ts (the raw exports), so the type is corrected here.
const wasmPath = wasmFile as unknown as string;
initSync({ module: await Bun.file(wasmPath).bytes() });
export const MAX_LIMIT = 20;
export const MAX_QUERY_CHARS = 100;
export function engineVersion(): string {
return version();
}
export class NameSearch implements Disposable {
#index: FuzzyIndex | null;
private constructor(index: FuzzyIndex) {
this.#index = index;
}
// Throws an Error (from Rust's JsError) when names is empty.
static fromNames(names: readonly string[]): NameSearch {
return new NameSearch(new FuzzyIndex([...names]));
}
get size(): number {
return this.#live().len;
}
search(query: string, limit = 5): string[] {
// A non-string here (say, a number from a JSON body) corrupts the call
// inside WebAssembly instead of failing cleanly, so check it first.
if (typeof query !== "string") {
throw new TypeError(`query must be a string, got ${typeof query}`);
}
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
throw new RangeError(`limit must be an integer from 1 to ${MAX_LIMIT}, got ${limit}`);
}
if (query.length > MAX_QUERY_CHARS) {
throw new RangeError(`query is longer than ${MAX_QUERY_CHARS} characters`);
}
return this.#live().search(query, limit);
}
[Symbol.dispose](): void {
this.#index?.free();
this.#index = null;
}
#live(): FuzzyIndex {
if (this.#index === null) throw new Error("NameSearch was disposed");
return this.#index;
}
}
The loading happens in three lines. The with { type: "file" } import gives a path string. Bun.file(wasmPath).bytes() reads that file into a byte array, and the await sits at the top level of the module, which ES modules allow. Because an importing file waits for the awaited work to finish, nobody can call into the glue before initSync has run.
The path has two forms, and Bun.file reads both: the Bun docs name Bun.file() as the recommended way to read an embedded file. A probe printed the path from source, then from a compiled executable:
== bun probes/show-wasm-path.ts
wasm path: <lab>/pkg/fuzzy_index_bg.wasm
== compiled
wasm path: /$bunfs/root/fuzzy_index_bg-f8kq0vte.wasm
From source, it is the absolute path of the file on disk. Bun's executables page shows the comment "During development: ./icon.png" for the same kind of import, while the File Types and Loaders pages say the runtime resolves to the absolute path. The lab printed the absolute path, so treat that as the real behavior. After compiling, it becomes a path in Bun's embedded file system, the area inside the executable under /$bunfs/, where the build step copied the file and added a suffix to its name. The Bun docs describe this: the build reads the file, embeds the data, and replaces the import with an internal path.
The type fix
The cast on line const wasmPath = wasmFile as unknown as string; exists because of a type error the first tsc run produced:
src/fuzzy.test.ts(51,12): error TS2571: Object is of type 'unknown'.
src/fuzzy.ts(7,35): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'typeof import("<lab>/pkg/fuzzy_index_bg.wasm")' is not assignable to parameter of type 'number'.
error: "tsc" exited with code 1
exit=1
TypeScript looks for a declaration next to the file named in the import. For fuzzy_index_bg.wasm, it finds the sibling fuzzy_index_bg.wasm.d.ts and types the import as that raw-exports namespace. Moving that one file out of pkg/ and running tsc again proves where the type came from, because the error changes to "no declaration at all":
== tsc with pkg/fuzzy_index_bg.wasm.d.ts moved away
src/fuzzy.ts(4,22): error TS2307: Cannot find module '../pkg/fuzzy_index_bg.wasm' or its corresponding type declarations.
error: "tsc" exited with code 1
exit=1
So the .d.ts is doing its job for code that wants the raw exports, and it is wrong only for this import, where the file attribute changes what the runtime returns. At runtime, with the file attribute, Bun returns a string. Cherny's warning applies: "it's on you to make sure that your type declarations match up to what your JavaScript environment actually supports at runtime" (Programming TypeScript, ch. 9, p. 269). One documented cast, with a comment explaining it, is the honest fix. The second error, TS2571 in the test file, is a separate mistake: res.json() returns unknown, so the test needed const body = (await res.json()) as { error: string };.
**Common mistake: skipping the cast and writing Bun.file(wasmFile) directly.** It runs fine, because the runtime value is a string, but tsc rejects it with TS2769 and your typecheck step fails. Spot it by reading the type in the error: typeof import(".../fuzzy_index_bg.wasm") is the sibling .d.ts, not a string.
Concept C: guarding the boundary and owning the lifetime
Definitions. The boundary is the call from JavaScript into WebAssembly. A handle is the JavaScript object that stands for a Rust value kept in linear memory. free() is the generated method that gives that Rust value's memory back. Symbol.dispose is a standard JavaScript method name: the using declaration (using s = ...) calls it automatically when the block ends. A RangeError is JavaScript's error for a number outside an allowed range, and a TypeError is its error for a value of the wrong type.
Why guard. JavaScript's checks and Rust's checks do not line up. The generated glue does not verify what you pass, and WebAssembly only understands numbers. So a wrong value can change meaning, or corrupt a call, instead of failing with a message. The lab's raw probe calls the generated class directly with no glue, to see what happens:
// Calling the generated class directly, with no glue in between.
import { initSync, FuzzyIndex } from "../pkg/fuzzy_index.js";
initSync({ module: await Bun.file(new URL("../pkg/fuzzy_index_bg.wasm", import.meta.url)).bytes() });
const idx = new FuzzyIndex(["Grace Hopper", "Ada Lovelace", "Alan Turing"]);
for (const limit of [1, 0, -1, 2.9, NaN]) {
console.log(`limit ${limit}:`, idx.search("a", limit));
}
try { console.log(idx.search(42 as unknown as string, 5)); } catch (e) { console.log("number as query:", String(e)); }
idx.free();
try { idx.search("ada", 5); } catch (e) { console.log("after free():", String(e)); }
try { new FuzzyIndex([]); } catch (e) { console.log("empty list:", e instanceof Error, String(e)); }
limit 1: [ "Ada Lovelace" ]
limit 0: []
limit -1: [ "Ada Lovelace", "Alan Turing" ]
limit 2.9: [ "Ada Lovelace", "Alan Turing" ]
limit NaN: []
number as query: RuntimeError: Out of bounds memory access (evaluating 'realloc(ptr, len, len = offset + arg.length * 3, 1)')
after free(): Error: null pointer passed to rust
empty list: true Error: FuzzyIndex needs at least one name
The probe makes one call per kind of bad input, and each result motivates a line in the glue.
The generated search method shows why the limit cases behave as they do. It copies the query string into memory, then hands limit to the raw export untouched:
const ret = wasm.fuzzyindex_search(this.__wbg_ptr, ptr0, len0, limit);
On the wasm32 target a usize is 32 bits wide, and the raw export takes it as a plain number. Whatever JavaScript number you pass is turned into a 32-bit integer at the call, with no range check. The outputs line up with that: the fraction is dropped, NaN becomes 0, and the bit pattern of -1 reads as the largest 32-bit unsigned value.
- **
limit -1returned every match.**limitis ausizein Rust, which cannot be negative, so the negative number arrived as a huge unsigned one.takewith a huge limit returns everything. A caller asking for "at most -1 results" got all of them, with no error. - **
limit 2.9was silently truncated to 2.** The fractional part was dropped on the way in. The result looks plausible, which makes the bug hard to notice. - **
limit NaNgave an empty list**, the same aslimit 0. - **A number passed as the query caused a
RuntimeError: Out of bounds memory access** from inside the generated string-copy code. That code asks for memory sized fromarg.length, and a number has no.length. This is a fault inside WebAssembly, not a clean JavaScript error. - **After
free(), a call threwError: null pointer passed to rust.** The wasm-bindgen guide documents this: after an object is moved into Rust or freed, later use "throws an error (Attempt to use a moved value when built with--debug, null pointer passed to rust otherwise)." - **An empty list threw a real
Error**, ande instanceof Errorprintedtrue. This matches the Result documentation: anErrbecomes a thrown JavaScript exception. The glue can let that one pass through unchanged.
The class
Cherny shows that a typeof check queries a value at runtime and that TypeScript uses the same check at compile time (Programming TypeScript, ch. 6, "Refinement", p. 175). TypeScript's types prove nothing about a JSON body parsed at runtime, so search repeats the check as a runtime test. It checks typeof query, requires an integer limit from 1 to 20, and caps the query length. The cap keeps one request from sending a huge string that costs a lot of work. Each check throws before the call reaches WebAssembly, so the failure is a named error with a clear message instead of a fault inside the module.
Two design choices in the class keep the handle private. The constructor is private and the only way in is the static fromNames, so no outside code can build a NameSearch around an arbitrary index. The index itself sits in a #index field. The # prefix is JavaScript's own private-field syntax, enforced at runtime, so even code that ignores TypeScript cannot reach the raw FuzzyIndex and call free() behind your back.
The generated class already supports using on its own: wasm-bindgen 0.2.129 ends the class with if (Symbol.dispose) FuzzyIndex.prototype[Symbol.dispose] = FuzzyIndex.prototype.free;. The wrapper still earns its place, because it adds the checks above, hides the handle, and replaces the Rust error text with one that names your type.
[Symbol.dispose] frees the index and sets the field to null. The ?. makes a second call do nothing, so disposing twice is safe. That matters because using calls it automatically, and a caller may also call it by hand. The private #live() method turns use after free into a clear Error("NameSearch was disposed") instead of the cryptic null pointer passed to rust. Without #live(), a caller who sees the Rust message has to learn what it means.
The reason to free explicitly is that the index lives in WebAssembly memory, not in JavaScript's normal object heap, and the handle is only a pointer into it. Freeing at a time you choose makes the release predictable. A long-lived server therefore builds one index at startup and keeps it, instead of building one per request. It disposes of the index in stop(), so the memory is released exactly when the server stops.
Common mistake: exporting the raw class from the glue "for convenience". Once any file imports FuzzyIndex directly, it can pass unchecked values and forget to free. Spot it by searching the source for pkg/fuzzy_index.js: only src/fuzzy.ts should import from it.
Concept D: the Bun server and its tests
Definitions. Bun.serve starts an HTTP server. Its routes option maps URL paths to handler functions, and fetch handles everything the routes do not match. A handler takes a Request and returns a Response. Response.json(value, init) builds a JSON response and lets you set a status code. bun:test is Bun's built-in test runner, with describe, test, expect and afterAll as in other JavaScript test libraries.
Why this shape. A server's job here is to translate between HTTP and the glue's typed API, and to turn a caller's mistake into a 400 response instead of a crash. The glue throws RangeError for bad limits, so the server catches exactly that class and nothing more. Anything else, such as a disposed index, is a bug in the server, and rethrowing it avoids disguising the bug as a client error.
First, the data:
export const NAMES = [
"Grace Hopper",
"Ada Lovelace",
"Alan Turing",
"Barbara Liskov",
"Edsger Dijkstra",
"Margaret Hamilton",
"Donald Knuth",
] as const;
The as const makes the list a read-only tuple of literal strings. The glue accepts readonly string[], so it takes this value directly and copies it before handing it to Rust.
import { NameSearch, engineVersion } from "./fuzzy.ts";
import { NAMES } from "./names.ts";
export function startServer(port: number) {
// One index for the life of the process; it is freed when the server stops.
const names = NameSearch.fromNames(NAMES);
const server = Bun.serve({
port,
routes: {
"/api/version": () => Response.json({ engine: engineVersion(), names: names.size }),
"/api/search": (req) => {
const url = new URL(req.url);
const q = url.searchParams.get("q") ?? "";
const limit = Number(url.searchParams.get("limit") ?? "5");
try {
return Response.json({ q, results: names.search(q, limit) });
} catch (err) {
if (err instanceof RangeError) {
return Response.json({ error: err.message }, { status: 400 });
}
throw err;
}
},
},
fetch: () => Response.json({ error: "not found" }, { status: 404 }),
});
return {
server,
stop() {
server.stop(true);
names[Symbol.dispose]();
},
};
}
if (import.meta.main) {
const { server } = startServer(Number(process.env.PORT ?? 3000));
console.log(`fuzzy-server on ${server.url} (${engineVersion()})`);
}
startServer builds the one long-lived NameSearch, then starts the server. Query-string values are always text, so the handler converts limit with Number(...). A value like 2.5 or abc becomes a number that fails the glue's integer check, and that RangeError becomes a 400 response carrying the glue's message. Wrapping the call in a try and checking err instanceof RangeError is the whole error policy.
The returned object exposes stop(), which stops the server first (the Bun docs say passing true terminates all connections immediately) and only then disposes the index, so the index is freed after the server stops taking requests. The last block runs only when import.meta.main is true, which means this file is the program Bun started, not a file that something else imported. That is what lets the tests import startServer without opening port 3000. The PORT environment variable overrides the default.
import { describe, expect, test, afterAll } from "bun:test";
import { NameSearch, engineVersion, MAX_LIMIT } from "./fuzzy.ts";
import { startServer } from "./server.ts";
import { NAMES } from "./names.ts";
describe("glue", () => {
test("the module is loaded on import", () => {
expect(engineVersion()).toBe("fuzzy-index 0.1.0");
});
test("prefix match beats a typo match", () => {
using s = NameSearch.fromNames(NAMES);
expect(s.search("ada")).toEqual(["Ada Lovelace"]);
expect(s.search("dijsktra")).toEqual(["Edsger Dijkstra"]);
expect(s.size).toBe(7);
});
test("an empty list is a thrown Error from Rust", () => {
expect(() => NameSearch.fromNames([])).toThrow("FuzzyIndex needs at least one name");
});
test("limit is checked before it reaches WebAssembly", () => {
using s = NameSearch.fromNames(NAMES);
for (const bad of [0, -1, 2.5, NaN, MAX_LIMIT + 1]) {
expect(() => s.search("a", bad)).toThrow(RangeError);
}
});
test("a non-string query is a TypeError, not a WebAssembly fault", () => {
using s = NameSearch.fromNames(NAMES);
expect(() => s.search(42 as unknown as string)).toThrow(TypeError);
});
test("a disposed search fails with a clear message", () => {
const s = NameSearch.fromNames(NAMES);
s[Symbol.dispose]();
s[Symbol.dispose](); // a second dispose is harmless
expect(() => s.search("ada")).toThrow("NameSearch was disposed");
});
});
describe("server", () => {
const app = startServer(0);
const get = (path: string) => fetch(new URL(path, app.server.url));
afterAll(() => app.stop());
test("GET /api/search returns ranked names", async () => {
const res = await get("/api/search?q=hoper");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ q: "hoper", results: ["Grace Hopper"] });
});
test("a bad limit is a 400, not a crash", async () => {
const res = await get("/api/search?q=a&limit=-1");
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("limit must be an integer");
});
});
The glue tests use using s = NameSearch.fromNames(NAMES), so each test frees its index when the test function ends, even if an assertion throws. The disposal test calls Symbol.dispose by hand, twice, because it needs the object alive after the first call to prove that the second is harmless and that a later search gets the clear message. The limit test loops over five bad values, including -1 and NaN, the exact inputs that misbehaved in the raw probe.
The server tests call startServer(0), which asks the operating system for a free port, and app.server.url reports the address it chose. That avoids port clashes between test runs. afterAll stops the server and frees the index once, after the last test. The result on macOS:
src/fuzzy.test.ts:
(pass) glue > the module is loaded on import [2.24ms]
(pass) glue > prefix match beats a typo match [3.25ms]
(pass) glue > an empty list is a thrown Error from Rust [1.84ms]
(pass) glue > limit is checked before it reaches WebAssembly [1.62ms]
(pass) glue > a non-string query is a TypeError, not a WebAssembly fault [0.25ms]
(pass) glue > a disposed search fails with a clear message [0.23ms]
(pass) server > GET /api/search returns ranked names [7.50ms]
(pass) server > a bad limit is a 400, not a crash [0.18ms]
8 pass
0 fail
16 expect() calls
With the server running from source, curl against it printed this (path, status, body):
/api/version 200 {"engine":"fuzzy-index 0.1.0","names":7}
/api/search?q=hoper 200 {"q":"hoper","results":["Grace Hopper"]}
/api/search?q=liskof&limit=3 200 {"q":"liskof","results":["Barbara Liskov"]}
/api/search?q=a&limit=-1 400 {"error":"limit must be an integer from 1 to 20, got -1"}
/api/search?q=a&limit=2.5 400 {"error":"limit must be an integer from 1 to 20, got 2.5"}
/nope 404 {"error":"not found"}
The two kinds of input that misbehaved with the raw class, a negative limit and a fractional one (2.9 in the probe, 2.5 here), now return a 400 with a message that names the rule and the value. Typos like hoper and liskof still find their names, and an unknown path hits the fetch fallback and returns 404.
Common mistake: catching every error in the handler. A bare catch that returns 400 for everything turns real server bugs, such as a disposed index, into client mistakes that nobody investigates. Spot it by asking what status a disposed index would return: whatever it is, it should not be 400.
Putting it together: one executable
A standalone executable is a single file that contains a copy of the Bun runtime plus your program. Bun's --compile flag builds one from a TypeScript or JavaScript file. It exists so that you can copy one file to another machine and run it with nothing installed. For this to work, every file the program reads at runtime must be inside that executable. The with { type: "file" } import is what puts the .wasm inside, because the Bun docs say the build reads the file, embeds it, and replaces the import with an internal /$bunfs/ path. Without that import, the compiler does not know the .wasm is needed.
The project files that tie it together are package.json and tsconfig.json:
{
"name": "fuzzy-server",
"private": true,
"type": "module",
"scripts": {
"wasm": "cd wasm-rs && wasm-pack build --target web --out-dir ../pkg --no-pack",
"typecheck": "tsc --noEmit",
"start": "bun src/server.ts",
"compile": "bun build --compile src/server.ts --outfile fuzzy-server"
},
"devDependencies": {
"@types/bun": "1.4.4",
"typescript": "7.0.2"
}
}
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["bun"]
},
"include": ["src"]
}
package.json has four scripts, one per task: build the WebAssembly, typecheck, run from source, and compile. The two dependencies are pinned to exact versions. "type": "module" declares the package's .js files to be ES modules. In tsconfig.json, noEmit means tsc only checks types and never writes files, since Bun runs the TypeScript itself. allowImportingTsExtensions permits the ./fuzzy.ts style of import that the server uses. "types": ["bun"] brings in the Bun global's types and the Disposable interface that NameSearch implements. moduleResolution: "bundler" here is a TypeScript setting for how imports are looked up, unrelated to the wasm-bindgen bundler target.
The script run-lab.sh does the whole build, test, and ship sequence:
#!/usr/bin/env bash
# Build the WebAssembly module, test the glue, then ship one executable.
set -u
cd "$(dirname "$0")"
step() { echo; echo "== $*"; "$@"; echo "exit=$?"; }
wait_up() { for _ in $(seq 1 50); do curl -s -o /dev/null "$1" && return 0; sleep 0.1; done; return 1; }
echo "bun $(bun --version) on $(uname -sm)"
step bun run wasm
step bun install --frozen-lockfile
step bun test
step bun run typecheck
echo; echo "== bun src/server.ts (port 3101)"
(PORT=3101 exec bun src/server.ts) & SERVER=$!
wait_up http://localhost:3101/api/version
for path in "/api/version" "/api/search?q=hoper" "/api/search?q=liskof&limit=3" \
"/api/search?q=a&limit=-1" "/api/search?q=a&limit=2.5" "/nope"; do
printf '%-32s %s ' "$path" "$(curl -s -o /tmp/fuzzy-body -w '%{http_code}' "localhost:3101$path")"
cat /tmp/fuzzy-body; echo
done
kill $SERVER; wait $SERVER 2>/dev/null
step bun run compile
ls -l fuzzy-server | awk '{print $5, $9}'
echo; echo "== compiled binary, run from an empty folder (port 3102)"
EMPTY=$(mktemp -d); cp fuzzy-server "$EMPTY/"
(cd "$EMPTY" && PORT=3102 exec ./fuzzy-server) & SERVER=$!
wait_up http://localhost:3102/api/version
for path in "/api/version" "/api/search?q=dijsktra"; do
printf '%-32s %s ' "$path" "$(curl -s -o /tmp/fuzzy-body -w '%{http_code}' "localhost:3102$path")"
cat /tmp/fuzzy-body; echo
done
kill $SERVER; wait $SERVER 2>/dev/null
ls "$EMPTY"; rm -rf "$EMPTY"
The step function prints a heading, runs the command, and prints its exit code, so failures show up in the log. wait_up polls a URL up to 50 times, 0.1 seconds apart, so the script does not query a server that has not started. Each server is started in the background with exec, which makes $! the server's own process id, so kill $SERVER stops the server and not a wrapper shell.
The last half is the important test. The script copies only the binary into a new empty folder from mktemp -d, runs it from there, and finally lists that folder, so the only thing present is fuzzy-server. If the program secretly depended on pkg/ or src/, it would fail here, because they are not available. The folder holds one file and the server still answers.
The compile step on macOS printed:
$ bun build --compile src/server.ts --outfile fuzzy-server
[9ms] bundle 5 modules
[201ms] compile fuzzy-server
exit=0
89192930 fuzzy-server
Bun counted five modules and wrote fuzzy-server, which is 89,192,930 bytes on macOS arm64. On Linux x64 with Bun 1.4.2 it is 81,360,352 bytes (81360352 fuzzy-server). The .wasm is only 37,643 bytes, so almost all of that size is a copy of the Bun runtime inside the file. The compiled binary then answered from the empty folder:
fuzzy-server on http://localhost:3102/ (fuzzy-index 0.1.0)
/api/version 200 {"engine":"fuzzy-index 0.1.0","names":7}
/api/search?q=dijsktra 200 {"q":"dijsktra","results":["Edsger Dijkstra"]}
fuzzy-server
The version call proves the WebAssembly module loaded inside the executable, and the dijsktra search proves the Rust matcher ran. The final line is the ls of the empty folder: fuzzy-server and nothing else.
Common mistake: testing the compiled program only from the project folder. There, the original files still exist, and a program that reads them from disk appears to work. Spot it by running the binary from a new folder that holds nothing else, as the script does.
Pitfalls
The bundler target under Bun
Symptom:
TypeError: wasm.__wbindgen_start is not a function. (In 'wasm.__wbindgen_start()', 'wasm.__wbindgen_start' is undefined)
Cause: The bundler target's glue starts with import * as wasm from "./fuzzy_index_bg.wasm" and expects module exports. Bun's file loader turns that import into a namespace holding only __esModule and a default path string.
Fix: Build with --target web and load the bytes yourself with initSync, as in Concept B.
Calling an export from the web target before init
Symptom:
TypeError: undefined is not an object (evaluating 'wasm.__wbindgen_free')
Cause: The web glue keeps its instance in a module-level wasm variable that stays undefined until init or initSync runs. The first exported function you call reads it.
Fix: Run initSync at the top of the glue module, with top-level await for the file read, and let only the glue import the generated package. Then no caller can run first.
Default init() inside a compiled executable
Symptom:
`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:
ENOENT: no such file or directory, open '/$bunfs/root/fuzzy_index_bg.wasm'
path: "/$bunfs/root/fuzzy_index_bg.wasm",
syscall: "open",
errno: -2,
code: "ENOENT"
TypeError: Body already used
code: "ERR_BODY_ALREADY_USED"
The same probe, run from source, printed loaded: fuzzy-index 0.1.0 and exited 0. Only the compiled version failed.
Cause: The default init() resolves the .wasm next to the glue with import.meta.url and fetches it. Nothing told Bun to embed that file, so the executable has no copy at /$bunfs/root/fuzzy_index_bg.wasm. The warning about instantiateStreaming is the glue's fallback path, and the real error is the missing file (ENOENT). The Body already used error comes afterward, when the fallback tries to read the same failed response again.
Fix: Import the .wasm with with { type: "file" } so Bun embeds it, read it with Bun.file(...).bytes(), and pass the bytes to initSync.
The nodejs target breaks after the source folder is gone
Symptom:
188 | written: buf.length
189 | };
190 | };
191 | }
192 | var WASM_VECTOR_LEN = 0;
193 | var wasmBytes = __require("fs").readFileSync(wasmPath);
^
ENOENT: no such file or directory, open '<lab>/pkg-nodejs/fuzzy_index_bg.wasm'
path: "<lab>/pkg-nodejs/fuzzy_index_bg.wasm",
syscall: "open",
errno: -2,
code: "ENOENT"
at /$bunfs/root/compile-nodejs:193:45
Bun v1.4.2 (Linux x64)
exit=1
Cause: The nodejs glue loads its module with const wasmPath = ${__dirname}/fuzzy_index_bg.wasm` and require('fs').readFileSync(wasmPath). Inside the compiled program, that path is the original absolute build path, shown here as <lab>/pkg-nodejs/..., where <lab> stands for the project folder. The first compiled run printed loaded: fuzzy-index 0.1.0 only because the file was still on disk. After pkg-nodejs/` was moved away, the executable could no longer find it.
Fix: Use the web target with the file import and initSync, and test a compiled binary from an empty folder after moving or deleting the source, which run-lab.sh does.
TS2769 on Bun.file(wasmPath)
Symptom:
src/fuzzy.ts(7,35): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'typeof import("<lab>/pkg/fuzzy_index_bg.wasm")' is not assignable to parameter of type 'number'.
Cause: TypeScript resolves import wasmFile from "../pkg/fuzzy_index_bg.wasm" to the sibling fuzzy_index_bg.wasm.d.ts, which declares the raw exports. Bun's runtime value is a path string, so the declaration and the runtime disagree.
Fix: Cast once, in one place, with a comment: const wasmPath = wasmFile as unknown as string;.
Unchecked arguments
Symptom:
limit -1: [ "Ada Lovelace", "Alan Turing" ]
limit 2.9: [ "Ada Lovelace", "Alan Turing" ]
number as query: RuntimeError: Out of bounds memory access (evaluating 'realloc(ptr, len, len = offset + arg.length * 3, 1)')
Cause: WebAssembly takes numbers, and the generated class does not validate them. A negative limit wrapped into a huge usize, so -1 returned every match. A fractional limit was silently truncated, and a number passed as a string made the generated string-copy code compute a bad size.
Fix: Check types and ranges in the glue before calling Rust, using typeof, Number.isInteger, and a length cap, and throw TypeError or RangeError with a clear message.
Use after free
Symptom:
after free(): Error: null pointer passed to rust
Cause: free() empties the JavaScript handle. Later calls on it hit a null pointer, and wasm-bindgen reports null pointer passed to rust in a normal build.
Fix: Keep the raw index private to the glue, set it to null in [Symbol.dispose], and use #live() so the caller sees NameSearch was disposed instead.
Run it yourself
You need three tools:
- Rust, with the WebAssembly target added by
rustup target add wasm32-unknown-unknown. - wasm-pack 0.15.0, from its installer page (the version check in the lab log points to
https://wasm-bindgen.github.io/wasm-pack/installer/). - Bun 1.4.4 or 1.4.2, the two versions this was tested on.
Create the nine files from this lesson in one project folder: wasm-rs/Cargo.toml, wasm-rs/src/lib.rs, package.json, tsconfig.json, src/fuzzy.ts, src/names.ts, src/server.ts, src/fuzzy.test.ts and run-lab.sh. The script installs with bun install --frozen-lockfile, which installs exactly what bun.lock records, so run bun install once first to write bun.lock. Then make the script executable and run it:
chmod +x run-lab.sh
./run-lab.sh
Success looks like this. Each step prints exit=0. bun test reports 8 pass and 0 fail. The six curl lines show statuses 200, 200, 200, 400, 400 and 404. The compile step prints a size in the tens of millions of bytes. The compiled binary then answers both requests from the empty folder, and the final ls prints only fuzzy-server. On Linux x64 with Bun 1.4.2, the same script produced the same test and curl results and an 81,360,352-byte executable. A lockfile that Bun 1.4.2 wrote on Linux also passed --frozen-lockfile on macOS with Bun 1.4.4, unchanged.
Check yourself
**Why does the bundler target fail under Bun with wasm.__wbindgen_start is not a function?**
The bundler glue imports the .wasm file as a namespace and expects its exports. Bun's file loader gives a namespace with only __esModule and a default path string, so __wbindgen_start does not exist on it.
**What does initSync do that the default init() does not, and why does that matter for a compiled executable?**
initSync takes bytes you supply and compiles and instantiates them at once. The default init() fetches a .wasm located through import.meta.url, which Bun has not embedded in a compiled program. With initSync and the with { type: "file" } import, the bytes come from the embedded file system, so the executable needs no files beside it.
**Why does search(query, -1) on the raw class return every match, and what stops that in the glue?**
The Rust limit is a usize, which cannot be negative, so the -1 wrapped into a huge unsigned number and take returned everything. The glue rejects the call first: Number.isInteger(limit) && limit >= 1 && limit <= MAX_LIMIT must hold, otherwise it throws a RangeError.
**What does using s = NameSearch.fromNames(NAMES) do, and why is a second dispose safe?**
using calls s[Symbol.dispose]() when the enclosing block ends, even if an error is thrown, which frees the Rust index. A second call is safe because the first set #index to null and the code uses this.#index?.free(), which does nothing on null.
Why test the compiled binary from an empty folder?
In the project folder, the source and generated files still exist, so a program that reads them from disk looks fine. The nodejs target did exactly that: it worked until pkg-nodejs/ was moved, then failed with ENOENT. An empty folder holds only the binary, so anything the program needs but did not embed shows up as an error.
Sources
Bun documentation
- Single-file executable, "Embed assets & files": https://bun.com/docs/bundler/executables
- File Types: https://bun.com/docs/runtime/file-types
- Server (
Bun.serve,routes,stop): https://bun.com/docs/runtime/http/server - Loaders: https://bun.com/docs/bundler/loaders
- Node.js Compatibility (WebAssembly: "Fully implemented. Memory64 is disabled by default."): https://bun.com/docs/runtime/nodejs-compat
wasm-bindgen guide
- Deploying Rust and WebAssembly: https://wasm-bindgen.github.io/wasm-bindgen/reference/deployment.html
- Without a Bundler: https://wasm-bindgen.github.io/wasm-bindgen/examples/without-a-bundler.html
- Exported struct Rust Types: https://wasm-bindgen.github.io/wasm-bindgen/reference/types/exported-rust-types.html
- Result<T, E>: https://wasm-bindgen.github.io/wasm-bindgen/reference/types/result.html
wasm-pack
- The
buildcommand: https://wasm-bindgen.github.io/wasm-pack/book/commands/build.html
Books
- Steve Klabnik and Carol Nichols, The Rust Programming Language, ch. 20 "Unsafe Rust", "Calling Rust Functions from Other Languages", PDF pp. 600-601.
- David Flanagan, JavaScript: The Definitive Guide, 7th ed. (O'Reilly), §10.3.7 "import.meta.url", PDF p. 477, and §15.15.4 "WebAssembly", PDF p. 984.
- Boris Cherny, Programming TypeScript (O'Reilly, 2019), ch. 6 "Advanced Types", "Refinement", PDF p. 175; ch. 9 "Frontend and Backend Frameworks", PDF p. 269; ch. 11 "Interoperating with JavaScript", "Ambient Module Declarations", PDF p. 315.