@ricsam/isolate
v0.1.26
Published
Unified runtime host for app servers, script runtimes, browser runtimes, module resolution, file bindings, and typechecking
Downloads
1,494
Maintainers
Readme
@ricsam/isolate
@ricsam/isolate is a runtime host for running JavaScript and TypeScript inside isolated V8 sandboxes. It gives you one host API for short-lived scripts, long-lived app servers, browser-backed tests, persistent sessions, module loading, files, fetch, and nested sandboxes.
Use it when you want a higher-level sandbox than raw isolated-vm: the host stays in control of capabilities, while sandboxed code gets a web-style runtime surface.
Getting Started
Installation
npm add @ricsam/isolate @ricsam/isolated-vm@ricsam/isolate expects the async-context-enabled @ricsam/isolated-vm peer. Upstream isolated-vm does not provide the required createContext({ asyncContext: true }) support and will fail fast during runtime boot.
Install Playwright when you want browser-enabled runtimes or test runtimes:
npm add playwrightcreateIsolateHost() will auto-start a daemon when needed, so the default setup is usually enough to get going.
Quick Start
import {
createFileBindings,
createIsolateHost,
createModuleResolver,
} from "@ricsam/isolate";
const host = await createIsolateHost({
daemon: {
socketPath: "/tmp/isolate.sock",
},
});
const runtime = await host.createRuntime({
bindings: {
console: {
onEntry(entry) {
if (entry.type === "output") {
console.log(entry.stdout);
}
},
},
fetch: async (request) => await fetch(request),
files: createFileBindings({
root: process.cwd(),
allowWrite: true,
}),
modules: createModuleResolver()
.virtual(
"@/env",
`export const mode = "sandbox";`,
{ filename: "env.ts", resolveDir: "/app" },
)
.virtual(
"/app/main.ts",
`
import { mode } from "@/env";
const response = await fetch("https://example.com");
console.log("mode:", mode);
console.log("status:", response.status);
console.log(await greet("isolate"));
`,
{ filename: "main.ts", resolveDir: "/app" },
),
tools: {
greet: async (name: string) => `hello ${name}`,
},
},
});
try {
await runtime.eval(`import "/app/main.ts";`, { filename: "/app/entry.ts" });
} finally {
await runtime.dispose();
await host.close();
}That example wires together the most common capabilities:
consoleto forward sandbox outputfetchfor outbound HTTP requestsfilesfor root-scoped filesystem accessmodulesfor virtual modules and source treestoolsfor async host functions
Event-style callbacks such as console.onEntry(...), runtime.test.onEvent(...), and Playwright onEvent(...) are sync-only, best-effort notifications. Returned promises are ignored after rejection logging, so schedule any async follow-up work from inside the synchronous handler.
Guides
Pick A Runtime
The host can create four runtime styles:
host.createRuntime()for scripts, agents, and ad hoc executionhost.createAppServer()for long-livedserve()request handlershost.createTestRuntime()for test suites withdescribe,test, hooks, andexpecthost.getNamespacedRuntime()for persistent sessions that survive soft dispose and can be reacquired later
Cancel Running Work
eval(), run(), runTests(), and app-server handle() accept web-style AbortSignal options.
If the signal is already aborted before the call starts, the call rejects with AbortError and does not start or dispose the runtime. If a signal aborts while eval(), run(), or runTests() is in flight, the runtime is hard-disposed and the handle becomes unusable. This is intentionally a hard cancellation path so CPU-bound code such as while (true) {} cannot keep running.
const runtime = await host.createRuntime({});
const controller = new AbortController();
const running = runtime.eval(
`while (true) {}`,
{ signal: controller.signal },
);
controller.abort();
await running.catch((error) => {
if (error.name !== "AbortError") {
throw error;
}
});
// The runtime was terminated by the abort.
await runtime.eval(`1 + 1`).catch((error) => {
console.log(error.message);
});Hard runtime termination cascades through nested hosts. If a parent runtime is aborted, times out, is hard-disposed, or is disposed during execution, child runtimes, nested app servers, test runtimes, and namespaced runtimes created through the sandbox @ricsam/isolate module are also disposed.
Signals created inside sandbox code can cancel nested work live:
await runtime.eval(`
import { createIsolateHost } from "@ricsam/isolate";
const nestedHost = createIsolateHost();
const child = await nestedHost.createRuntime();
const controller = new AbortController();
const running = child.eval("await new Promise(() => {})", {
signal: controller.signal,
});
controller.abort();
try {
await running;
} catch (error) {
console.log(error.name); // AbortError
}
`);For app servers, handle(request, { signal }) is request-scoped. Aborting the request signal cancels that one request and is visible as request.signal.aborted inside the sandbox; it does not dispose the server runtime unless the owning runtime is otherwise terminated. Inside a sandbox app server, pass request.signal to a nested app server handle() call to forward the same request cancellation.
Configure Bindings
Bindings define how sandboxed code talks to the host:
consoleforwards runtime and browser console outputfetchhandles outbound HTTP requestsfilesexposes a safe, root-scoped filesystemmodulesresolves virtual modules, source trees, mounted packages, and fallbackstoolsexposes async host functions and async iteratorsbrowserexposes a Playwright-like browser surface
Every host callback receives a HostCallContext with an AbortSignal, runtime identity, resource identity, and request metadata.
fetch is disabled unless you provide a host binding. If sandbox code calls fetch(...) without a bindings.fetch callback, the runtime rejects the request instead of falling back to the Node.js process fetch. To allow outbound network access, pass an explicit policy-enforcing callback:
const runtime = await host.createRuntime({
bindings: {
fetch: async (request, context) => {
// Validate, log, rate-limit, meter, or rewrite here.
return await fetch(request, {
signal: context.signal,
});
},
},
});When exposing browser support, choose exactly one mode per runtime:
- factory-first: provide
createContext()and optionallycreatePage(),readFile(), andwriteFile() - handler-first: provide
handler, usually fromcreatePlaywrightSessionHandler(...)
Do not mix handler with createContext() / createPage() / readFile() / writeFile() in the same binding.
Keep bindings plain-data and host-owned. Do not leak raw isolated-vm handles or other engine objects into untrusted code.
Create Nested Hosts Inside The Sandbox
Sandbox code can import @ricsam/isolate and create child runtimes against the same top-level host connection when the parent runtime opts in with nestedHost.
const runtime = await host.createRuntime({
bindings: {
console: {
onEntry(entry) {
if (entry.type === "output") {
console.log(entry.stdout);
}
},
},
fetch: async (request) => await fetch(request),
},
nestedHost: {
fetch: "inherit",
maxTotalResources: 8,
maxRuntimes: 6,
maxAppServers: 2,
maxMemoryLimitMB: 128,
maxExecutionTimeoutMs: 30_000,
maxAppServerLifetimeMs: 10 * 60_000,
},
});
await runtime.eval(`
import { createIsolateHost } from "@ricsam/isolate";
const nestedHost = createIsolateHost();
const child = await nestedHost.createRuntime({
bindings: {
tools: {
greet: async (name) => "hello " + name,
},
},
});
await child.eval('console.log(await greet("nested"))');
await child.dispose();
await nestedHost.close();
`);Nested hosts support:
createRuntime()createAppServer()createTestRuntime()getNamespacedRuntime()disposeNamespace()diagnostics()close()
The synthetic sandbox module also exports createModuleResolver() for isolate-authored module bindings. It supports pure in-sandbox virtual(), sourceTree(), and fallback() resolution. Host-backed helpers such as createFileBindings(), virtualFile(), and mountNodeModules() are intentionally not exposed inside sandbox code.
Nested resources are brokered by the parent host. The parent nestedHost policy controls whether child runtimes inherit the parent fetch binding, how many nested resources can exist across the whole descendant tree, and the maximum memory and execution timeout each child may request. Namespace keys created from inside a nested host are internally scoped so sandbox code can dispose only namespaces it created through that nested host.
The available policy fields are:
fetch: "inherit" | "disabled"- defaults to"disabled"maxTotalResources- total nested runtimes, test runtimes, namespaced runtimes, and app serversmaxRuntimes- nested script, test, and namespaced runtimesmaxAppServers- nested app serversmaxMemoryLimitMB- maximum per-child memory limitmaxExecutionTimeoutMs- maximum per-child execution timeoutmaxAppServerLifetimeMs- optional hard lifetime for nested app servers
If nestedHost is omitted, nested hosts use conservative defaults and do not inherit fetch. Pass nestedHost: false to disable the synthetic sandbox @ricsam/isolate module entirely for a runtime.
Build An App Server
createAppServer() is the long-lived server API. It boots a runtime around an entry module that calls serve() and lets the host dispatch requests into it.
import { createIsolateHost, createModuleResolver } from "@ricsam/isolate";
const host = await createIsolateHost();
const server = await host.createAppServer({
key: "example/server",
entry: "/server.ts",
bindings: {
modules: createModuleResolver().virtual(
"/server.ts",
`
serve({
fetch(request) {
return Response.json({
pathname: new URL(request.url).pathname,
});
},
});
`,
),
},
});
const result = await server.handle(new Request("http://localhost/hello"));
if (result.type === "response") {
console.log(await result.response.json());
}
await server.dispose();
await host.close();server.handle() returns either an HTTP response or WebSocket upgrade metadata. Pass handle(request, { signal }) to forward request cancellation into the sandbox as request.signal. For upgraded connections, server.ws lets the host send open, message, close, and error events back into the runtime.
Add Browser Support To Script And Server Runtimes
If you provide bindings.browser, script and app runtimes get a global browser factory even when they are not full Playwright browser runtimes.
import { chromium } from "playwright";
import { createIsolateHost } from "@ricsam/isolate";
const browser = await chromium.launch();
const host = await createIsolateHost();
const runtime = await host.createRuntime({
bindings: {
browser: {
createContext: async (options) =>
await browser.newContext(options ?? undefined),
createPage: async (contextInstance) =>
await contextInstance.newPage(),
},
},
});
await runtime.eval(`
const ctx = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await ctx.newPage();
await page.goto("https://example.com");
console.log(await page.title());
console.log(typeof browser.close);
await page.close();
await ctx.close();
`);
await runtime.dispose();
await browser.close();
await host.close();Inside these runtimes:
browser.newContext()is availablebrowser.contexts()is availablecontext.newPage()is availablecontext.pages()is availablecontext.newCDPSession(page)is available for Chromium-backed contextspage.target().createCDPSession()is available as a Puppeteer-style CDP shimpage.close()andcontext.close()are availablebrowser.close()is not exposed inside the sandboxpageandcontextare never injected as implicit globals
Persist Browser Profiles
Browser profiles are isolate-owned virtual filesystem data. Enable them by providing bindings.files and bindings.browser.profiles; sandbox code then uses profile IDs instead of host paths.
import { chromium } from "playwright";
import {
createFileBindings,
createIsolateHost,
} from "@ricsam/isolate";
const browser = await chromium.launch();
const host = await createIsolateHost();
const runtime = await host.createRuntime({
bindings: {
files: createFileBindings({
root: "./session-data",
allowWrite: true,
}),
browser: {
profiles: true,
createContext: async (options) =>
await browser.newContext(options ?? undefined),
createPage: async (contextInstance) =>
await contextInstance.newPage(),
},
},
});
await runtime.eval(`
const ctx = await browser.newContext({ profile: "auth/main" });
const page = await ctx.newPage();
await page.goto("https://example.com");
await ctx.storageState({ path: "/snapshots/auth.json" });
await ctx.close();
const restored = await browser.newContext({
storageState: "/snapshots/auth.json",
});
await restored.close();
`);
await runtime.dispose();
await browser.close();
await host.close();For full browser profile directories, provide createPersistentContext() and request a persistent profile. The host receives a temporary real userDataDir; @ricsam/isolate materializes the virtual profile into it before launch and syncs it back on context.close().
const runtime = await host.createRuntime({
bindings: {
files: createFileBindings({
root: "./session-data",
allowWrite: true,
}),
browser: {
profiles: { defaultMode: "persistent" },
createPersistentContext: async (userDataDir, options) =>
await chromium.launchPersistentContext(userDataDir, {
...(options ?? {}),
headless: true,
}),
createPage: async (contextInstance) =>
await contextInstance.newPage(),
},
},
});
await runtime.eval(`
const ctx = await browser.newContext({ profile: "chrome/main" });
const page = await ctx.newPage();
await page.goto("https://example.com");
await ctx.close();
`);Profile IDs are validated relative paths such as auth/main. Concurrent opens for the same profile are rejected. Nested isolates that reuse bindings: { browser } inherit the same profile store without receiving a raw host path.
Run Tests Inside The Sandbox
createTestRuntime() enables describe, test / it, hooks, and expect. If you also provide bindings.browser, the same test runtime gets Playwright-style browser access and matcher support.
import { chromium } from "playwright";
import { createIsolateHost } from "@ricsam/isolate";
const browser = await chromium.launch();
const host = await createIsolateHost();
const runtime = await host.createTestRuntime({
key: "example/browser-test",
bindings: {
browser: {
captureConsole: true,
createContext: async (options) =>
await browser.newContext(options ?? undefined),
createPage: async (contextInstance) =>
await contextInstance.newPage(),
},
},
});
const unsubscribe = runtime.test.onEvent((event) => {
if (event.type === "testStart") {
console.log("running", event.test.fullName);
}
});
const result = await runtime.run(
`
let ctx;
let page;
beforeAll(async () => {
ctx = await browser.newContext();
page = await ctx.newPage();
});
afterAll(async () => {
await ctx.close();
});
test("loads a page", async () => {
expect((await browser.contexts()).length).toBe(1);
expect((await ctx.pages()).length).toBe(1);
await page.goto("https://example.com", {
waitUntil: "domcontentloaded",
});
await expect(page).toHaveTitle(/Example Domain/);
});
`,
{
filename: "/browser-test.ts",
timeoutMs: 10_000,
},
);
console.log(result);
unsubscribe();
await runtime.dispose();
await browser.close();
await host.close();From inside another sandbox, nestedHost.createTestRuntime() can reuse the sandbox browser handle:
import { createIsolateHost } from "@ricsam/isolate";
const nestedHost = createIsolateHost();
const child = await nestedHost.createTestRuntime({
bindings: {
browser,
},
});
await child.run(`
let ctx;
let page;
beforeAll(async () => {
ctx = await browser.newContext();
page = await ctx.newPage();
});
afterAll(async () => {
await ctx.close();
});
test("loads a nested page", async () => {
await page.goto("https://example.com");
await expect(page).toHaveTitle(/Example Domain/);
});
`);
await child.dispose();
await nestedHost.close();Reuse A Namespaced Session
host.getNamespacedRuntime(key, options) is the persistent-session API. Use it when you want one underlying runtime to survive across multiple calls while refreshing host bindings on each acquire.
import { chromium } from "playwright";
import { createIsolateHost } from "@ricsam/isolate";
import { createPlaywrightSessionHandler } from "@ricsam/isolate/playwright";
const browser = await chromium.launch();
const host = await createIsolateHost();
const playwright = createPlaywrightSessionHandler({
createContext: async (options) =>
await browser.newContext(options ?? undefined),
createPage: async (context) =>
await context.newPage(),
});
const session = await host.getNamespacedRuntime("playwright:preview:session", {
bindings: {
browser: {
handler: playwright.handler,
},
},
});
await session.eval(`
globalThis.ctx = await browser.newContext();
globalThis.page = await globalThis.ctx.newPage();
await globalThis.page.goto("https://example.com");
`);
await session.dispose();
const reused = await host.getNamespacedRuntime("playwright:preview:session", {
bindings: {
browser: {
handler: playwright.handler,
},
},
});
const unsubscribe = reused.test.onEvent((event) => {
if (event.type === "testStart") {
console.log("running", event.test.fullName);
}
});
const results = await reused.runTests(`
test("sees the existing browser state", async () => {
const contexts = await browser.contexts();
expect(contexts.length).toBe(1);
const pages = await contexts[0].pages();
expect(pages.length).toBe(1);
});
`);
console.log(results.success);
unsubscribe();
await host.disposeNamespace("playwright:preview:session");
await browser.close();
await host.close();Lifecycle notes:
- only one live handle per namespace is allowed at a time
runTests(code)resets test registration before loading and running the provided suitesession.test.onEvent(...)exposes suite and test lifecycle events for timeout and progress reporting- runtime globals, module state, and Playwright resources survive soft dispose and reacquire
- browser shutdown stays host-owned, while page and context shutdown stay sandbox-owned
Use Async Context Inside The Sandbox
Runtimes created by @ricsam/isolate enable the TC39 proposal-style AsyncContext global inside the sandbox. This experimental surface is also used to implement the node:async_hooks shim exposed to sandboxed code.
This shim is for async context propagation inside the sandbox. It is not a full reimplementation of Node's async_hooks lifecycle, resource graph, or profiling APIs.
Currently supported:
AsyncContext.VariableAsyncContext.Snapshotnode:async_hooksAsyncLocalStoragenode:async_hooksAsyncResourcenode:async_hookscreateHook()node:async_hooksexecutionAsyncId()node:async_hookstriggerAsyncId()node:async_hooksexecutionAsyncResource()node:async_hooksasyncWrapProviders
import {
createHook,
executionAsyncResource,
} from "node:async_hooks";
const hook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
resource.requestTag = type + ":" + asyncId;
},
before() {
console.log(executionAsyncResource().requestTag ?? null);
},
}).enable();
setTimeout(() => {
console.log(executionAsyncResource().requestTag);
hook.disable();
}, 0);API
Package Entry Points
@ricsam/isolateexportscreateIsolateHost(),createModuleResolver(),createFileBindings(),getTypeProfile(),typecheck(),formatTypecheckErrors(), and public types such asHostBindings,NestedHostPolicy, and runtime handles@ricsam/isolate/playwrightexportscreatePlaywrightSessionHandler()and related Playwright handler types- inside sandbox code,
@ricsam/isolateis also available as a synthetic module that exports sandbox-onlycreateIsolateHost(),createModuleResolver(), and nested-safe type aliases for runtime handles and bindings
createIsolateHost()
createIsolateHost() creates the top-level host connection. The returned host exposes:
createRuntime(options)for script executioncreateAppServer(options)for long-livedserve()entrypointscreateTestRuntime(options)for testsgetNamespacedRuntime(key, options)for persistent sessionsdisposeNamespace(key, options?)for hard-deleting a namespacediagnostics()for host-level diagnosticsclose()to shut everything down
CreateIsolateHostOptions currently supports engine: "auto" and daemon options such as socketPath, entrypoint, cwd, timeoutMs, and autoStart.
Runtime creation options support:
bindingsfor host-owned capabilitiescwdfor path resolutionexecutionTimeoutfor eval/test execution limitsmemoryLimitMBfor isolate memory limitsnestedHostfor controlling sandbox-created child runtimes, orfalseto disable nested host access
Execution methods support:
runtime.eval(code, { filename?, executionTimeout?, signal? })testRuntime.run(code, { filename?, timeoutMs?, signal? })namespacedRuntime.eval(code, { filename?, executionTimeout?, signal? })namespacedRuntime.runTests(code, { filename?, timeoutMs?, signal? })appServer.handle(request, { requestId?, metadata?, signal? })
When signal aborts an in-flight script or test execution, the runtime is hard-disposed and nested resources are terminated recursively. Pre-aborted signals reject with AbortError before starting execution.
createModuleResolver()
createModuleResolver() returns a fluent builder. You can mix and match:
virtual(specifier, source, options)for inline modulesvirtualFile(specifier, filePath, options)for a host file mapped to a virtual specifiersourceTree(prefix, loader)for lazy source loading under a virtual pathmountNodeModules(virtualMount, hostPath)for package resolution from a realnode_modulesfallback(loader)for custom last-resort resolution
createFileBindings()
createFileBindings({ root, allowWrite }) creates a filesystem bridge that stays inside the configured root directory. Attempts to escape that root are rejected, and write operations are disabled unless allowWrite is true.
Typechecking
The typecheck helpers let you validate sandbox code against supported capability profiles before executing it.
import {
formatTypecheckErrors,
getTypeProfile,
typecheck,
} from "@ricsam/isolate";
const profile = getTypeProfile({
profile: "browser-test",
capabilities: ["files"],
});
console.log(profile.include);
const result = typecheck({
code: "page.goto('/')",
profile: "browser-test",
});
if (!result.success) {
console.error(formatTypecheckErrors(result.errors));
}Built-in profiles:
backendagentbrowser-test
Capabilities can extend a profile with fetch, files, tests, browser, tools, console, crypto, encoding, and timers.
@ricsam/isolate/playwright
createPlaywrightSessionHandler() builds a handler-first browser binding for namespaced sessions and other Playwright-backed runtimes.
It accepts host callbacks such as:
createContextcreatePersistentContextcreatePagereadFilewriteFileprofilesevaluatePredicate
It returns:
handlerforbindings.browser.handlergetCollectedData()for collected browser artifactsgetTrackedResources()for active contexts and pagesclearCollectedData()to reset collected artifactsonEvent(callback)for sync-only, best-effort Playwright event subscriptions
isolate-daemon
The package also exposes an isolate-daemon binary:
isolate-daemon --socket /tmp/isolate.sockBy default, createIsolateHost() will auto-start a daemon when needed. You can also point the host at an already-running daemon with daemon.socketPath, or disable auto-start with daemon.autoStart: false.
