This tutorial walks you through compiling Turmeric's WASM module, hosting it in a browser page, and calling the WASM API from JavaScript. It covers three progressively more capable projects: a zero-bundler "Hello WASM" page, a file-fetching example, and a Vite-based production app.
This guide is distinct from web/README.md (which documents the internal
try-REPL build) and docs/guides/c-integration-guide.md (which covers native C
interop). The target audience is a developer who wants to embed or ship a
Turmeric program on a web page.
| Requirement | How to get it |
|---|---|
Turmeric compiler (tur) built from source |
just build |
Emscripten SDK (emsdk) |
https://emscripten.org/docs/getting_started |
| CMake 3.20+ | system package manager or cmake.org |
| Node.js 18+ (for the dev server) | https://nodejs.org |
just (optional but recommended) |
https://github.com/casey/just |
Verify your environment before starting:
tur --version
emcc --version
cmake --version
node --version
The build pipeline has four stages:
tur compiles .tur source files to C99.emcc) compiles that C99 to wasm32, producing turmeric.wasm
plus a JS loader (turmeric.js).<script> tag.Key files in the Turmeric repository:
| File | Purpose |
|---|---|
src/web/wasm_glue.h |
Exported C API (turi_wasm_init, turi_wasm_eval, etc.) |
src/web/wasm_glue.c |
Implementation of the glue layer |
src/CMakeLists.txt lines 282-365 |
How the tur_wasm CMake target is defined |
web/main.js |
Reference JS integration using the WASM module |
The emcc link command in CMakeLists.txt exports these functions:
_turi_wasm_init _turi_wasm_reset _turi_wasm_shutdown _turi_wasm_eval
_turi_wasm_eval_ex _turi_wasm_version _turi_wasm_format _turi_doc_lookup
_malloc _free
And these runtime methods:
stringToUTF8 UTF8ToString lengthBytesUTF8
The JS module is exported as TurmericModule (via -sEXPORT_NAME=TurmericModule
-sMODULARIZE=1), so it is a factory function you call to obtain an initialized
module object.
The simplest possible end-to-end example. You create a single index.html that
loads the pre-built turmeric.js / turmeric.wasm from the Turmeric repo and
evaluates a one-liner.
From the Turmeric repository root:
just wasm
This runs just docs first (so stdlib/docstrings.tur is current) then invokes
CMake with TUR_WASM=ON and builds the tur_wasm target. Outputs:
build-wasm/wasm/turmeric.js
build-wasm/wasm/turmeric.wasm
Both files are also copied automatically to web/public/.
hello-wasm/
index.html
turmeric.js <- copy from build-wasm/wasm/turmeric.js
turmeric.wasm <- copy from build-wasm/wasm/turmeric.wasm
mkdir hello-wasm
cp build-wasm/wasm/turmeric.js hello-wasm/
cp build-wasm/wasm/turmeric.wasm hello-wasm/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello Turmeric WASM</title>
</head>
<body>
<pre id="output"></pre>
<!-- Load the Emscripten-generated JS loader. -->
<!-- MODULARIZE=1 means this defines TurmericModule as a factory function, -->
<!-- not an immediately-live module, so evaluation is safe inside .then(). -->
<script src="turmeric.js"></script>
<script>
TurmericModule().then(function(Module) {
// Must be called once before any evaluation.
Module._turi_wasm_init();
var input = '(+ 1 2)';
// Strings must be copied into WASM linear memory before passing to C.
// allocateUTF8 mallocs a buffer and writes the NUL-terminated string.
var ptr = Module.allocateUTF8(input);
// _turi_wasm_eval returns a malloc'd char* with the result.
var resPtr = Module._turi_wasm_eval(ptr);
// UTF8ToString reads a NUL-terminated string out of linear memory.
var result = Module.UTF8ToString(resPtr);
document.getElementById('output').textContent = result;
// Always free the input buffer. The result pointer is owned by the
// runtime and must NOT be freed by the caller.
Module._free(ptr);
});
</script>
</body>
</html>
Why these helpers are needed:
allocateUTF8(str) -- allocates WASM memory and writes the JS string as
UTF-8, returning a pointer. You must _free this pointer when done.UTF8ToString(ptr) -- reads a NUL-terminated C string from WASM memory
back into a JS string.Module._turi_wasm_eval -- note the leading underscore; Emscripten
prefixes all exported C functions with _.WASM modules require a real HTTP server (browsers block file:// WASM loads).
Either of these works:
npx serve hello-wasm
# or
python3 -m http.server 8080 --directory hello-wasm
Then open http://localhost:8080 (or whichever port serve prints).
Turmeric's WASM build uses pthreads via SharedArrayBuffer. Browsers only
expose SharedArrayBuffer in cross-origin isolated contexts, which require two
response headers on every page and asset:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
npx serve does not set these by default. For a quick fix, create a
serve.json config alongside your index.html:
{
"headers": [
{
"source": "**",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
]
}
]
}
Then run npx serve hello-wasm. The hello-wasm/ directory layout becomes:
hello-wasm/
index.html
serve.json
turmeric.js
turmeric.wasm
Extend the previous example to load a .tur source file at runtime via fetch
rather than embedding a string literal in the HTML.
Create program.tur in your project directory:
(defn greet [name] :str
(str-append "Hello, " name "!"))
(println (greet "Web"))
Replace the inline <script> block from Project A with:
<script src="turmeric.js"></script>
<script>
TurmericModule().then(async function(Module) {
Module._turi_wasm_init();
var src = await fetch('program.tur').then(function(r) { return r.text(); });
var ptr = Module.allocateUTF8(src);
var res = Module._turi_wasm_eval(ptr);
document.getElementById('output').textContent = Module.UTF8ToString(res);
Module._free(ptr);
});
</script>
hello-wasm/
index.html
program.tur
serve.json
turmeric.js
turmeric.wasm
turi_wasm_eval collapses result and error into a single string. For proper
error handling use turi_wasm_eval_ex:
// Allocate two output pointers in WASM memory (each is 4 bytes in wasm32).
var resultPtrAddr = Module._malloc(4);
var errorPtrAddr = Module._malloc(4);
var inputPtr = Module.allocateUTF8(src);
var status = Module._turi_wasm_eval_ex(inputPtr, resultPtrAddr, errorPtrAddr);
// Read the pointer values out of WASM memory.
var resultPtr = Module.getValue(resultPtrAddr, 'i32');
var errorPtr = Module.getValue(errorPtrAddr, 'i32');
if (status === 0) {
console.log('result:', Module.UTF8ToString(resultPtr));
Module._turi_wasm_free_string(resultPtr);
} else {
console.error('error:', Module.UTF8ToString(errorPtr));
Module._turi_wasm_free_string(errorPtr);
}
Module._free(inputPtr);
Module._free(resultPtrAddr);
Module._free(errorPtrAddr);
Note: both *out_result and *out_error must be freed with
_turi_wasm_free_string (not _free) because they are allocated by the glue
layer with its own allocator.
A production-ready setup using Vite as a bundler -- mirroring the approach used
by the Turmeric repository's own web/ directory.
npm create vite@latest my-tur-app -- --template vanilla
cd my-tur-app
npm install
mkdir -p public
cp /path/to/turmeric/build-wasm/wasm/turmeric.js public/
cp /path/to/turmeric/build-wasm/wasm/turmeric.wasm public/
Both files go into public/ so Vite serves them verbatim without bundling.
Open vite.config.js and add a server.headers block:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
Create src/turmeric.js to wrap the WASM module in a singleton:
import createModule from '/turmeric.js';
let _mod = null;
export async function init() {
if (_mod) return _mod;
_mod = await createModule();
_mod._turi_wasm_init();
return _mod;
}
export async function evalCode(src) {
var mod = await init();
var ptr = mod.allocateUTF8(src);
var rp = mod._turi_wasm_eval(ptr);
var out = mod.UTF8ToString(rp);
mod._free(ptr);
return out;
}
export async function version() {
var mod = await init();
var rp = mod._turi_wasm_version();
return mod.UTF8ToString(rp);
}
export async function reset() {
var mod = await init();
mod._turi_wasm_reset();
}
Replace the generated src/main.js with:
import { evalCode, version } from './turmeric.js';
document.querySelector('#app').innerHTML = `
<h1>Turmeric WASM</h1>
<textarea id="code" rows="6" cols="60">(+ 1 2)</textarea><br>
<button id="run">Run</button>
<pre id="output"></pre>
<p id="ver"></p>
`;
version().then(function(v) {
document.getElementById('ver').textContent = 'Turmeric ' + v;
});
document.getElementById('run').addEventListener('click', async function() {
var src = document.getElementById('code').value;
var out = await evalCode(src);
document.getElementById('output').textContent = out;
});
npm run dev
Open the URL that Vite prints (usually http://localhost:5173). Type an
expression in the textarea and click Run.
When you modify Turmeric itself or the glue layer (src/web/wasm_glue.c):
# From the Turmeric repository root
just wasm
# Copy updated artifacts into your project
cp build-wasm/wasm/turmeric.js my-tur-app/public/
cp build-wasm/wasm/turmeric.wasm my-tur-app/public/
just wasm always runs just docs first, which regenerates
stdlib/docstrings.tur and web/public/doc-names.json so that the runtime
doc-lookup table stays current.
To force a complete rebuild from scratch (useful if CMake's dependency tracking gets confused after a large refactor):
just clean-wasm
just wasm
turi_doc_lookup retrieves the stdlib documentation string for any named
function or macro. It backs the doc panel in the web REPL.
async function docLookup(name) {
var mod = await init(); // reuse the singleton from Section 4
var ptr = mod.allocateUTF8(name);
var rp = mod._turi_doc_lookup(ptr);
var doc = rp ? mod.UTF8ToString(rp) : '(no documentation)';
mod._free(ptr);
return doc;
}
// Example
docLookup('map').then(console.log);
The returned pointer is a static C string owned by the runtime -- do NOT free it.
Doc strings are generated by python3 tools/gendocs.py (just docs) and
baked into stdlib/docstrings.tur, which the runtime loads on turi_wasm_init.
If you add or update ;;; doc comments in a stdlib file, re-run just docs
and rebuild the WASM module to see the changes.
| Problem | Cause | Fix |
|---|---|---|
SharedArrayBuffer is not defined |
Missing COOP/COEP headers | Add both headers to every HTTP response (including .wasm) |
_turi_wasm_init is not a function |
Missing -sEXPORTED_FUNCTIONS flag |
Re-run just wasm with the correct CMake configuration |
TypeError: WebAssembly.instantiate fails |
Server sends .wasm as text/plain |
Configure MIME type application/wasm on your server |
| Page hangs on first call | pthread pool not yet started | Call _turi_wasm_init inside the .then() callback, not at top level |
allocateUTF8 is not a function |
Missing EXPORTED_RUNTIME_METHODS |
Ensure stringToUTF8,UTF8ToString,lengthBytesUTF8 are in the link flags |
.wasm file not found (404) |
Emscripten looks for .wasm next to .js |
Copy both files to the same directory; do not rename them |
The web/ directory in the Turmeric repository deploys via Cloudflare Pages.
The full pipeline is:
just deploy-web # runs: just web then cd web && npm run deploy
COOP/COEP headers are set by the Cloudflare Worker in web/worker.js so they
apply to every response including the .wasm file.
Cloudflare Pages / Netlify: Create a _headers file in your public/
directory (or the build output root):
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
GitHub Pages cannot set custom response headers. Use a Service Worker instead:
// sw.js
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).then(function(response) {
var headers = new Headers(response.headers);
headers.set('Cross-Origin-Opener-Policy', 'same-origin');
headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: headers,
});
})
);
});
Register it in index.html before the WASM module loads:
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
if (!reg.active) { location.reload(); }
});
}
</script>
Vite base path for sub-paths: If deploying to
username.github.io/my-tur-app/, set base in vite.config.js:
export default defineConfig({
base: '/my-tur-app/',
// ...
});
docs/guides/c-integration-guide.md -- calling C from Turmeric (native builds)docs/guides/effects-system-guide.md -- algebraic effects in Turmericweb/README.md -- the full try-REPL build and deployment workflowsrc/web/wasm_glue.h -- complete WASM C API reference