@tinysandbox/js-runtime
v0.3.1
Published
Stateless QuickJS runtime for standard WebAssembly hosts
Readme
@tinysandbox/js-runtime
A small, stateless QuickJS runtime for standard WebAssembly hosts. It has no
runtime dependencies and uses the same checked-in QuickJS-ng artifact and guest
glue as tinysandbox's Rust/Wasmtime js command.
The package never reads a file or calls fetch itself. Supply the wasm bytes or
a compiled WebAssembly.Module explicitly, compile an engine once, and call
runCode() as needed. Every call creates a new bounded linear memory, wasm
instance, QuickJS runtime, and context; no guest state survives the call.
import { readFile } from "node:fs/promises";
import { createEngine } from "@tinysandbox/js-runtime";
const bytes = await readFile(new URL(
"./node_modules/@tinysandbox/js-runtime/quickjs.wasm",
import.meta.url,
));
const engine = await createEngine(bytes);
const result = await engine.runCode("console.log(tools.search({ query: 'hello' }))", {
globals: {
"tools.search": ({ query }) => ({ query, hits: 1 }),
},
timeoutMs: 1000,
wasmMemoryBytes: 16 * 1024 * 1024,
quickjsHeapBytes: 8 * 1024 * 1024,
});Browser callers can fetch the exported quickjs.wasm asset themselves. Hosts
such as Convex that compile .wasm imports use the exported subpath directly:
import { createEngine } from "@tinysandbox/js-runtime";
import quickjsModule from "@tinysandbox/js-runtime/quickjs.wasm";
const engine = await createEngine(quickjsModule);
const result = await engine.runCode("(async () => { console.log(await context.value(null)) })()", {
globals: { "context.value": async () => await doConvexWork() },
});Browser playground
The package includes a minimal two-pane browser example that displays guest console output, the evaluated script's return value, exit status, elapsed time, and initial/peak wasm linear memory. It deliberately ends with an expression so the example can capture that value through a synchronous custom global; the browser never evaluates the source itself.
From this directory in either a source checkout or the unpacked package:
npm run example:browserThen open http://127.0.0.1:4173/. The default script prints that window and
document are unavailable inside the guest. Each click creates the same fresh,
bounded QuickJS/WASM instance as a direct runCode() call.
Cloudflare Pages
The site is entirely static. In a Cloudflare Pages Git integration, use:
| Setting | Value |
| --- | --- |
| Framework preset | None |
| Root directory | tinysandbox-js-runtime |
| Build command | npm run build:site |
| Build output directory | site-dist |
No environment variables, Pages Functions, Wrangler configuration, or separate
deployment workflow are required. build:site creates a clean output directory
containing only the playground, runtime.js, and quickjs.wasm.
runCode(code, options) resolves to exitCode, UTF-8 stdout and stderr, and
initial/peak wasm memory bytes. Its defaults are a 64 MiB wasm maximum, 32 MiB
QuickJS heap, 30 second monotonic deadline, and 1 MiB each for source, serialized
host responses, stdout, and stderr. A wasmMemoryBytes value below the artifact
minimum of 1,245,184 bytes is rejected before instantiation. Non-page-aligned
values are rounded down for the actual WebAssembly maximum.
Global names use dot-separated JavaScript identifier segments. A global's first
argument is the guest value; a second context argument exposes signal,
deadlineMs, remainingTimeMs(), and isCancelled(). Existing one-argument
functions keep working. Guest arguments cross the boundary with JavaScript's
normal JSON.stringify semantics (including its omission and coercion rules);
host return values must already be strict JSON values and are validated without
coercion. Invalid returns, invalid names, namespace conflicts, and
runtime-global shadowing fail deterministically.
A global may be async. Returning a promise suspends the guest and returns
control to the V8 event loop, so runCode() and runFile() are awaited:
const result = await engine.runCode(
"(async () => { console.log(JSON.stringify(await tools.search({ q: 'kittens' }))) })()",
{ globals: { "tools.search": async (args) => await performSearch(args) } },
);Every global returns a promise in the guest, so one script shape works on this runtime and on tinysandbox's Rust and Node hosts. A synchronous host global is answered inline and its promise is already settled: a microtask, not a suspension, so only the globals that need it pay for suspending. Concurrent awaits settle in completion order, and a rejected promise surfaces in the guest as a catchable error. At most 64 host calls may be outstanding at once. When all slots are occupied, another call is rejected before its host callback runs, including a synchronous callback. Promises returned by admitted callbacks remain observed even when the run is cancelled before consuming their results.
Optional filesystem capability
Pass a synchronous Vfs implementation to enable the same Buffer, fs
subset, and relative/absolute CommonJS loader as tinysandbox's /bin/js:
const result = await engine.runFile("main.js", {
vfs,
cwd: "/app",
argv: ["js", "main.js", "one"],
});runFile() resolves the entry against cwd, reads it through
open/readAt/close, rejects invalid UTF-8, and evaluates it with the
resolved path as __filename and the stack filename. If argv is omitted, it
defaults to ["js", originalPath], preserving the path string passed by the
caller. Relative require() resolves from the requiring module; direct fs
paths resolve from cwd.
The exported Vfs interface is deliberately small and synchronous: stat,
readdir, mkdir, rename, unlink, rmdir, open, readAt, writeAt,
truncate, and close, plus optional abort for discarding staged writes.
A run that reaches its own end closes any remaining descriptors, whatever exit
status the script chose; a run the host cut short — a timeout, an abort signal,
or an exhausted output limit — calls abort when supplied, otherwise close. Handles must continue to refer to the
same file after its original path is renamed, unlinked, or reused.
Paths delivered to it are normalized absolute paths;
handles and offsets are non-negative safe integers, and positional operations
do not change the guest fd cursor. Implementations report one of the exported
VfsErrno strings by throwing new VfsError(code). Quotas and storage
accounting belong to the supplied implementation. The package provides no
production filesystem or storage backend; the repository's TestVfs exists
only under test/ for examples and conformance tests.
Omitting vfs leaves the run storage-independent: Buffer is absent and both
require("fs") and file-module requests throw
ERR_CAPABILITY_UNAVAILABLE before any filesystem host call. This stateless
package also does not provide network access, timers, guest ESM, TypeScript
transpilation, Asyncify/JSPI, or persistent isolates. The compatibility glue's
fetch never reaches ambient V8 networking and fails with unavailable-host
capability behavior.
Host resource budgets
hostInputBytes bounds whole-file reads and decoded guest writes (default
8 MiB). hostResponseBytes bounds serialized host responses (default 1 MiB),
including JSON framing and base64 expansion. Oversized reads fail before their
entire contents are copied into the host or encoded. maxOpenFiles bounds guest
descriptors (default 1,024). Positional reads can return fewer bytes than
requested to stay within the response budget. Paths and recursive traversal
are limited to 256 components.
The monotonic timeoutMs budget covers source loading in runFile, guest
execution, and synchronous host calls. A host callback already executing cannot
be interrupted mid-call; further guest filesystem operations are rejected after
it returns if the deadline has expired.
Host callbacks can poll context.isCancelled() or context.remainingTimeMs()
to cooperate with that same monotonic deadline. Polling also refreshes
context.signal; deadline events cannot fire autonomously while synchronous
JavaScript blocks the event loop. deadlineMs is an approximate Unix timestamp
for display. An optional options.signal rejects already-aborted runs and
propagates aborts raised synchronously during callbacks. Each callback's signal
also aborts when that callback returns, and its listener is removed. The upstream
signal listener is removed when the run finishes. A timer on the same event loop cannot interrupt
runCode() or runFile(); use a separate worker if the host needs that boundary.
This package runs JavaScript only and has no jq command or jqMemoryBytes option.
The package build type-checks the implementation with strict TypeScript and
emits JavaScript and declarations together. Run npm test for the shared guest
corpus, independent Node filesystem comparisons, and resource-limit regressions.
Releases
The portable package keeps its own version number and releases automatically
alongside the Rust and native Node packages after successful CI on main.
Every eligible release increments both version lines, even when changes affect
only one package. The default bump is a patch; standalone #minor / #major
commit markers and Conventional Commits breaking-change markers use the same
rules as the native release. A breaking change below 1.0 increments the minor.
The Release workflow's manual bump input applies to both version lines;
current retries their checked-in versions. Automatic releases honor
[skip release] and release commits. The workflow updates the portable manifest
and lockfile together, verifies the package, and pushes the shared version
commit only after all packages publish successfully. Rerunning a failed release
from the same source reuses its prepared versions and skips packages already
published at those versions.
