Open-source library · MIT
@nodetool-ai/sandbox-compiler
Check whether an npm package runs in QuickJS before you load it.
Bundle a package with esbuild, scan the bundle for globals a QuickJS guest does not have, then import it in an empty QuickJS context. You get a named reason for every rejection and the export list for every package that passes.
$ npm install @nodetool-ai/sandbox-compiler- TypeScript
- Node 22+
- esbuild
- acorn
- QuickJS-ng
import {
bundleNpmModule,
scanBundle,
probeBundle,
} from "@nodetool-ai/sandbox-compiler";
// Resolve the package from this directory's node_modules.
const bundled = await bundleNpmModule(process.cwd(), "js-yaml");
if (bundled.status === "failed") {
throw new Error(`${bundled.failure.code}: ${bundled.failure.message}`);
}
const { source, bytes } = bundled.result;
const scan = scanBundle(source); // { errors, warnings, rejection? }
const verdict = await probeBundle(source); // { ok, exports, error?, logs }
console.log(bytes, scan.errors.length, verdict.ok, verdict.exports.length);Measured
Eight popular packages, three admitted.
Bundled with esbuild 0.28.1 and the options below. Every rejection names code that would throw in the guest at that line.
| Package | Version | Bundle | Inputs | Verdict |
|---|---|---|---|---|
| date-fns | 4.4.0 | 175.6 KB | 305 | Admitted, 250 exports |
| js-yaml | 4.3.0 | 101.4 KB | 2 | Admitted, 15 exports |
| fflate | 0.8.3 | 60.8 KB | 2 | Admitted, 49 exports. Warns on queueMicrotask and setTimeout, both guarded. |
| zod | 4.4.3 | 483.3 KB | 80 | Scan error: const F = Function (×2), used for compiled validators |
| fast-xml-parser | 5.7.3 | 142.1 KB | 27 | Scan error: window && window.parseInt (×3), no typeof guard |
| diff | 4.0.4 | 35.1 KB | 2 | Scan error: setTimeout (×2) |
| papaparse | 5.5.3 | — | — | Bundle failed: imports node:stream |
| cheerio | 1.2.0 | — | — | Bundle failed: imports 25 Node builtins |
Pipeline
Bundling proves the imports resolve. Nothing more.
So there are three stages. Each one can end the run with a named result instead of an exception.
- 01
Bundle
esbuild with
platform: "neutral", ESM output, target ES2022, no externals and no minification. Export conditions are pinned toimport,module,default. Neithernodenorbrowseris set, so a package's most portable build wins. An import of a Node builtin fails here, by name. - 02
Scan
An acorn walk of the bundle that tracks scopes. A free reference to a global the guest does not have is an error. A shadowed name is not a reference. A name the module feature-detects, inside
typeofor a branch guarded by one, is a warning. Dynamicimport()is rejected. - 03
Probe
The bundle is imported in a real QuickJS-ng context with no fetch, no filesystem, no environment and no host bridges. It gets 5 seconds, a 64 MB heap, a 1 MB stack and 20 log lines of 500 characters. The result is the list of exports the module produced, or the error it threw.
Scope-aware scan
Text search cannot tell these four apart.
The scan resolves each name against the scopes around it before it reports. It also reads typeof guards, so a package that checks for a timer before using it is admitted with a warning.
// error: a free reference to a global the guest lacks
export const bytes = Buffer.from("hi");
// error: no typeof guard, so this throws in the guest
export const parse = window && window.parseInt;
// nothing: `process` is a parameter here, not the global
export function argv(process) { return process.argv; }
// warning: the module checks before it uses it
export const later = typeof setTimeout === "function"
? (fn) => setTimeout(fn, 0)
: (fn) => fn();Results
Six ways to fail, each with a name.
| npm-module-builtin-import | Imports node:fs, node:stream or another builtin. |
| npm-module-unresolved | The package or one of its imports does not resolve. |
| npm-module-too-large | The bundle is over 1 MB. |
| npm-module-forbidden-global | The scan found a free reference to a missing global. |
| npm-module-scan-rejected | The bundle does not parse, or uses dynamic import(). |
| npm-module-probe-failed | The module threw or timed out while initializing. |
Cache
A version number is not a cache key.
- Keyed by content, not name@version
- The key hashes every input file esbuild read, the esbuild version, the compiler's contract version and the build options. A linked package, a transitive update or a lockfile change all produce a new key while the version string stays the same.
- A warm hit skips the probe
- The cached entry stores the scan report and the probe verdict with the bundle, so a second run starts no QuickJS context.
- Atomic writes
- Entries are written to a temp file and renamed. A key must match ^[a-f0-9]{64}$ before it becomes a path.
Questions
Limits and trade-offs.
- Why not run the package in QuickJS and catch the error?
- Initialization only runs the top level. A reference to setTimeout inside a function that runs on the third call passes that test and fails in production. The scan reads every path and names the line. The probe then catches what static reading misses, such as a top-level throw.
- Which globals count as missing?
- The list matches NodeTool's guest: Node globals such as
process,Bufferandrequire,evalandFunction, timers,WebAssembly, and DOM names. It is exported asFORBIDDEN_GLOBALS. It is not configurable yet, so a guest that provides timers would reject packages it could run. - Does an admitted package work?
- It initializes and it references nothing the guest lacks. That does not prove every export behaves. The probe lists the exports. It does not call them.
- Why 1 MB?
- The cap was set after measuring the table above. The largest bundle was zod at 483 KB, and the largest admitted one was date-fns at 176 KB.
- What does it depend on?
- esbuild, acorn and the QuickJS-ng WebAssembly build. It also depends on
@nodetool-ai/node-sdkfor its result types and on a NodeTool config package for the cache directory, whichNODETOOL_CACHE_DIRoverrides. The higher-levelcompileNpmModuleexpects a NodeTool sandbox pack directory. The three functions above do not.
NodeTool runs user and agent JavaScript in a QuickJS sandbox. Packs can expose an npm package to that sandbox by naming it in their manifest, and this compiler decides whether the package is admitted. It is MIT licensed.