Skip to content

NodeTool Cloud is in alpha. Try it →

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.

PackageVersionBundleInputsVerdict
date-fns4.4.0175.6 KB305Admitted, 250 exports
js-yaml4.3.0101.4 KB2Admitted, 15 exports
fflate0.8.360.8 KB2Admitted, 49 exports. Warns on queueMicrotask and setTimeout, both guarded.
zod4.4.3483.3 KB80Scan error: const F = Function (×2), used for compiled validators
fast-xml-parser5.7.3142.1 KB27Scan error: window && window.parseInt (×3), no typeof guard
diff4.0.435.1 KB2Scan error: setTimeout (×2)
papaparse5.5.3——Bundle failed: imports node:stream
cheerio1.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.

  1. 01

    Bundle

    esbuild with platform: "neutral", ESM output, target ES2022, no externals and no minification. Export conditions are pinned to import, module, default. Neither node nor browser is set, so a package's most portable build wins. An import of a Node builtin fails here, by name.

  2. 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 typeof or a branch guarded by one, is a warning. Dynamic import() is rejected.

  3. 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-importImports node:fs, node:stream or another builtin.
npm-module-unresolvedThe package or one of its imports does not resolve.
npm-module-too-largeThe bundle is over 1 MB.
npm-module-forbidden-globalThe scan found a free reference to a missing global.
npm-module-scan-rejectedThe bundle does not parse, or uses dynamic import().
npm-module-probe-failedThe 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, Buffer and require, eval and Function, timers, WebAssembly, and DOM names. It is exported as FORBIDDEN_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-sdk for its result types and on a NodeTool config package for the cache directory, which NODETOOL_CACHE_DIR overrides. The higher-level compileNpmModule expects 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.