Skip to content

Embedding Sema (JavaScript)

Overview

Sema can be embedded as a JavaScript scripting engine via WebAssembly. The WASM build runs entirely client-side — no server needed. You get the full Sema standard library (minus shell access and LLM functions) in the browser, including HTTP via fetch(), an in-memory virtual filesystem, and persistent definitions across evaluations.

Two npm packages are available:

PackageDescription
@sema-lang/semaRecommended. High-level TypeScript wrapper with ergonomic API
@sema-lang/sema-wasmLow-level wasm-bindgen output — exports SemaInterpreter (used internally)

This page documents the low-level @sema-lang/sema-wasm API. For the wrapper, see the npm README.

Quick Start

npm

Install the WASM package:

sh
npm install @sema-lang/sema-wasm

Evaluate an expression in three lines:

js
import init, { SemaInterpreter } from '@sema-lang/sema-wasm';

await init();
const interp = new SemaInterpreter();

const result = interp.evalGlobal('(+ 1 2 3)');
console.log(result.value); // "6"

CDN

Use Sema directly in a <script> tag with no build step:

html
<script type="module">
  import init, { SemaInterpreter } from 'https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm.js';

  await init('https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm_bg.wasm');
  const interp = new SemaInterpreter();

  const result = interp.evalGlobal('(+ 1 2 3)');
  console.log(result.value); // "6"
</script>

Creating an Interpreter

new SemaInterpreter() creates a new interpreter with the full standard library, I/O overrides for browser output, and a 10M eval-step limit to prevent infinite loops from freezing the tab.

js
import init, { SemaInterpreter } from '@sema-lang/sema-wasm';

await init();
const interp = new SemaInterpreter();

The init() call loads and compiles the WASM binary. It only needs to be called once — after that, you can create as many interpreters as you want.

When using CDN, pass the .wasm URL explicitly:

js
await init('https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm_bg.wasm');

Evaluating Code

Sema's WASM API provides four evaluation methods. All return a JS object with the same shape:

ts
// JS object returned directly by eval methods
interface EvalResult {
  value: string | null;   // String representation of the result, or null on error
  output: string[];       // Lines printed by (print), (println), (display)
  error: string | null;   // Error message with stack trace, or null on success
}

evalGlobal(code) — Synchronous, Persistent

Evaluates code in the global environment. Definitions persist across calls:

js
const interp = new SemaInterpreter();

interp.evalGlobal('(define x 42)');
const result = interp.evalGlobal('(+ x 8)');
console.log(result.value); // "50"

eval(code) — Synchronous, Isolated

Evaluates code in a child environment. Definitions do not persist:

js
interp.eval('(define y 10)');
interp.eval('y'); // error: unbound variable y

evalAsync(code) — Async, with HTTP Support

Use this when your Sema code makes HTTP requests. The async method bridges the synchronous Sema evaluator with the browser's asynchronous fetch() API using a replay-with-cache strategy:

js
const result = await interp.evalAsync('(http/get "https://httpbin.org/get")');
console.log(result.value); // {:status 200 :headers {...} :body "..."}

evalVM(code) / evalVMAsync(code) — Bytecode VM

These use the bytecode compiler and VM instead of the tree-walking evaluator. Same interface, potentially faster for compute-heavy code:

js
const result = await interp.evalVMAsync('(http/get "https://httpbin.org/get")');

Error Handling

Errors are returned in the error field — they never throw JavaScript exceptions:

js
const result = interp.evalGlobal('(/ 1 0)');

if (result.error) {
  console.error('Sema error:', result.error);
  // Includes stack trace and hints when available
} else {
  console.log('Result:', result.value);
}

// Output lines are always captured, even on error
for (const line of result.output) {
  console.log('Output:', line);
}

Capturing Output

(print), (println), and (display) write to a buffer that is returned in the output array:

js
const result = interp.evalGlobal(`
  (println "hello")
  (println "world")
  (+ 1 2)
`);

console.log(result.output); // ["hello", "world"]
console.log(result.value);  // "3"

Minimal Interpreter (No Stdlib)

Use SemaInterpreter.createWithOptions() to create a minimal interpreter with only special forms and core evaluation — no standard library functions:

js
const minimal = SemaInterpreter.createWithOptions({ stdlib: false });

minimal.evalGlobal('(+ 1 2)').value; // "3" (special forms work)
minimal.evalGlobal('(map identity (list 1 2 3))').error; // "unbound variable: map"

Sandboxed Interpreter

Deny specific capabilities while keeping the full stdlib:

js
// Deny network access
const sema = SemaInterpreter.createWithOptions({
  deny: ["network"]
});

sema.evalGlobal("(+ 1 2)");                           // works
sema.evalGlobal('(http/get "https://example.com")');   // => PermissionDenied error

// Deny both network and VFS writes
const strict = SemaInterpreter.createWithOptions({
  deny: ["network", "fs-write"]
});

Available capabilities to deny:

CapabilityAffected Functions
"network"http/get, http/post, http/put, http/delete, http/request
"fs-read"file/read, file/exists?, file/list, file/is-directory?, file/is-file?
"fs-write"file/write, file/delete, file/rename, file/mkdir, file/append

Registering JavaScript Functions

Use registerFunction to expose JavaScript functions to Sema code. Arguments are passed as native JS values, and the return value is converted back to a Sema value:

js
const interp = new SemaInterpreter();

// Simple function — args arrive as native JS values
interp.registerFunction('add1', (n) => n + 1);

interp.evalGlobal('(add1 41)').value; // "42"

Multiple Arguments

Each Sema argument is passed as a separate native JS value:

js
interp.registerFunction('greet', (greeting, name) => `${greeting}, ${name}!`);

interp.evalGlobal('(greet "Hello" "world")').value; // "Hello, world!"

Returning Structured Data

Return a JSON string for objects/arrays — they'll be converted to Sema maps/lists:

js
interp.registerFunction('get-user', (id) => {
  return JSON.stringify({ name: "Alice", age: 30 });
});

interp.evalGlobal('(:name (get-user 1))').value; // "Alice"

Capturing State

Use closures to share mutable state between JavaScript and Sema:

js
let counter = 0;
interp.registerFunction('inc!', () => ++counter);

interp.evalGlobal('(inc!)').value; // "1"
interp.evalGlobal('(inc!)').value; // "2"

Value Conversion

Arguments are passed as native JS values (numbers, strings, booleans, arrays, objects). Return values are automatically converted: numbers, booleans, null/undefined → nil, strings, and JSON-stringified objects/arrays are all supported. Non-JSON-serializable values (functions, symbols, circular references) are not supported.

Preloading Modules

Use preloadModule to inject virtual modules that can be imported with (import "name") — no filesystem needed:

js
const interp = new SemaInterpreter();

interp.preloadModule('utils', `
  (define (double x) (* x 2))
  (define pi 3.14159)
`);

interp.evalGlobal(`
  (import "utils")
  (double pi)
`).value; // "6.28318"

Selective Exports

Use (module ...) with (export ...) to control which bindings are visible:

js
interp.preloadModule('math', `
  (module math (export square cube)
    (define (square x) (* x x))
    (define (cube x) (* x x x))
    (define internal-helper 42))
`);

interp.evalGlobal(`
  (import "math" square)
  (square 5)
`).value; // "25"

Persistent Definitions

Use evalGlobal to build up state across multiple calls — this is the key pattern for embedding:

js
// Define functions
interp.evalGlobal(`
  (define (greet name)
    (string-append "Hello, " name "!"))
`);

// Define data
interp.evalGlobal('(define users (list "Alice" "Bob" "Carol"))');

// Use them together
const result = interp.evalGlobal('(map greet users)');
console.log(result.value); // ("Hello, Alice!" "Hello, Bob!" "Hello, Carol!")

Virtual Filesystem

The WASM build includes an in-memory virtual filesystem. Files persist for the interpreter's lifetime but are lost on page reload:

js
interp.evalGlobal('(file/write "/config.json" "{\\"key\\": \\"value\\"}")');

const result = interp.evalGlobal('(file/read "/config.json")');
console.log(result.value); // "{\"key\": \"value\"}"

Quotas apply: 1 MB per file, 16 MB total, 256 files max.

Virtual Filesystem (from JavaScript)

The VFS can also be accessed directly from JavaScript, enabling file browser UIs, script editors, and pre-seeded environments:

Seeding Files

js
const sema = new SemaInterpreter();

// Write files from JS
sema.writeFile("/lib/math.sema", "(define (square x) (* x x))");
sema.writeFile("/main.sema", '(import "/lib/math") (square 7)');

// Run the script
sema.evalGlobal('(load "/main.sema")'); // => 49

Building a File Browser

js
sema.mkdir("/src");
sema.writeFile("/src/app.sema", "(println \"hello\")");
sema.writeFile("/src/utils.sema", "(define pi 3.14)");
sema.writeFile("/README.md", "# My Project");

sema.listFiles("/");      // ["README.md", "src"]
sema.listFiles("/src");   // ["app.sema", "utils.sema"]
sema.isDirectory("/src"); // true
sema.fileExists("/src/app.sema"); // true

Reading Files Back

js
const source = sema.readFile("/src/app.sema"); // "(println \"hello\")"
const missing = sema.readFile("/nope");        // null

Quota Management

js
const stats = sema.vfsStats();
// { files: 3, bytes: 62, maxFiles: 256, maxBytes: 16777216, maxFileBytes: 1048576 }

// Clear everything
sema.resetVFS();
sema.vfsStats(); // { files: 0, bytes: 0, ... }

Quotas: 1 MB per file, 16 MB total, 256 files max.

VFS Persistence

By default, VFS files live only in WASM memory and are lost on page reload. To persist files across sessions, pass a VFS backend when creating the interpreter:

js
import { SemaInterpreter, IndexedDBBackend } from "@sema-lang/sema";

const sema = await SemaInterpreter.create({
  vfs: new IndexedDBBackend({ namespace: "my-project" }),
});

// Sema code can read/write files as usual
await sema.evalStrAsync('(file/write "/config.json" "{\\"theme\\": \\"dark\\"}")');

// Persist current VFS state to the backend
await sema.flushVFS();

// On next page load, files are automatically restored via hydrate()

Built-in Backends

Four backends ship with the @sema-lang/sema package:

BackendImportPersistenceLimit
MemoryBackend@sema-lang/semaNone — lost on reloadWASM quota only
LocalStorageBackend@sema-lang/semaAcross page loads, per origin~5–10 MB
SessionStorageBackend@sema-lang/semaWithin the current tab~5–10 MB
IndexedDBBackend@sema-lang/semaAcross page loads, per originHundreds of MB

Choosing a Backend

Use IndexedDBBackend for production apps — it handles large file sets and doesn't compete with other localStorage usage. Use LocalStorageBackend for quick prototypes. Use MemoryBackend (or no backend) when persistence isn't needed.

Backend Options

All backends accept a namespace option to isolate storage:

js
// Two interpreters with separate persistent storage
const editor = await SemaInterpreter.create({
  vfs: new IndexedDBBackend({ namespace: "editor-files" }),
});
const preview = await SemaInterpreter.create({
  vfs: new IndexedDBBackend({ namespace: "preview-files" }),
});

Flush and Reset

js
// Persist VFS to the backend (call after eval)
await sema.flushVFS();

// Clear both VFS memory and persistent storage
await sema.resetVFSAndBackend();

Custom Backends

Implement the VFSBackend interface to persist files anywhere — a remote API, a service worker cache, or a custom database:

ts
import type { VFSBackend, VFSHost } from "@sema-lang/sema";

class CloudBackend implements VFSBackend {
  async init() {
    // Open connections, authenticate, etc.
  }

  async hydrate(host: VFSHost) {
    // Fetch files from your API and write them into the WASM VFS
    const files = await fetch("/api/files").then(r => r.json());
    for (const { path, content } of files) {
      host.writeFile(path, content);
    }
  }

  async flush(host: VFSHost) {
    // Read files from the WASM VFS and upload them
    const paths = host.listFiles("/");
    for (const name of paths) {
      const content = host.readFile("/" + name);
      if (content !== null) {
        await fetch("/api/files/" + name, {
          method: "PUT",
          body: content,
        });
      }
    }
  }

  async reset() {
    await fetch("/api/files", { method: "DELETE" });
  }
}

const sema = await SemaInterpreter.create({
  vfs: new CloudBackend(),
});

VFSHost API

The host object passed to hydrate() and flush() provides these methods:

  • readFile(path)string | null
  • writeFile(path, content) — write or overwrite a file
  • deleteFile(path)boolean
  • mkdir(path) — create directories recursively
  • listFiles(dir)string[] — list entries in a directory
  • fileExists(path)boolean
  • isDirectory(path)boolean
  • resetVFS() — clear all files and directories

Real-World Example: User-Scriptable Web App

A web application that lets users write Sema scripts to customize behavior. The host app evaluates user scripts and uses the results:

HTML

html
<div id="app">
  <textarea id="script" rows="8" cols="60">
(define (transform items)
  (filter (lambda (item) (> (:score item) 50))
    (map (lambda (item)
           (assoc item :label
             (string-append (:name item) " (" (number->string (:score item)) ")")))
         items)))
  </textarea>
  <button id="run">Run Transform</button>
  <pre id="output"></pre>
</div>

JavaScript

js
import init, { SemaInterpreter } from '@sema-lang/sema-wasm';

await init();
const interp = new SemaInterpreter();

// Preload sample data
interp.evalGlobal(`
  (define sample-data
    (list
      {:name "Alice" :score 85}
      {:name "Bob"   :score 42}
      {:name "Carol" :score 91}
      {:name "Dave"  :score 33}))
`);

document.getElementById('run').addEventListener('click', () => {
  const script = document.getElementById('script').value;

  // Load user's function definition
  const loadResult = interp.evalGlobal(script);
  if (loadResult.error) {
    document.getElementById('output').textContent = `Error: ${loadResult.error}`;
    return;
  }

  // Call the user's transform function with our data
  const result = interp.evalGlobal('(transform sample-data)');

  if (result.error) {
    document.getElementById('output').textContent = `Error: ${result.error}`;
  } else {
    document.getElementById('output').textContent = result.value;
    // Output: ({:label "Alice (85)" :name "Alice" :score 85}
    //          {:label "Carol (91)" :name "Carol" :score 91})
  }
});

Multiple Interpreters

Each SemaInterpreter instance has fully isolated state — its own environment, virtual filesystem, module cache, and eval-step counter:

js
const interpA = new SemaInterpreter();
const interpB = new SemaInterpreter();

interpA.evalGlobal('(define x 1)');
interpB.evalGlobal('(define x 2)');

interpA.evalGlobal('x').value; // "1"
interpB.evalGlobal('x').value; // "2"

CDN Usage

jsdelivr

https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm.js
https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm_bg.wasm

unpkg

https://unpkg.com/@sema-lang/sema-wasm/sema_wasm.js
https://unpkg.com/@sema-lang/sema-wasm/sema_wasm_bg.wasm

Complete HTML Page

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Sema Playground</title>
</head>
<body>
  <textarea id="code" rows="6" cols="50">(println "Hello from Sema!")
(+ 40 2)</textarea>
  <button id="run">Run</button>
  <pre id="output"></pre>

  <script type="module">
    import init, { SemaInterpreter } from
      'https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm.js';

    await init('https://cdn.jsdelivr.net/npm/@sema-lang/sema-wasm/sema_wasm_bg.wasm');
    const interp = new SemaInterpreter();

    document.getElementById('run').addEventListener('click', async () => {
      const code = document.getElementById('code').value;
      const result = await interp.evalAsync(code);

      let text = '';
      if (result.output.length > 0) {
        text += result.output.join('\n') + '\n';
      }
      if (result.error) {
        text += 'Error: ' + result.error;
      } else if (result.value) {
        text += '=> ' + result.value;
      }
      document.getElementById('output').textContent = text;
    });
  </script>
</body>
</html>

Limitations

Compared to the Rust embedding API, the WASM/JavaScript embedding has these differences:

FeatureRustJavaScript (WASM)
FilesystemReal filesystemIn-memory VFS with pluggable persistence (1 MB/file, 16 MB total)
Shell access(shell ...) worksNot available
registerFunctionRegister native Rust closuresregisterFunction with native JS value args
LLM functionsFull provider supportNot available in browser
HTTPSynchronous (reqwest)Async via fetch() (CORS restrictions apply)
Sandbox/CapsFine-grained capability controlInherently sandboxed by the browser
ThreadingSingle-threaded (Rc)Single-threaded (WASM)
Eval step limitUnlimited by default10M steps (prevents tab freezes)
stdin / read-lineWorksNot available

Workarounds

  • No LLM: Use JavaScript to call LLM APIs, then pass results into Sema via registerFunction or evalGlobal.
  • Persistence: Use a VFS backend (IndexedDBBackend, LocalStorageBackend) to persist files across page reloads. See VFS Persistence.

API Reference

Type / MethodDescription
init(wasmUrl?)Initialize the WASM module. Call once before creating interpreters. Pass URL when using CDN.
SemaInterpreterInterpreter instance with isolated state
new SemaInterpreter()Create an interpreter with full stdlib and 10M step limit
SemaInterpreter.createWithOptions(opts)Create with options: { stdlib, deny }. Use deny to restrict capabilities.
interp.eval(code)Evaluate in a child env (definitions don't persist). Returns a JS object { value, output, error }.
interp.evalGlobal(code)Evaluate in global env (definitions persist). Returns a JS object { value, output, error }.
interp.evalAsync(code)Async eval with HTTP support (tree-walker). Returns a Promise<object>.
interp.evalVM(code)Evaluate via bytecode VM. Returns a JS object { value, output, error }.
interp.evalVMAsync(code)Async VM eval with HTTP support. Returns a Promise<object>.
interp.registerFunction(name, fn)Register a JS function callable from Sema. Args passed as native JS values.
interp.preloadModule(name, source)Inject a virtual module for (import "name"). Returns { ok, error }.
interp.readFile(path)Read a VFS file. Returns string or null.
interp.writeFile(path, content)Write a file to the VFS.
interp.deleteFile(path)Delete a VFS file. Returns boolean.
interp.listFiles(dir)List entries in a VFS directory.
interp.fileExists(path)Check if path exists in VFS.
interp.mkdir(path)Create a directory in the VFS.
interp.isDirectory(path)Check if path is a directory.
interp.vfsStats()Get VFS usage stats (files, bytes, quotas).
interp.resetVFS()Clear all VFS state.
interp.flushVFS()Persist VFS to the configured backend. Returns Promise<void>.
interp.resetVFSAndBackend()Clear VFS and persistent backend. Returns Promise<void>.
MemoryBackendEphemeral VFS backend — no persistence
LocalStorageBackendPersist VFS to localStorage (~5 MB limit)
SessionStorageBackendPersist VFS to sessionStorage (per-tab)
IndexedDBBackendPersist VFS to IndexedDB (recommended for production)
VFSBackendInterface for custom backends: { init?, hydrate, flush, reset? }
VFSHostHost bridge passed to backends: { readFile, writeFile, deleteFile, mkdir, listFiles, fileExists, isDirectory, resetVFS }
interp.version()Returns the Sema version string
EvalResult{ value: string | null, output: string[], error: string | null }