# sablejs: compiling untrusted JavaScript to host JavaScript instead of trapping it

> An ahead-of-time compiler that turns ES5.1 guest programs into host JavaScript, so a sandbox for user and AI-written rules costs no embedded VM and still produces stack traces that point at the original source.

**ErosZy/sablejs** — Run user plugins, rules, formulas, and AI-generated JavaScript safely in browsers.

- Repository: https://github.com/ErosZy/sablejs
- Stars: 1,124 · Forks: 53
- Language: JavaScript
- License: Apache-2.0
- Published: 2026-10-06 · Updated: 2026-10-06 · Language: en
- Canonical page: https://hysenlabs.com/projects/eroszy-sablejs

## No embedded VM, which is the whole architectural bet

Most code sandboxes for the browser work by putting a second JavaScript engine inside the first one. Figma took that route with QuickJS compiled to WebAssembly, and the README says sablejs was inspired by Figma's WebAssembly plugin sandbox and its QuickJS runtime. sablejs takes the opposite road. Its first bullet is fast, with AOT to host JavaScript and no embedded VM, and its second is debuggable, with source maps and inspectable compiler IR.

Those two claims are the same claim viewed from two sides. If the guest program becomes ordinary host JavaScript before it runs, there is no interpreter boundary to cross at call time, and there is no separate engine whose error formats you have to translate. The cost moves to compile time and to the discipline of what you accept as input.

The licence is Apache-2.0, the version is 2.0.0-beta.6, and the last push was on 2026-09-03 with a beta release published the same day. Two earlier betas sit in the release list within the same month, so the version line is moving quickly. The README warns that the npm `latest` tag remains on v1 until v2 is stable, which is the kind of detail that will save you an afternoon.

## Compiling, instantiating, and running a program

The quick start is two files. The first compiles an ES5.1 program, meaning a script with no imports that returns its result as the final expression, and writes the generated module to disk:

```js
// build.cjs
const fs = require("node:fs");
const { compile } = require("sablejs");
const generated = compile("({ total: input.price * 1.2 });", {
  optimization: "O1", // temporary containment while O2/Os are hardened
});
fs.writeFileSync("program.cjs", generated.code);
```

The second loads that artifact, creates an instance with globals, runs it, and disposes it:

```js
// run.cjs
const program = require("./program.cjs");
const instance = program.createInstance({
  globals: { input: { price: 100 } },
});

try {
  // { total: 120 } — synchronous, returns the value
  console.log(instance.run());
} finally {
  instance.dispose();
}
```

The package installs from the beta tag, because the stable tag still points at v1:

```sh
npm install sablejs@beta
```

Two lifecycle facts in that example matter more than they look. Instances are single-run and meant to be disposed, so one `instance.run()` call is the unit of execution and `dispose()` in a finally block is the correct habit. And the default `security: "sandbox"` mode recursively copies plain `globals` data, so a guest that mutates the object it was handed does not reach the host object graph.

There is also a build-time path, since examples/precompile covers precompilation and examples/caching covers caching the compiled artifact. That separation is the feature to notice if you are deploying this: compile once at build time, ship the generated module, and keep per-request work down to creating an instance and running it.

## O1, O2 and Os, and why the compiler defaults to the cautious one

The optimization levels are the part of the documentation that deserves a careful read, because the README is unusually blunt about their status. The compiler defaults to O1 when `optimization` is omitted. Explicit O2 and Os remain available for development and compatibility testing, but are experimental until the CFG/SSA hardening gates are complete. The README states plainly that O1 is containment, not a full correctness certificate.

So the default you get is the conservative one, and the comment in the quick start says why in a few words: temporary containment while O2 and Os are hardened. The hardening gates live in docs/cfg-ssa-hardening.md, which tells you the project has a written plan for when the faster levels become the recommended ones.

The performance claim is presented with the same care. The README gives a historical V8 Benchmark Suite 7 reference score dated 2026-08-24 of 2,202 in the sablejs O2 sandbox against 1,181 in QuickJS-WASM 0.32.0, and immediately qualifies it: this is not a production recommendation or a CFG/SSA-only result, and O2 has open correctness and harness-methodology work. The methodology and status are deferred to docs/performance.md.

That combination, a number plus an explicit refusal to let you treat the number as a recommendation, is a good sign about how the project reports itself. It also means the honest summary is that the speed claim is plausible and not yet settled.

## Guest functions, capabilities, and where the copy boundary sits

End a program with a function expression and `run()` hands you back a callable rather than a value:

```js
const program = require("./program.cjs");
// program source: "function price(input) { return { total: input.price * 1.2 }; } price;"
const instance = program.createInstance({ globals: {} });
// synchronous — returns the callable
const price = instance.run();
// { total: 120 } — synchronous call
price({ price: 100 });
instance.dispose();
```

The README is precise about a subtlety that sandbox implementations often get wrong. Arguments and the receiver are copied like `globals`, so guest mutations cannot reach host objects, but guest functions keep reference semantics between their own frames. Only host-initiated calls copy. If you want the simpler mental model, the alternative is to pass arguments through the `input` global and call once per run.

Host functions passed in are treated differently, and deliberately so. In sandbox mode `globals` may carry any host function, and it becomes a capability automatically, while `capability(fn, options)` returns an opaque `CapabilityToken`. That is the mechanism to reach for when you want a guest to call something specific: you are not trying to hide every global, you are naming the ones that cross the boundary. The README frames this as built for generated code, with copied data and explicit capabilities, which is a fair summary of the threat model. It also means a reviewer should read your globals object carefully, because that is the actual security boundary.

## Debugging generated code through source maps and dumped IR

This is where the project earns the debuggable bullet. `compile()` can emit a deterministic Source Map v3 for the generated CommonJS, mapping every statement's generated code back to the original guest source. Opting in takes `sourceMap: true` or `"external"` for a map with logical defaults, or `"inline"` for the same names embedded in the artifact.

The TypeScript surface is typed around that. Options are declared as `CompileOptions`, results as `CompileResult`, and `sablejs` ships declarations in `types/` wired through the package exports map for the main entry plus `sablejs/runtime` and `sablejs/worker`:

```ts
import { compile } from "sablejs";
import type { CompileOptions, CompileResult, SourceMapSettings } from "sablejs";

const options: CompileOptions = {
  optimization: "O1",
  security: "sandbox",
  sourceMap: { mode: "external", sourceFile: "rules/input.js" },
};
const result: CompileResult = compile(source, options);
```

For deeper inspection there is a `dumpDir` option that writes `hir.txt`, a completion-labelled `cfg.txt`, an SSA `mir.txt`, and the generated `code.js` into a directory. You can also attach graph objects directly with `includeHIR`, `includeCFG`, and `includeMIR`, or select one with `dumpIR`. The README stresses that the dump is a side channel and the compile result is unchanged by `dumpDir`.

The browser case gets a specific accommodation: `fs` can be passed as an inspection-mode adapter defaulting to Node's `fs` and `path`, lazily required, so a browser bundle can pass an in-memory implementation such as memfs and still get `dumpDir` working without Node built-ins.

Modern JavaScript is not accepted directly. The README says it should be downleveled with Babel or SWC before compilation, and browser artifacts can then be bundled with something like esbuild.

## Worker isolation, the platform examples, and the honest limits

The examples directory is the best map of what is supported, and it is unusually broad: Node with errors and guest functions, a browser bundle with inline source maps, worker isolation and timeouts, build-time precompilation, compiled-artifact caching, Deno, and Bun. The worker client is exported as `sablejs/worker` and is typed over a structural `SandboxWorker`, which the browser's `Worker` satisfies directly and Node's `worker_threads.Worker` satisfies through a small adapter in `examples/worker/host.cjs`.

Worker isolation and timeouts are worth separating as concepts, because they solve different problems. The copying semantics described above stop data crossing the boundary. A worker with a timeout stops a program from never returning. If you are running code an LLM wrote, you probably want both, and the fact that they are separate features rather than one bundled sandbox is a sign the threat model is thought through rather than decorative.

The limits are equally visible. This is a beta, v2 is not stable, `latest` is still v1, the default optimization level is described as containment rather than proof, and the input language is ES5.1 after downleveling rather than whatever your users write. There is a SECURITY.md and a THIRD_PARTY_NOTICES file in the tree, which is the minimum you would expect from something taking a security boundary seriously, though neither is a substitute for reading how the boundary actually works.

Compare it to the Figma route it takes inspiration from. QuickJS in a WebAssembly worker gives you a genuinely separate engine and a real wall, at the cost of an interpreter boundary and awkward debugging. sablejs gives you host-speed compiled code and real source maps, at the cost of trusting a compiler that is still working through its own hardening gates. That trade is the project, stated plainly.

## Conclusion

The design bet worth naming is that you do not need a second JavaScript engine to isolate code you did not write. By compiling guest programs ahead of time into host JavaScript, sablejs removes the interpreter boundary that most in-browser sandboxes pay for, and keeps the debugging story that a WebAssembly boundary usually costs you, since source maps and dumped compiler IR are first-class options rather than afterthoughts. It is a beta on the npm beta tag with `latest` still on v1, O2 and Os flagged as experimental, and O1 described as containment rather than a correctness certificate, so treat the version you pin as a real dependency decision. The fastest way to evaluate it is the two-file Node quick start above, then read docs/performance.md and docs/cfg-ssa-hardening.md, because those two documents hold the honesty the README defers.

## FAQ

### What does sablejs do and who is it for?

It compiles user-authored or AI-generated JavaScript ahead of time into host JavaScript so it can run inside a sandbox, and it is aimed at products that execute rules, plugins, or formulas they did not write. Topics on the repository are sandbox, aot, web-worker, untrusted-code, and ai-generated-code.

### How does sablejs keep guest code from touching host objects?

The default security sandbox mode recursively copies plain globals data, so guest mutations never reach the host object graph, and arguments and receivers on host-initiated calls are copied the same way. Guest code keeps reference semantics only between its own frames. Host functions cross the boundary deliberately, becoming capabilities.

### Which npm tag should I install?

Use npm install sablejs@beta for the v2 line. The README states that latest remains on v1 until v2 is stable, so installing the default tag gets you the older major version rather than the one the documentation describes.

### Can I use modern JavaScript syntax directly?

Not directly. The compiler accepts ES5.1 programs with no imports, so modern syntax should be downleveled first with Babel or SWC. For the browser, the generated artifact can then be bundled with a tool such as esbuild.

### Is the performance claim in the README settled?

No, and the README says so. It reports a dated V8 Benchmark Suite 7 reference score of 2,202 for the O2 sandbox against 1,181 for QuickJS-WASM 0.32.0, then notes this is not a production recommendation and that O2 has open correctness and harness-methodology work. The methodology is in docs/performance.md.

## Sources

- [ErosZy/sablejs on GitHub](https://github.com/ErosZy/sablejs)
- [Issues](https://github.com/ErosZy/sablejs/issues)
- [License: Apache-2.0](https://github.com/ErosZy/sablejs/blob/master/LICENSE)
- [README](https://github.com/ErosZy/sablejs/blob/master/README.md)
- [Releases](https://github.com/ErosZy/sablejs/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/eroszy-sablejs
