Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/forge-isolator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sandboxxjs/core": minor
---

feat(isolator): add Forge microVM isolator

New `ForgeIsolator` class that bridges to a Rust/Axum gateway via HTTP.
Adds "forge" to `IsolatorType` union.
3 changes: 3 additions & 0 deletions packages/core/src/Sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Isolator } from "./isolators/Isolator.js";
import { NoneIsolator } from "./isolators/NoneIsolator.js";
import { SrtIsolator } from "./isolators/SrtIsolator.js";
import { CloudflareContainerIsolator } from "./isolators/CloudflareContainerIsolator.js";
import { ForgeIsolator } from "./isolators/ForgeIsolator.js";
import { SandboxError } from "./errors.js";

export class BaseSandbox implements ISandbox {
Expand All @@ -39,6 +40,8 @@ export class BaseSandbox implements ISandbox {
return new CloudflareContainerIsolator(runtime);
case "e2b":
throw new SandboxError(`Isolator "e2b" not yet implemented`);
case "forge":
return new ForgeIsolator(runtime);
default:
throw new SandboxError(`Unknown isolator type: ${isolatorType}`);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { Isolator } from "./isolators/Isolator.js";
export { NoneIsolator } from "./isolators/NoneIsolator.js";
export { SrtIsolator } from "./isolators/SrtIsolator.js";
export { CloudflareContainerIsolator } from "./isolators/CloudflareContainerIsolator.js";
export { ForgeIsolator } from "./isolators/ForgeIsolator.js";

// Re-export from @sandboxxjs/state
export {
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/isolators/ForgeIsolator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it, mock, afterEach } from "bun:test";
import { ExecutionError } from "../errors.js";
import { ForgeIsolator } from "./ForgeIsolator.js";

describe("ForgeIsolator", () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
});

describe("constructor", () => {
it("constructor_noArgs_usesDefaultGatewayUrl", () => {
const isolator = new ForgeIsolator("shell");
expect((isolator as unknown as { gatewayUrl: string }).gatewayUrl).toBe(
"http://127.0.0.1:3456",
);
});
});

describe("destroy", () => {
it("destroy_noSandboxId_resolvesWithoutError", async () => {
const isolator = new ForgeIsolator("shell");
await expect(isolator.destroy()).resolves.toBeUndefined();
});
});

describe("shell", () => {
it("shell_gatewayUnavailable_throwsExecutionError", async () => {
const isolator = new ForgeIsolator("shell", "http://127.0.0.1:19999");
global.fetch = mock(() =>
Promise.resolve(
new Response(null, { status: 503, statusText: "Service Unavailable" }),
),
);

await expect(isolator.shell("echo hello")).rejects.toThrow(ExecutionError);
});
});
});
172 changes: 172 additions & 0 deletions packages/core/src/isolators/ForgeIsolator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Forge Isolator - Executes code via HTTP calls to a Forge microVM gateway
*/

import { ExecutionError, FileSystemError } from "../errors.js";
import type {
EvaluateResult,
ExecuteResult,
RuntimeType,
ShellResult,
} from "../types.js";
import { Isolator, type IsolatorOptions } from "./Isolator.js";

export class ForgeIsolator extends Isolator {
private readonly gatewayUrl: string;
private sandboxId: string | null = null;

constructor(runtime: RuntimeType, gatewayUrl = "http://127.0.0.1:3456") {
super(runtime);
this.gatewayUrl = gatewayUrl;
}

private async ensureSandbox(): Promise<string> {
if (this.sandboxId) return this.sandboxId;

const res = await fetch(`${this.gatewayUrl}/v1/sandbox`, {
method: "POST",
});
if (!res.ok) {
throw new ExecutionError(
`failed to create forge sandbox: ${res.status} ${res.statusText}`,
);
}
const body = (await res.json()) as { id: string };
this.sandboxId = body.id;
return this.sandboxId;
}

async shell(
command: string,
options: IsolatorOptions = {},
): Promise<ShellResult> {
const id = await this.ensureSandbox();
const startTime = Date.now();

const res = await fetch(`${this.gatewayUrl}/v1/sandbox/${id}/shell`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command }),
signal: options.timeout
? AbortSignal.timeout(options.timeout)
: undefined,
});

if (!res.ok) {
throw new ExecutionError(
`forge shell request failed: ${res.status} ${res.statusText}`,
);
}

const result = (await res.json()) as ShellResult;
return {
...result,
executionTime: result.executionTime ?? Date.now() - startTime,
};
}

async execute(
code: string,
options: IsolatorOptions = {},
): Promise<ExecuteResult> {
const id = await this.ensureSandbox();
const startTime = Date.now();

const res = await fetch(`${this.gatewayUrl}/v1/sandbox/${id}/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, runtime: this.runtime }),
signal: options.timeout
? AbortSignal.timeout(options.timeout)
: undefined,
});

if (!res.ok) {
throw new ExecutionError(
`forge execute request failed: ${res.status} ${res.statusText}`,
);
}

const result = (await res.json()) as ExecuteResult;
return {
...result,
executionTime: result.executionTime ?? Date.now() - startTime,
};
}

async evaluate(
expr: string,
options: IsolatorOptions = {},
): Promise<EvaluateResult> {
const startTime = Date.now();
let code: string;

switch (this.runtime) {
case "node":
code = `console.log(${expr})`;
break;
case "python":
code = `print(${expr})`;
break;
default:
throw new ExecutionError(
`unsupported runtime for evaluate: ${this.runtime}`,
);
}

const result = await this.execute(code, options);

if (!result.success) {
throw new ExecutionError(
result.stderr || `evaluation failed with exit code ${result.exitCode}`,
);
}

return {
value: result.stdout.trim(),
executionTime: result.executionTime ?? Date.now() - startTime,
};
}

async upload(data: Buffer, remotePath: string): Promise<void> {
const id = await this.ensureSandbox();

const res = await fetch(`${this.gatewayUrl}/v1/sandbox/${id}/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: data.toString("base64"), path: remotePath }),
});

if (!res.ok) {
throw new FileSystemError(
`forge upload failed: ${res.status} ${res.statusText}`,
);
}
}

async download(remotePath: string): Promise<Buffer> {
const id = await this.ensureSandbox();
const url = `${this.gatewayUrl}/v1/sandbox/${id}/download?path=${encodeURIComponent(remotePath)}`;

const res = await fetch(url);
if (!res.ok) {
throw new FileSystemError(
`forge download failed: ${res.status} ${res.statusText}`,
);
}

return Buffer.from(await res.arrayBuffer());
}

async destroy(): Promise<void> {
if (!this.sandboxId) return;

try {
await fetch(`${this.gatewayUrl}/v1/sandbox/${this.sandboxId}`, {
method: "DELETE",
});
} finally {
this.sandboxId = null;
}
}
}
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { WithState, StateLog } from "@sandboxxjs/state";
// Configuration Types
// ============================================

export type IsolatorType = "none" | "srt" | "cloudflare" | "e2b";
export type IsolatorType = "none" | "srt" | "cloudflare" | "e2b" | "forge";
export type RuntimeType = "node" | "python";

export interface StateConfig {
Expand Down