The sandbox isolation model
How vm.Context + happy-dom Window own-prop projection gives the bundle a globalThis without touching the host realm.
Snap's bundle is browser code. To run it from Node we have to give it a globalThis shaped like a Window — document, navigator, localStorage, fetch, crypto, Element, the lot. The naive way is to import happy-dom's GlobalRegistrator and let it install its Window properties straight onto Node's globalThis. The first cut of snapcap did exactly that. It worked, and it was wrong.
This chapter explains what we replaced it with and why.
Why GlobalRegistrator had to go
GlobalRegistrator mutates the consumer's process. Once you import a Snap-bundle-loading module, the host's globalThis.fetch, globalThis.localStorage, globalThis.document are all happy-dom shims. fetch("https://example.com") from anywhere in your app suddenly goes through happy-dom's URL/cookie/redirect handling. Math is fine, Promise is fine, but anything browser-shaped is no longer the standard library you imported.
For a public SDK that ships as @snapcap/native, that's a non-starter. Consumers would observe behaviour they didn't opt into.
The vm.Context approach
src/shims/sandbox.ts constructs an isolated Node vm.Context and projects happy-dom's Window properties onto its global, not the host's. Snap's bundle and the WASMs run via sandbox.runInContext(src) and see that synthesized global as globalThis / window / self. The host realm's globalThis is never touched.
The trick is the construction order:
// src/shims/sandbox.ts:106-118
this.hdWindow = new Window({ url, width, height, settings: { navigator: { userAgent } } });
// Empty sandbox object → V8 fills the new context's global with built-ins
// (Object, Array, Promise, WebAssembly, JSON, …) before any of our own
// properties land.
this.context = vm.createContext({});
const ctxGlobal = vm.runInContext("globalThis", this.context) as Record<string, unknown>;
this.window = ctxGlobal;Two halves:
- Empty sandbox first so V8 fills the new realm with
Object,Array,Promise,WebAssembly,JSON, the typed-array constructors, etc. These are the vm-realm built-ins, not the host's. - Project happy-dom Window own-props onto that global afterwards.
If you instead pass the happy-dom Window directly into vm.createContext(hdWindow), V8 sees an existing object and won't install built-ins on top of it. happy-dom's Window has Object / Array / Promise defined as undefined instance stubs (it's designed to be installed via GlobalRegistrator, which works because the host already has them). Using it as the context object means those undefined stubs replace V8's built-ins. The bundle then hits new Promise(...) and throws Promise is not a constructor immediately.
Doing it in two steps gives us V8's built-ins first, happy-dom's browser-side properties layered on top.
Project everything, not a curated list
The original implementation enumerated browser-API keys and copied them by name. That's a trap. A curated list silently leaves out things the WASM expects. When the bundle's React code does requestAnimationFrame(...) and gets undefined, it Promise-chains a callback that never resolves; the kameleon WASM coroutine ends up busy-looping on emscripten_get_now at ~10M calls/sec. There is no thrown error, no log line — just CPU pegged and the test timing out.
The fix is to copy every defined own-property of happy-dom's Window:
// src/shims/sandbox.ts:128-138
for (const key of Object.getOwnPropertyNames(hd)) {
if (key in ctxGlobal) continue; // don't shadow built-ins V8 already provided
const v = hd[key];
if (v === undefined || v === null) continue;
try { ctxGlobal[key] = v; } catch { /* non-configurable — skip */ }
}Two safety rails:
if (key in ctxGlobal) continue— V8's built-ins (Object/Array/Promise/etc.) win. happy-dom'sundefinedinstance stubs are skipped.if (v === undefined || v === null) continue— second-line defense. Even if a built-in happens to not be on the vm global at this point, anundefinedstub from happy-dom never lands.
BROWSER_PROJECTED_KEYS (src/shims/sandbox.ts:43-65) is documentation of which keys we explicitly expect to be present, not the actual install list. The actual installation is the loop above.
Shim layer, layered on top
After projection, the Sandbox iterates the canonical SDK_SHIMS array (src/shims/index.ts) and calls each shim's install(this, ctx). Six shims today: CookieContainerShim, DocumentCookieShim, WebSocketShim, LocalStorageShim, SessionStorageShim, IndexedDbShim. See the shims chapter for the full table and the rationale for each.
Without a DataStore in the SandboxOpts, the shim pipeline is skipped and happy-dom's in-memory defaults apply (the projection step copied them). That's fine for one-shot scripts but loses every browser-storage write between processes. In normal SnapcapClient usage the constructor passes the consumer's DataStore in eagerly so the shim pipeline runs.
CookieContainerShim runs before the Window is constructed — it patches the per-process
CookieContainer.prototypeso the instance happy-dom news up insidenew Window(...)inherits the patched methods. The other five shims run after the Window + vm context exist. Seesrc/shims/sandbox.ts:82-104for the exact sequencing.
Snap-bundle-specific stubs
A handful of globals the bundle expects are not browser API — they're Chrome / web-worker conventions. Sandbox installs minimal stubs for them after shim install:
chrome.runtime/chrome.app/chrome.csi/chrome.loadTimes— feature-detection points only; never read backrequestIdleCallback/cancelIdleCallback— backed bysetTimeoutimportScripts— no-op (worker-only API; can be overridden later bybringUpMessagingSessionto load the sibling chat chunk)caches— emptyCacheStorageshim returning empty results
window / self / top / parent / frames are aliased to the same global so bundle code that does self.webpackChunk_*, window.foo, and globalThis.bar interchangeably all hits the same object.
Cross-realm Uint8Array
The vm context has its own typed-array constructors. Bundle protobuf decoders run in the vm realm and check instanceof Uint8Array against their Uint8Array. A Uint8Array constructed in the host realm fails that check and the decoder throws:
Error: illegal buffer
at Reader.create (protobufjs/src/reader.js:...)Anywhere SDK code passes raw bytes into bundle code — login-response parsing, Embind argument marshalling, WebSocket frame projection — bytes have to be copied into a vm-realm Uint8Array first:
toVmU8(bytes: Uint8Array | ArrayBufferView): Uint8Array {
const VmU8 = this.runInContext("Uint8Array") as Uint8ArrayConstructor;
const src = bytes instanceof Uint8Array
? bytes
: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out = new VmU8(src.byteLength);
out.set(src);
return out;
}Two main callers today:
src/api/auth.ts— wrapping the bundle-drivenWebLoginresponse bytes before handing them to the bundle's protobuf decoder during the cold-path login.src/shims/websocket.ts— projecting incoming WebSocket binary frames soe.data instanceof ArrayBufferpasses the bundle's check.
If you write a new path that hands bytes into the bundle, run it through sandbox.toVmU8(...).
Bundle source wrapping
runInContext(src) evaluates src directly as if it were the top of a script. Snap's bundles are emitted by webpack as IIFEs, but their last byte is a //# sourceMappingURL=… line comment with no trailing newline. If the SDK appends its own })(…) continuation to invoke a wrapping IIFE without a newline, the line comment swallows it and the bundle never runs.
The fix is unglamorous: wrap with explicit newlines.
const wrapped =
`(function(module, exports, require) {\n` +
src +
`\n})({ exports: {} }, {}, () => { throw new Error("require not available"); });`;
sandbox.runInContext(wrapped, "snap-bundle.js");The trailing \n is the load-bearing one. The leading \n is defensive (some chunk files start with //# source-map comments too).
Per-instance lifecycle
SnapcapClient's constructor news up its own Sandbox directly — no process-wide singleton dance. Each Sandbox owns its own vm.Context, happy-dom Window, shimmed I/O layer, and per-Sandbox bring-up caches (kameleon Module, chat bundle eval, chat WASM Module, throttle gate). Two clients in the same process never share Zustand state, bearer tokens, webpack runtime caches, or storage namespaces.
That's the substrate for multi-tenant in one process. Each tenant gets its own DataStore, its own BrowserContext.userAgent, and its own Sandbox — localStorage writes from tenant A's bundle are isolated from tenant B's bundle at the V8 vm-realm boundary.
SnapcapClient constructs a new Sandbox({ dataStore, ... }) directly — there is no process-wide singleton. Scratch scripts that want a Sandbox without spinning up a full SnapcapClient import the Sandbox class and construct one themselves; everything that previously read a singleton (idbGet/idbPut/idbDelete, the bundle loaders) now takes the Sandbox instance as its first argument.
What the consumer sees
From outside, the entire vm.Context is invisible. The consumer's globalThis is unmodified. globalThis.fetch, globalThis.localStorage, globalThis.document — all whatever Node provides natively (or undefined in Node, which is correct). transport/native-fetch.ts snapshots globalThis.fetch at module load as defence-in-depth, so the SDK's outbound traffic always uses the host realm's fetch even if a future change ever regresses isolation.
The contract is: import @snapcap/native, get a client, use it. Nothing else changes.