@live-codes/grain-wasm
v0.1.0
Published
Compile and run Grain with one API, on the Grain compiler running in JavaScript
Maintainers
Readme
@live-codes/grain-wasm
Run Grain with one API. The compiler is JavaScript (a
js_of_ocaml build of grainc), the standard library is compiled by it inside the
page, and the WebAssembly it produces executes under a WASI shim in the same
thread — no server compiles anything, and nothing is sent anywhere.
import { createCompiler } from '@live-codes/grain-wasm';
const compiler = await createCompiler('grain'); // Node: reads the assets in this package
const { stdout, errors, exitCode } = await compiler.run(`
module Hello
print("hello from Grain")
`);hello from GrainInstall
npm install @live-codes/grain-wasmThe package carries its own runtime — the compiler (assets/grainc.bc.mjs) and the
standard library sources (assets/stdlib/) — so in Node there is nothing else to
set up, and in a browser one command puts a copy where you serve from. It has no
dependencies.
Node
Node can read the packaged assets off disk, so createCompiler('grain') needs no
configuration:
import { createCompiler } from '@live-codes/grain-wasm';
const compiler = await createCompiler('grain');
await compiler.run('module M\n\nprint("hi")', 'stdin for the program');
compiler.dispose();Browser
Host it in a worker. Compiling is synchronous — grainc does not yield, and the
program it produces runs to completion in one call — so a program that never
returns blocks its thread until the host terminates the worker. A worker is what
makes a runaway program killable.
A page cannot read files out of node_modules, so publish the assets and point
baseUrl at them:
npx grain-wasm-copy-assets public/grainimport { createCompiler } from '@live-codes/grain-wasm';
const compiler = await createCompiler('grain', {
baseUrl: new URL('/grain/', location.href),
onProgress: (fraction) => console.log(`${Math.round(fraction * 100)}%`)
});
const { stdout } = await compiler.run(code, stdinText);baseUrl may be a URL, an absolute URL, or a path relative to the page; the
loader fetches grainc.bc.mjs and stdlib/ from it, so a directory that works for
the page also works as a CDN path. It must send Access-Control-Allow-Origin if
cross-origin. grainc.bc.mjs is 19 MB — serve it compressed and let the browser
cache it as a module; it is fetched once per baseUrl.
Once published, the compiler module and the standard library are loaded once and
shared by every compiler created against the same baseUrl, so a host that makes a
compiler per run still only pays for the load once.
Which worker
With a bundler, import the module entry in a module worker:
// worker.module.js
import { createCompiler } from '@live-codes/grain-wasm';
const compiler = await createCompiler('grain', { baseUrl });If the worker cannot use ES modules — LiveCodes builds its language workers from a
content string — importScripts() the IIFE build instead:
// worker.js — a classic worker: no { type: 'module' }, no imports
importScripts('grain-wasm.global.js'); // from @live-codes/grain-wasm/iife
const compiler = await self.grainWasm.createCompiler('grain', { baseUrl });self.grainWasm is the same API as the module entry (createCompiler and
LANGUAGE_IDS). The bundle is ~15 KB, committed in dist/, built from the browser
entry so it carries no Node code, and pulls in no dependencies — the compiler and
the standard library are fetched at run time from baseUrl. example/
runs both entries — the module entry in a module worker, the IIFE in a classic
worker — and checks that they agree.
The classic-worker path loads grainc.bc.mjs with a dynamic import(), which
current browsers support in classic workers; that is what makes the IIFE genuinely
usable without module support, rather than just a smaller module entry.
API
createCompiler(language = 'grain', options)
Returns a promise for a compiler. The compiler module and the standard library are
loaded here, so a bad URL or an unreachable server fails at this point rather than
at the first run(). language is grain (gr is accepted).
| Option | Description |
| --- | --- |
| baseUrl | Where grainc.bc.mjs and stdlib/ are served from. Required outside Node. |
| onProgress | (fraction) => void while the standard library downloads, 0 to 1. |
The returned object:
| Member | Description |
| --- | --- |
| language | The resolved language id, always grain. |
| fileName | The name the compiler reports sources under (input.gr). |
| run(code, input?) | Compile and run. input is stdin, string or Uint8Array. |
| dispose() | Release this compiler's hold on the shared runtime. Further runs throw. |
There is no warm(): the expensive part of a first run is loading the compiler and
seeding the standard library, which createCompiler already does.
run() result
| Field | Description |
| --- | --- |
| stdout | What the program wrote to fd 1. |
| stderr | What it wrote to fd 2. |
| output | stdout and stderr in the order the program wrote them. |
| errors | The compiler's diagnostics, one string per line. Empty on a clean compile. |
| exitCode | The program's exit status, or null if it never ran. |
| wasmBytes | Size of the compiled module. |
| compileMs | Time spent compiling. |
| runMs | Time spent running, or null if the program never ran. |
A failed compile does not throw: it comes back with exitCode: null and errors
filled in — the same shape @live-codes/clang-wasm uses, so errors.length is a
reliable failure test. Only a load failure, a disposed compiler, or a bad argument
throws.
LANGUAGE_IDS
['grain']. Grain is one language and one compiler here: grainc always targets
WebAssembly and always links the standard library, so there is no target or
standard to choose between.
What works
Verified in Node and in Chromium; npm test compiles and runs all of it (13 tests).
- Compiling and running client-side — source → WebAssembly → execution, no server involved.
- The standard library, compiled from source in the browser like any other
code:
List,Bytes,Wasi.Fileand the rest of what thepervasivesprelude pulls in. - Recursion, loops, enums and pattern matching.
- stdin — a program reads the text you pass in on fd 0 (
File.fdRead), and a repeat run reads it again from the start. - Diagnostics with line/character ranges, e.g.
Error on line 4, characters 8-13:. - Exit codes — a program that exits cleanly reports
0.
Measured on this machine: the first compile of a program is ~5–10 s (Node), and Chromium similar; most of that is the compiler walking the standard library the first time. A hello-world module is ~220 KB of WebAssembly.
Limitations
- Output is batched, not streamed. The program runs to completion inside one call, so stdout arrives when it finishes. For a program that prints forever, nothing appears until it is killed. This is the main gap against LiveCodes' other wasm languages.
- One source string. The standard library is on the virtual filesystem, but a program cannot yet import extra modules or files it brings with it.
- No argv, no environment, no real filesystem. A program gets stdin and the standard library and nothing else; filesystem calls outside the seeded paths fail.
- A compile takes a few seconds. Fine for a Run button; too slow to drive from a keystroke debounce.
- Pinned to Grain 0.6.0. The compiler build and the standard library version must move together; there is no version negotiation. See THIRD-PARTY-NOTICES.md.
- It installs
globalThis.require. The compiler resolves a barerequire("fs")against the global scope (see below), so this package definesglobalThis.requirewhile loaded, chained to whatever was there before. It does not touchglobalThis.process.
Things worth knowing
Each of these cost real debugging time and shapes the code. They were established by running the compiler, not by reading its docs.
1. The compiler is a Node program that thinks it is under Node. grainc.bc.mjs
is js_of_ocaml output whose Unix runtime does require("fs") /
require("constants") and inspects a process. It must believe it is Node
(process.versions.node, process.platform === "posix", process.cwd() → "/") or
path handling silently changes. src/engine.js supplies all of that.
2. It needs an fs — and only a small one. src/fs.js is a ~300-line
in-memory filesystem covering exactly the synchronous Node surface the bundle
reaches for, found by running it against a logged proxy:
openSync closeSync readSync writeSync ftruncateSync fstatSync statSync lstatSync
readdirSync existsSync mkdirSync unlinkSync rmdirSync renameSync readlinkSync
symlinkSync opendirSync (+ a constants table)Upstream the same role is played by memfs, which is CommonJS-only; a hand-written
fs is why this package needs no bundler and no dependencies.
3. It does not need Buffer — but a WASI runtime would have. The only Buffer
in the compiler bundle is Grain's own Stdlib.Buffer. Choosing to hand-roll the
WASI shim (src/wasi.js) rather than depending on @wasmer/wasi also removed that
runtime's Buffer.from(base64) need, so this package ships no Buffer polyfill at
all.
4. grainc(d) takes the global object as a parameter. It reads d.process, so
passing a sandbox avoids clobbering the host's real process (which would be fatal
under Node) — but the bare require(...) inside the bundle resolves against the
real global scope, so that one value has to live on globalThis. The runner
stashes the active filesystem for the duration of the call, so require("fs") can
answer per compile.
5. Diagnostics live on fd 2 and end with a sentinel. A failed compile writes
File "input.gr", line N, characters A-B:\nError: … to fd 2 and then aborts by
throwing a js_of_ocaml error. The engine catches the throw and returns everything
before the literal grain: internal error, rewriting the file prefix to Error on.
6. Tail calls are feature-detected, not assumed. The compiler can emit
return_call and memory.copy; src/features.js probes for both with
WebAssembly.validate and passes --no-wasm-tail-call / --no-bulk-memory when
they are missing. Tail calls are not universal (Safari before 18), so emitting them
unconditionally would produce modules that fail to compile on real browsers.
LiveCodes integration
- Create one compiler per sandbox worker, lazily on the first Grain run, and reuse it: compiles are cheap once the compiler module and stdlib are loaded.
- Hosting the assets is a copy and a URL.
grain-wasm-copy-assetspublishesgrainc.bc.mjsandstdlib/; pass the directory asbaseUrl. Servegrainc.bc.mjsgzipped and cacheable — it is 19 MB. - Keep one compiler per worker. The compiled compiler module and the standard
library are shared by every compiler created against the same
baseUrl. run()collects output and returns it rather than streaming it; a runaway program can only be stopped by terminating the worker.- The compiler always reports the source as
input.gr, so diagnostics carryinput.gr(<line>). A host with its own file name should map that name when showing errors.
Development
npm install --include=dev # note: `npm config get omit` is `dev` on some machines
npm test # node --test — real compiles and runs, no mocks
npm run build:iife # rewrites dist/grain-wasm.global.js (committed)
npm run asset-receipts # after changing anything under assets/dist/ is committed because a classic worker can only importScripts() a URL, and
a consumer should not need a bundler to get one. bin/copy-assets.mjs verifies the
assets against src/asset-receipts.js before copying, so a stale pairing fails
there rather than on someone's page.
Licence
MIT for this package — see LICENSE. The vendored compiler is Grain under
LGPL-3.0, and the standard library comes from @grain/stdlib (MIT declared,
LGPL-3.0 in the Grain repository) — read
THIRD-PARTY-NOTICES.md before redistributing them.
