Embedding BioLang
The same WebAssembly module that runs this site's playground is a plain ES module you can
import into your own application. It has no server, no network dependency and no install
step: the interpreter, the bioinformatics builtins and the parser are all inside the
.wasm file.
That makes it useful in places a CLI cannot go — a teaching site that runs student code in the student's own browser, a lab notebook that must keep patient sequences on the machine that holds them, an Electron or Tauri desktop app, or a VS Code web extension.
Everything on this page is verified. The API below is checked against the shipped module on every commit — all 274 Rosalind problems in the example packs are re-run through it, and the documentation's own claims about which problems run in a browser are audited against what actually happens.
Getting the module
Two files are needed, and they must come from the same build: the module loader
bl_wasm.js and the binary bl_wasm_bg.wasm. Both are served from
this site, so the quickest way to get a matching pair is to copy them:
curl -O https://lang.bio/wasm/bl_wasm.js
curl -O https://lang.bio/wasm/bl_wasm_bg.wasm
To build them yourself from a checkout — which is what you want if you are pinning a version or adding your own builtins:
wasm-pack build crates/bl-wasm --target web \
--out-dir ./wasm --no-typescript --release
Serve both files from the same directory. The loader fetches the .wasm
alongside itself, and your server must send it as
application/wasm — a wrong MIME type is the single most common reason
the module fails to start.
Running code
Three calls: load the binary, initialise, evaluate. init() is not optional
— it installs the panic hook that turns a Rust panic into a readable error, and the
file-reading bridge described further down.
import init, { evaluate } from "./wasm/bl_wasm.js";
await init(); // loads and instantiates the .wasm
const module = await import("./wasm/bl_wasm.js");
module.init(); // panic hook + file bridge
const result = JSON.parse(evaluate(`
let genome = dna("ATGCGCGATCGATCG")
println("GC: " + str(gc_content(genome)))
reverse_complement(genome)
`));
console.log(result.output); // "GC: 0.6\n"
console.log(result.value); // "DNA(CGATCGATCGCGCAT)"
console.log(result.type); // "DNA"
What evaluate returns
A JSON string, not an object — parse it. Errors are reported in the payload
rather than thrown, so a syntax error in user code does not need a
try/catch. Note that value is formatted for
display, so a typed value arrives wrapped: DNA(ACGT), not
ACGT. Reach for structured when you need the data itself.
// Success
{
ok: true,
value: "5", // the final expression, formatted for display
type: "Int", // its runtime type
output: "hi\n", // everything print/println wrote
structured: null, // a table or chart as JSON, when the value is one
results: [], // every displayed value, for a notebook-style UI
trace: [{ line: 1, text: "\"hi\"" }] // which line produced what
}
// Failure
{
ok: false,
error: "undefined variable 'nope'",
output: "" // whatever was printed before it failed
}
output is filled in on failure too, which matters: a script that prints
progress and then fails should still show the reader how far it got.
State persists between calls
The interpreter is kept alive between evaluate calls, so an embedded editor
behaves like a REPL without any work on your part:
evaluate("let counts = read_fasta('reads.fa') |> map(|r| len(r.seq))");
evaluate("mean(counts)"); // counts is still in scope
reset(); // drop every variable and start clean
evaluate("mean(counts)"); // { ok: false, error: "undefined variable 'counts'" }
This is worth deciding about deliberately. A notebook wants the state; a "run this
snippet" widget on a documentation page usually does not, and should call
reset() before each run so one example cannot silently depend on another.
That failure mode is real — it is the reason this project runs each example pack as
a single shared session in CI, which caught roughly a dozen examples that only worked
because of a variable an earlier one had left behind.
Reading files
read_fasta, read_csv, read_vcf and the rest call
out to JavaScript, because WebAssembly has no filesystem. The module looks for
window.__blFetch.sync and calls it with the path. Install it before
init():
window.__blFetch = {
sync(url) {
// Anything you return is the file's contents. Return a string beginning
// with "ERROR:" to make the BioLang call fail with that message.
if (localFiles[url]) return localFiles[url];
const xhr = new XMLHttpRequest();
xhr.open("GET", url, false); // synchronous: the interpreter is blocking
xhr.send();
return xhr.status === 200 ? xhr.responseText : "ERROR:404 " + url;
},
};
The call is synchronous, which is the awkward part: the interpreter is running inside a
single WebAssembly invocation and cannot await anything. On the main thread a synchronous
XMLHttpRequest freezes the page, so for anything larger than a small file,
run the module in a Web Worker and let the worker block instead. Serving a file to a
user's own browser also means CORS applies — a remote host that does not allow your
origin will fail here and nowhere else.
If your files are already in memory — dropped onto the page, or pulled from
IndexedDB — you can skip the network entirely and return them from a lookup, as the
example above does with localFiles.
The rest of the API
Every export returns a JSON string unless noted.
| Export | Returns | Notes |
|---|---|---|
init() |
— | Panic hook and file bridge. Call once, after the loader. |
evaluate(source) |
result object | Runs code in the persistent interpreter. |
reset() |
— | Discards all variables and functions. |
list_variables() |
[{ name, type, preview, members }] |
Everything currently in scope — for a variables pane. |
list_builtins() |
[{ name, arity }] |
789 in the current build. Use it to drive autocomplete. |
tokenize(source) |
[{ start, end, kind }] |
Byte offsets, for syntax highlighting. |
format(source, indent) |
source string | The formatter behind bl fmt. |
qc_metrics(kind, text) |
metrics or null |
kind is "fastq" or "vcf"; anything else is null. |
import_source(source, format, filename) |
converted source | Translates Python, R or a notebook into BioLang. |
validate_import(source, notebook) |
report | Checks a conversion without running it. |
Running under Node
The --target web build expects a browser. Under Node it will try to
fetch the .wasm and fail, so hand it the bytes yourself and stub
the two globals it looks for:
import fs from "node:fs";
globalThis.window = globalThis;
globalThis.__blFetch = { sync: (url) => fs.readFileSync(url, "utf8") };
const bytes = fs.readFileSync("wasm/bl_wasm_bg.wasm");
const wasm = await import("./wasm/bl_wasm.js");
await wasm.default({ module_or_path: bytes });
wasm.init();
console.log(JSON.parse(wasm.evaluate("2 + 3")).value); // "5"
This is exactly how this project's own CI runs all 274 example problems through the module, so it is a supported path rather than a trick.
What is not in the browser build
The WASM build ships a subset of the CLI's builtins — 789 of them. Anything that needs a real operating system is absent: writing files, spawning processes, opening sockets, and the API clients that would be blocked by CORS anyway. A call to a missing builtin fails at runtime with an ordinary "undefined variable" error.
list_builtins() is the authoritative answer for a given build, and checking
against it beats assuming. Passing native code through CI is not evidence a script runs in
a browser — a pack can be green natively and dead in the playground, which is why
the example packs here are run through the real module separately.
Size and startup
The release module is a few megabytes and compiles once per page load. Two things worth
doing: serve it with Content-Encoding: br or gzip, which roughly
quarters the transfer, and start the load before the user needs it rather than on their
first click. Once instantiated, evaluate is a direct call with no per-run
startup cost.
Licensing
BioLang is MIT licensed, and the WebAssembly module carries the same terms — so embedding it in a commercial product is fine, provided the licence text travels with it. The full text is in the repository.