@absolutejs/isolated-jsc
v0.11.0
Published
JavaScriptCore-native sandbox for Bun — heap-isolated execution for untrusted code, isolated-vm-shaped API
Maintainers
Readme
@absolutejs/isolated-jsc
JavaScriptCore-native sandbox for Bun. Heap-isolated execution for untrusted code, with an
isolated-vm-shaped API.
import { createIsolate, Reference } from "@absolutejs/isolated-jsc";
const isolate = await createIsolate({ memoryLimit: 64 });
const context = await isolate.createContext();
await context.setGlobal(
"log",
new Reference((msg) => console.log("[tenant]", msg)),
);
const script = await isolate.compileScript('await log("hello"); 1 + 1');
const result = await script.run(context, { timeout: 500 });
// result === 2
await isolate.dispose();Why this exists
Bun has no equivalent to Node's isolated-vm. The Node library is V8-specific — it links against V8's HasCustomHostObject ABI symbol — and Bun uses JavaScriptCore, not V8. So bun install isolated-vm succeeds, then import fails with undefined symbol: HasCustomHostObject.
This leaves an entire category of applications stranded on Node:
- AI agent code execution. Anthropic's
code_execution, the OpenAI Code Interpreter, every "run this LLM-generated snippet" tool — these all need a heap-isolated runtime with hard resource limits. (oven-sh/bun#25929) - Multi-tenant scripting. Cloudflare-Workers-style "your customer wrote some JS, run it scoped to their account."
- Build-time evaluation of untrusted plugins. (oven-sh/bun#23653)
- General sandboxing permissions. (oven-sh/bun#6617)
@absolutejs/isolated-jsc fills that gap. See BUN_POSITIONING.md for the Bun-focused strategic frame, ISSUES_WILL_CLOSE.md for the upstream issues this library closes, and UPSTREAM_ISSUES.md for the upstream Bun bugs this library works around (with cleanup instructions for when each is fixed). See MIGRATING_FROM_ISOLATED_VM.md for the Node isolated-vm to Bun migration path. See SECURITY.md for the threat model and hardening guidance. See SNAPSHOT_RESEARCH.md for the JavaScriptCore checkpoint research. See CHANGELOG.md for release notes.
Quick answers
Why not Node isolated-vm? It is the right shape for Node/V8, but Bun is JavaScriptCore. isolated-jsc ports the isolate-shaped API to Bun/JSC instead of trying to load a V8 addon.
Why not just use Bun Workers? Workers are the portable substrate and the fallback backend. This package adds the sandbox product layer: hardened globals, heap limits, timeouts, metrics, error fidelity, TypeScript helpers, pools, and explicit host capability brokers.
When should I require FFI? Require backend: "ffi" for hostile-code production paths on macOS or Linux where JavaScriptCore is available. Use backend: "auto" for portable defaults, demos, and CI. Add process/container/uid/network boundaries whenever a sandbox escape would expose meaningful host secrets.
What ships today (v0.8.16)
@absolutejs/isolated-jsc runs on two interchangeable backends behind one API:
FFI backend (default when libJSC is reachable) — talks to
libJavaScriptCoredirectly viabun:ffi. Cold heap ~300 KB, CPU timeouts use JSC's interrupt-driven watchdog (the isolate keeps running after a TimeoutError), and(0, eval)('X')/new Function('return X')()are blocked entirely viaJSGlobalContextSetEvalEnabled. Available on macOS (system framework) + Linux withlibjavascriptcoregtk-4.1-0orlibjavascriptcoregtk-6.0-1installed.Worker backend (fallback) — one Bun
Workerper isolate. Cold heap ~46 MB, timeout terminates the whole isolate. Always available (no system dependency). Default on Windows and any Linux without libJSC.
The two share every public type. Pick explicitly with createIsolate({ backend: 'ffi' | 'worker' | 'auto' }), or let 'auto' (the default) probe for libJSC and fall back to Worker. Both backends:
- Heap isolation. Each
Isolateruns in its own Bun Worker → its own JSC VM → its own GC heap. No memory sharing with the host or with peer isolates. isolated-vm-shaped API.Isolate,Context,Script,Reference,ExternalCopy. Port-friendly.- Wall-clock timeouts.
script.run(context, { timeout: 500 })— millisecond accuracy, enforced from the host viaWorker.terminate(). v1 trade-off: timeout terminates the entire isolate (pool at the app layer if you need to recycle). - Memory limits. Soft cap polled every 50 ms via
bun:jsc.memoryUsage(). Breach posts a fatal and self-terminates; the host rejects pending ops withMemoryLimitError. - Hardened sandbox by default (T2.1, new in 0.1). Host-capability globals —
fetch,Bun,process,Worker,WebSocket, hostpostMessage/addEventListener,navigator, storage, … — are stripped from the sandbox. User code can't reach them via bare lookup,globalThis.X,this.X, or directeval('X'). Pure JS built-ins (Math, JSON, Promise, the typed-array suite) and safe Web primitives (URL, TextEncoder, Web Crypto, setTimeout, console) stay reachable. Opt out per-isolate viaharden: falsefor trusted code, or expose specific capabilities viaunsafelyExposeGlobals: ['fetch']. - Host-callable
References. Expose host functions to the isolate. Worker backend: calls always round-trip via async message-passing (useawaiton the isolate side). FFI backend (0.3+): sync host fns return their value directly through a per-ReferenceJSCallback. Async (Promise-returning) host fns work too on FFI (0.4+) — the runner alternately yields to Bun's event loop and drains JSC's microtask queue until the promise settles, bounded byScript.run'stimeout. ExternalCopyfor marking large host-side values for cheap pass-through.- Optional bounded console capture.
onConsoleroutes isolateconsole.logcalls back to the host.maxConsoleEntriesandmaxConsoleBytesbound forwarded logs, and receipts report overflow. - First-class isolate pool (T2.2, new in 0.1).
createIsolatePool({ isolate, maxSize, idleMs, recycleAfter })returns a keyed pool — lazy spawn per key, reuse across calls, LRU eviction at cap, transparent re-spawn after isolate self-termination, configurable post-N-call recycle to bound JSC heap creep. Replaces the bespoke per-tenant lookup-or-spawn map every consumer rolls. - Context seed + data checkpoint (T2.3, new in 0.1; expanded in 0.8.19; receipts added in 0.8.21).
createContext({ seed, snapshot })still restores old plain snapshots, andcreateContext({ seed, checkpoint })restores versioned checkpoints fromcontext.checkpoint({ maxBytes, include, exclude }). Checkpoints carryschemaVersion, backend, byte length, included/skipped counts, and skipped-key reasons.context.checkpointWithReceipt()andisolate.createContextWithReceipt()produce schema-v1CheckpointReceipts —operation: "create" | "restore",executionId,durationMs, aggregatedskippedReasons, and optionalpurpose/tenant/policylabels for audit workflows. This is a data checkpoint, not a JSC heap pause/resume image; see SNAPSHOT_RESEARCH.md. - Error fidelity (T2.4, new in 0.1). Errors thrown inside the isolate round-trip with
error.cause(recursively) and enumerable own properties intact. Custom Error subclasses' instance data (HttpErrorwith.statusCode, etc.) survives.instanceofdoesn't work across the boundary; use.name/.codechecks. - Per-run telemetry (T2.4, new in 0.1).
script.runWithMetrics(ctx, opts)returns{ result, metrics: { backend, cpuMs, heapBytes } }for billing / dashboards / per-call monitoring. Plainrun()still returns the bare value. - Execution receipts.
script.runWithReceipt(),callable.callWithReceipt(),runIsolated(..., { withReceipt: true }), and runner receipt modes return local audit records withschemaVersion: 1, execution id, backend, policy, resource settings, timing, output size, bounded capability-call summaries, and dropped-event counts. - Capability audit redaction. Brokers support default
redactAuditInput/redactAuditOutputhooks, and each tool can override them before audit events hit logs or receipts. - Bounded capability audit buffers.
createCapabilityAuditBuffer({ maxEvents })gives apps a receipt-readyonAuditsink with explicit truncation metadata, avoiding unbounded in-memory audit arrays when sandbox code spams host tools. - Capability output limits. Set
maxOutputBytesper capability ordefaultMaxOutputByteson a broker to reject oversized host-tool outputs withCapabilityErrorbefore they return to sandbox code. - Result size limits. Pass
maxResultBytesin run options to reject oversized successful outputs withResultSizeErrorbefore application code accepts them. - Console output limits. Pass
maxConsoleEntries/maxConsoleByteswhen creating an isolate to drop excess captured console events and surface overflow flags in receipts. - Backend observability.
isolate.backendreports the resolved backend ("ffi"or"worker"),runWithMetrics()/callWithMetrics()includemetrics.backend, andisolated-jsc doctor --jsonemits machine-readable backend probe details. - Policy recipes.
createIsolate({ policy: "ai-tool" | "tenant-script" | "plugin" | "trusted" })applies standardized isolate/run defaults.resolveIsolatePolicy(name, overrides?)returns the same policy object plus arecipewith recommended result limits, console limits, audit buffer caps, broker caps, and runner pool settings. Helper builders such aspolicyAuditOptions(policy),policyBrokerOptions(policy),policyConsoleOptions(policy),policyRunOptions(policy), andpolicyRunnerOptions(policy)turn those recommendations into copy-safe options for the surrounding APIs. - One-shot execution.
runIsolated(source, { policy, globals, context, run, withMetrics })covers request/response paths that do not need to manage isolate lifecycle directly. - Reusable runners.
createIsolatedRunner({ policy, globals, pool })reuses isolates by key for hot tenant/session/conversation paths. Userunner.run()for fresh-context source execution,runner.precompile()to warm callables,runner.call()to compile once per key/name and call repeatedly, andrunner.stats()to inspect pool/cache size.
Bun sandboxing decision guide
| Use case | Choose | Why |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Local development, demos, CI smoke tests, or platforms without libJSC | backend: "auto" | Uses FFI when available and Worker when it is not, so the same code runs everywhere. |
| Production untrusted code on macOS or Linux where you can install JavaScriptCore | backend: "ffi" | Lowest cold heap, interrupt-driven CPU timeouts, isolate survives timeouts, and eval / Function-constructor residuals are closed. |
| Production untrusted code on Windows or hosts without system JavaScriptCore | Worker fallback behind a process/container boundary | Worker keeps heap isolation and resource caps, but hostile workloads should not share host secrets or broad network/file permissions. |
| User plugins that need explicit host powers | FFI plus Reference / capability broker | Keep the sandbox global small and expose only validated, audited host tools. |
| High-value secrets, arbitrary third-party code, or network-adjacent workloads | FFI plus process, container, uid, seccomp, and network egress policy | JavaScript isolation is one layer; OS isolation still owns blast-radius control. |
| Trusted app code that only needs cancellation or tenant-level pooling | Worker or FFI pool | Security posture matters less; optimize for deployment reach and operational simplicity. |
Rule of thumb: use backend: "ffi" for hostile-code production paths, backend: "auto" for portable defaults, and add OS/process isolation whenever sandbox escape would expose meaningful host secrets.
What it ISN'T (honest limits per backend)
| Path | FFI backend | Worker backend |
| -------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------- |
| fetch(url) (bare) | undefined | undefined |
| Bun.spawn(...) (bare) | undefined | undefined |
| globalThis.fetch / globalThis.Bun | undefined | undefined |
| this.Bun | undefined | undefined |
| eval('Bun') (direct) | blocks (eval disabled per-context) | undefined |
| (0, eval)('Bun') (indirect) | blocks | reachable — documented residual |
| new Function('return Bun')() | blocks | reachable — documented residual |
| Math.PI / JSON.stringify / Promise / crypto.randomUUID | reachable | reachable |
| URL / TextEncoder / WebSocket | not present (JSC API only, no Web APIs) | reachable (Bun Worker exposes Web APIs) |
| Cold heap | ~300 KB | ~46 MB |
| Timeout behaviour | TerminationException thrown into script; isolate keeps running | isolate dies, must respawn |
| Memory cap | watchdog-polled heapCapacity; terminates on overage | polled 50 ms; terminates whole isolate |
| Sync host fns via Reference | supported (direct return) | always wrapped in await |
| Async (Promise) host fns via Reference | supported (0.4+) — pumped to settlement, bounded by timeout | supported via await |
| Prototype isolation across contexts | one isolate per tenant | one isolate per tenant |
Installation note for the FFI backend
- macOS: zero install. JavaScriptCore is a system framework at
/System/Library/Frameworks/JavaScriptCore.framework. - Linux (Debian / Ubuntu):
sudo apt install libjavascriptcoregtk-4.1-0(8 MB / 31 MB installed). - Linux (Fedora):
sudo dnf install webkit2gtk4.1. - Linux with Playwright already installed: we accept Playwright's bundled
libjavascriptcoregtk-6.0.so.1— no extra install. - Windows: not supported (no system JSC, no realistic distribution path).
createIsolate({ backend: 'auto' })falls back to Worker automatically;backend: 'ffi'throwsJscLibraryNotFoundError.
API
import {
createCapabilityAuditBuffer,
createCapabilityBroker,
defineCapabilityTool,
compileTypeScriptCallable,
compileTypeScriptCallableFile,
compileTypeScriptFile,
createIsolate,
createIsolatedRunner,
policyAuditOptions,
policyBrokerOptions,
policyConsoleOptions,
policyRunnerOptions,
resolveIsolatePolicy,
runIsolated,
runIsolatedFile,
Reference,
ExternalCopy,
validateContextCheckpoint,
type Isolate,
type Context,
type Script,
} from "@absolutejs/isolated-jsc";
// Create an isolate (one per untrusted tenant).
const isolate: Isolate = await createIsolate({
memoryLimit: 256, // MB; default 256
bootstrap: "var foo = 1", // optional — runs once in the worker
maxConsoleEntries: 100,
maxConsoleBytes: 64_000,
onConsole: (level, args) => console.log(`[iso/${level}]`, ...args),
});
// One or more contexts (fresh global scopes) per isolate.
const context: Context = await isolate.createContext();
// Expose a host function callable from inside the isolate.
const dbQuery = new Reference(async (sql: string) => {
return await myDb.query(sql); // host-side call, tenant-scoped
});
await context.setGlobal("db", dbQuery);
// Compile + run.
const script: Script = await isolate.compileScript(`
(async () => {
const rows = await db('SELECT * FROM users');
return rows.length;
})()
`);
const count = await script.run(context, { timeout: 500 });
// count === number of rows
// TypeScript source: transpiled with Bun before execution in the isolate.
const handler = await compileTypeScriptCallable(
context,
"async (name: string): Promise<string> => name.trim().toUpperCase()",
);
await handler.call([" alex "]); // "ALEX"
// Real files work too. Keep generics, types, and complex tenant/plugin code in
// normal `.ts`, `.tsx`, `.js`, or `.jsx` files instead of string literals.
const typedScript = await compileTypeScriptFile(
isolate,
"./tenant-code/score.ts",
);
await typedScript.run(context);
// Callable files use a default export:
//
// type Box<T> = { value: T };
// export default function unwrap<T>(box: Box<T>): T {
// return box.value;
// }
//
const unwrap = await compileTypeScriptCallableFile(
context,
"./tenant-code/unwrap.ts",
);
await unwrap.call([{ value: "typed file" }]); // "typed file"
// Explicit data checkpoints for turn/session handoff. These capture
// structured-cloneable own globals only, not a JSC heap image.
const checkpoint = await context.checkpoint({
exclude: ["scratch"],
maxBytes: 64 * 1024,
});
const persisted = JSON.stringify(checkpoint);
const restoredCheckpoint = JSON.parse(persisted);
validateContextCheckpoint(restoredCheckpoint);
const resumed = await isolate.createContext({
checkpoint: restoredCheckpoint,
seed: "this.double = (n) => n * 2",
});
// checkpoint.schemaVersion === 1
// checkpoint.skipped === [{ key, reason, bytes? }, ...]
// Versioned checkpoint receipts for operational evidence. Mirrors the
// ExecutionReceipt shape: schemaVersion:1, durationMs, startedAt/endedAt,
// executionId, plus operation:"create"|"restore" and aggregated
// skippedReasons counts. Pass purpose/tenant for audit-log workflows;
// errors rethrow with `.receipt` attached.
const audited = await context.checkpointWithReceipt({
exclude: ["scratch"],
maxBytes: 64 * 1024,
purpose: "turn-handoff",
tenant: "tenant-a",
});
// audited.receipt.operation === "create"
// audited.receipt.skippedReasons === { excluded, notClonable, overMaxBytes }
const restored = await isolate.createContextWithReceipt({
checkpoint: audited.checkpoint,
purpose: "turn-handoff",
tenant: "tenant-a",
});
// restored.receipt.operation === "restore"
// restored.receipt.sourceBackend === "ffi" | "worker"
type TenantContext = { id: string };
type LookupOrderInput = { id: string };
type Order = {
cardLast4: string;
customerEmail: string;
id: string;
status: string;
};
const policy = resolveIsolatePolicy("ai-tool", {
maxResultBytes: 32 * 1024,
timeout: 750,
});
const audit = createCapabilityAuditBuffer<TenantContext>({
...policyAuditOptions(policy),
});
const redactEmail = (email: string) => {
const [name, domain] = email.split("@");
return domain === undefined
? "[email redacted]"
: `${name.slice(0, 2)}***@${domain}`;
};
const broker = createCapabilityBroker(
{
lookupOrder: defineCapabilityTool<
LookupOrderInput,
Order | null,
TenantContext
>({
description: "Read one order by id for the current tenant",
risk: "read-only",
input: { name: "LookupOrderInput" },
output: "Order | null",
maxOutputBytes: 16_384,
redactAuditInput: (input) => ({ id: (input as { id?: unknown }).id }),
redactAuditOutput: (output) => {
if (output === null) return null;
const order = output as Order;
return {
cardLast4: order.cardLast4,
customerEmail: redactEmail(order.customerEmail),
id: order.id,
status: order.status,
};
},
timeoutMs: 250,
validateInput: (input) => {
if (input === null || typeof input !== "object") {
throw new Error("lookupOrder input must be an object");
}
const id = (input as { id?: unknown }).id;
if (typeof id !== "string") {
throw new Error("lookupOrder input requires a string id");
}
return { id };
},
handler: async ({ id }, tenant) => await lookupOrder(tenant.id, id),
}),
chargeCard: defineCapabilityTool<
{ cardToken: string; orderId: string },
{ authorizationId: string; processorTraceId: string },
TenantContext
>({
description: "Charge an opaque card token for one tenant order",
input: "ChargeInput",
output: "ChargeResult",
risk: "write",
redactAuditInput: (input) => ({
cardToken: "[token redacted]",
orderId: (input as { orderId?: unknown }).orderId,
}),
redactAuditOutput: (output) => ({
authorizationId: (output as { authorizationId?: unknown })
.authorizationId,
processorTraceId: "[trace redacted]",
}),
validateInput: (input) => input as { cardToken: string; orderId: string },
handler: async (input, tenant) => await chargeCard(tenant.id, input),
}),
},
{
context: { id: "tenant_123" },
...policyBrokerOptions(policy),
onAudit: audit.onAudit,
redactAuditInput: () => "[input redacted by default]",
redactAuditOutput: () => "[output redacted by default]",
},
);
// Host-side direct calls are typed from the tool map.
const order = await broker.call("lookupOrder", { id: "ord_123" });
// ^? Order | null
// Reviewable capability manifest for docs, audits, and agent/tool UIs.
broker.manifest();
// [{
// name: "lookupOrder",
// schemaVersion: 1,
// description: "Read one order by id for the current tenant",
// risk: "read-only",
// input: { name: "LookupOrderInput" },
// output: "Order | null",
// maxOutputBytes: 16384,
// timeoutMs: 250,
// hasInputValidator: true,
// hasOutputValidator: false,
// redactsInput: true,
// redactsOutput: true
// }]
// Audit events and receipts retain the operational proof, not raw secrets:
// order ids stay visible
// emails become ad***@example.com
// card tokens become [token redacted]
// processor trace ids become [trace redacted]
// Sandbox calls still use an untrusted-code-safe Reference.
await context.setGlobal("tools", broker.reference);
// Policy recipes standardize isolate construction, default timeout, default
// result-size limits, and recommended audit/broker/runner settings.
const policyIsolate = await createIsolate({
policy,
...policyConsoleOptions(policy),
});
const policyContext = await policyIsolate.createContext();
const policyScript = await policyIsolate.compileScript("1 + 1");
await policyScript.run(policyContext);
await policyIsolate.dispose();
// One-shot execution for request/response paths.
const oneShot = await runIsolated<number>("input.n * 2", {
policy,
globals: { input: { n: 21 } },
});
// oneShot === 42
const oneShotFile = await runIsolatedFile<number>("./tenant-code/score.ts", {
policy,
globals: { input: { n: 21 } },
});
// oneShotFile === 42
const measured = await runIsolated<number>("input.n + 1", {
policy: "tenant-script",
globals: { input: { n: 41 } },
withMetrics: true,
});
// measured.result === 42; measured.metrics.backend === "ffi" | "worker"
const receipted = await runIsolated<number>("input.n + 1", {
policy: "tenant-script",
globals: { input: { n: 41 } },
run: {
...audit.receiptOptions(),
executionId: "exec_123",
purpose: "ai-tool-call",
tenant: "tenant_123",
},
withReceipt: true,
});
// receipted.result === 42
// receipted.receipt.status === "success"
// receipted.receipt.backend === "ffi" | "worker"
// Reusable runner for repeated request/response paths.
const runner = createIsolatedRunner({
...policyRunnerOptions(policy),
policy,
globals: { tools: broker.reference },
});
const tenantResult = await runner.run<number>("input.n * 2", {
key: "tenant_123",
globals: { input: { n: 21 } },
});
// tenantResult === 42
const tenantFileResult = await runner.runFile<number>(
"./tenant-code/score.ts",
{
key: "tenant_123",
globals: { input: { n: 21 } },
},
);
// tenantFileResult === 42
await runner.precompile(
"scoreFormula",
"(input) => input.base * 2 + input.bonus",
{ key: "tenant_123" },
);
const callableResult = await runner.call<number>(
"scoreFormula",
"(input) => input.base * 2 + input.bonus",
[{ base: 20, bonus: 2 }],
{ key: "tenant_123" },
);
// callableResult === 42
await runner.precompileFile("unwrap", "./tenant-code/unwrap.ts", {
key: "tenant_123",
});
const callableFileResult = await runner.callFile<string>(
"unwrap",
"./tenant-code/unwrap.ts",
[{ value: "typed file" }],
{ key: "tenant_123" },
);
// callableFileResult === "typed file"
// runner.stats() === { poolSize: 1, callableCacheSize: 1, callablesByKey: { tenant_123: 1 } }
await runner.dispose();
await isolate.dispose();Errors
TimeoutError— wall-clock budget elapsed.MemoryLimitError— heap exceeded the configured cap.ResultSizeError— successful output exceededmaxResultBytes.IsolateDisposedError— operation on a disposed isolate.CompileError— syntax error in script source.
Install
bun add @absolutejs/isolated-jscRequires Bun ≥ 1.3.
Doctor
bunx @absolutejs/isolated-jsc
bunx @absolutejs/isolated-jsc --json
# or, from this repo:
bun src/doctor.tsThe doctor prints Bun/platform details, FFI backend availability, JavaScriptCore flavor/path when found, checked library paths when missing, and the install hint for the current platform. Pass --json for machine-readable CI or deployment checks.
Examples
bun run example:agent-tool
bun run example:checkpointThe agent-tool example combines TypeScript callable compilation, a capability broker, tenant context, tool timeouts, audit events, and per-call metrics. The checkpoint example shows persisted checkpoint JSON, restore validation, and seed-based resume.
Benchmarks
bun run bench:proofThis writes BENCHMARKS.md with local measurements for the FFI backend, Worker backend, Bun process-spawn baseline, and Node isolated-vm when that optional native package is installed in the Node environment.
Tests
bun install
bun testThe test suite covers compile/run, contexts, Reference call-through, ExternalCopy, timeout, memory caps, dispose idempotency, pool behavior, TypeScript helpers, capability brokers, FFI behavior, host-reachability documentation, and hostile-tenant stress cases.
Release checks
bun run check:releaseThe release check runs the package typecheck, build, unit tests, agent-tool example, and the browser smoke in the sibling ../examples/isolated-jsc app. Keep the absolutejs/examples repo checked out next to this package repo when cutting releases so the demo path is covered before publish.
Related
@absolutejs/sync— reactive sync engine. Will eventually use this library to sandbox per-tenant mutation handlers in the hosted PaaS.- Bun docs:
bun:jsc— the JSC primitives this library builds on. isolated-vm— the V8/Node equivalent this library mirrors (we owe Andrew Laverdet the API shape).
License
CC BY-NC 4.0. Commercial licensing available — contact [email protected].
