From b1c9388c9b184d8de43245632e5b1a5fc7865bbc Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:05:42 -0400 Subject: [PATCH 1/5] Reuse prepared sandbox stubs across processes --- js/lib/resources/abstraction/sandbox.ts | 94 +++++++++++++--------- js/lib/resources/abstraction/stub.ts | 58 +++++++++++--- js/package-lock.json | 4 +- js/package.json | 2 +- js/tests/sandbox-network.test.ts | 102 +++++++++++++++++++++++- 5 files changed, 206 insertions(+), 54 deletions(-) diff --git a/js/lib/resources/abstraction/sandbox.ts b/js/lib/resources/abstraction/sandbox.ts index 52bccd8..464128d 100644 --- a/js/lib/resources/abstraction/sandbox.ts +++ b/js/lib/resources/abstraction/sandbox.ts @@ -175,6 +175,25 @@ export class Sandbox extends Pod { ); } + private async createContainer(preparationCacheKey?: string): Promise<{ + ok: boolean; + containerId: string; + errorMsg?: string; + stubId?: string; + }> { + const response = await beamClient.request({ + method: "POST", + url: "api/v1/gateway/pods", + data: this.stub.stubId ? { stubId: this.stub.stubId } : {}, + headers: preparationCacheKey + ? { + "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey, + } + : undefined, + }); + return response.data; + } + /** * Create a new sandbox instance. * @@ -193,47 +212,50 @@ export class Sandbox extends Pod { const ignorePatterns = this.syncLocalDir ? undefined : ["*"]; - if (!this.runtimePreparation) { - this.runtimePreparation = this.stub.prepareRuntime( - undefined, - EStubType.Sandbox, - true, - ignorePatterns, - ); - } + // eslint-disable-next-line no-console + console.log("Creating sandbox"); - const currentPreparation = this.runtimePreparation; - let prepared: boolean; - try { - prepared = await currentPreparation; - } catch (error) { - if (this.runtimePreparation === currentPreparation) { - this.runtimePreparation = undefined; - } - throw error; + let body = await this.createContainer( + this.stub.runtimeReady + ? undefined + : this.stub.preparationCacheKey(EStubType.Sandbox, ignorePatterns), + ); + if (body.ok && body.stubId) { + this.stub.stubCreated = true; + this.stub.stubId = body.stubId; + this.stub.runtimeReady = true; } - if (!prepared && this.runtimePreparation === currentPreparation) { - this.runtimePreparation = undefined; - } - if (!prepared) { - const detail = this.stub.lastError?.message ?? "unknown reason"; - throw new SandboxConnectionError(`Failed to prepare runtime: ${detail}`); - } + if (!body.ok && !body.stubId) { + if (!this.runtimePreparation) { + this.runtimePreparation = this.stub.prepareRuntime( + undefined, + EStubType.Sandbox, + true, + ignorePatterns, + ); + } - // eslint-disable-next-line no-console - console.log("Creating sandbox"); + const currentPreparation = this.runtimePreparation; + let prepared: boolean; + try { + prepared = await currentPreparation; + } catch (error) { + if (this.runtimePreparation === currentPreparation) { + this.runtimePreparation = undefined; + } + throw error; + } - const createResp = await beamClient.request({ - method: "POST", - url: `api/v1/gateway/pods`, - data: { stubId: this.stub.stubId }, - }); - const body = createResp.data as { - ok: boolean; - containerId: string; - errorMsg?: string; - }; + if (!prepared && this.runtimePreparation === currentPreparation) { + this.runtimePreparation = undefined; + } + if (!prepared) { + const detail = this.stub.lastError?.message ?? "unknown reason"; + throw new SandboxConnectionError(`Failed to prepare runtime: ${detail}`); + } + body = await this.createContainer(); + } if (!body.ok) { throw new SandboxConnectionError( diff --git a/js/lib/resources/abstraction/stub.ts b/js/lib/resources/abstraction/stub.ts index 1f25bfe..4a5c94e 100644 --- a/js/lib/resources/abstraction/stub.ts +++ b/js/lib/resources/abstraction/stub.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import { createHash } from "crypto"; import beamClient, { GpuType, GpuTypeAlias } from "../.."; import { Image } from "./image"; import { Volume } from "../volume"; @@ -239,6 +240,8 @@ export class StubBuilder { return true; } + const preparationCacheKey = this.preparationCacheKey(stubType, ignorePatterns); + // Build image if not available if (!this.imageAvailable) { try { @@ -263,20 +266,25 @@ export class StubBuilder { // Sync files if not already synced if (!this.filesSynced) { - try { - const syncResult = await this.syncer.sync(ignorePatterns); - if (syncResult.success) { - this.filesSynced = true; - this.objectId = syncResult.objectId; - } else { - this.lastError = new Error("File sync failed"); - console.error("File sync failed"); + if (ignorePatterns?.length === 1 && ignorePatterns[0] === "*") { + this.filesSynced = true; + this.objectId = ""; + } else { + try { + const syncResult = await this.syncer.sync(ignorePatterns); + if (syncResult.success) { + this.filesSynced = true; + this.objectId = syncResult.objectId; + } else { + this.lastError = new Error("File sync failed"); + console.error("File sync failed"); + return false; + } + } catch (error) { + this.lastError = error instanceof Error ? error : new Error(String(error)); + console.error("File sync failed:", error); return false; } - } catch (error) { - this.lastError = error instanceof Error ? error : new Error(String(error)); - console.error("File sync failed:", error); - return false; } } @@ -372,6 +380,9 @@ export class StubBuilder { method: "POST", url: "/api/v1/gateway/stubs", data: camelCaseToSnakeCaseKeys(stubRequest), + headers: { + "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey, + }, }); stubResponse = response.data; } else { @@ -392,6 +403,9 @@ export class StubBuilder { method: "POST", url: "/api/v1/gateway/stubs", data: camelCaseToSnakeCaseKeys(stubRequest), + headers: { + "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey, + }, }); stubResponse = response.data; setStubCreatedForWorkspace(true); @@ -423,6 +437,26 @@ export class StubBuilder { return true; } + public preparationCacheKey( + stubType: string, + ignorePatterns?: string[] + ): string { + return createHash("sha256") + .update( + JSON.stringify({ + stubType, + config: { + ...this.config, + image: this.config.image.config, + volumes: this.config.volumes.map((volume) => volume.export()), + }, + extra: this.extra, + ignorePatterns, + }), + ) + .digest("hex"); + } + public async deployStub( request: DeployStubRequest ): Promise { diff --git a/js/package-lock.json b/js/package-lock.json index 77adff1..d9c0059 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.13", + "version": "1.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@beamcloud/beam-js", - "version": "1.0.13", + "version": "1.0.14", "license": "MIT", "dependencies": { "axios": "^1.16.0", diff --git a/js/package.json b/js/package.json index 81a73e1..2b97731 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.13", + "version": "1.0.14", "description": "TypeScript and JavaScript SDK for Beam", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/js/tests/sandbox-network.test.ts b/js/tests/sandbox-network.test.ts index b724d30..2532561 100644 --- a/js/tests/sandbox-network.test.ts +++ b/js/tests/sandbox-network.test.ts @@ -59,6 +59,91 @@ describe("Sandbox network parity", () => { ); }); + test("creates from a prepared stub without rebuilding or syncing", async () => { + const sandbox = new Sandbox({ name: "cached-sandbox" }); + const buildMock = jest.spyOn(sandbox.stub.config.image, "build"); + const syncMock = jest.spyOn(sandbox.stub.syncer, "sync"); + const requestMock = jest + .spyOn(beamClient, "request") + .mockImplementation(async (config) => { + if (config.url?.endsWith("/connect")) { + return { data: { ok: true } }; + } + return { + data: { + ok: true, + containerId: "sandbox-1", + stubId: "stub-cached", + }, + }; + }); + + await expect(sandbox.create()).resolves.toMatchObject({ + containerId: "sandbox-1", + stubId: "stub-cached", + }); + + expect(sandbox.stub.stubId).toBe("stub-cached"); + expect(buildMock).not.toHaveBeenCalled(); + expect(syncMock).not.toHaveBeenCalled(); + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "api/v1/gateway/pods", + data: {}, + headers: { + "Grpc-Metadata-Preparation-Cache-Key": expect.stringMatching( + /^[0-9a-f]{64}$/ + ), + }, + }) + ); + }); + + test("does not prepare again when a prepared sandbox cannot be scheduled", async () => { + const sandbox = new Sandbox({ name: "cached-sandbox" }); + const prepareMock = jest.spyOn(sandbox.stub, "prepareRuntime"); + jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: false, + errorMsg: "cpu quota exceeded", + stubId: "stub-cached", + }, + }); + + await expect(sandbox.create()).rejects.toThrow("cpu quota exceeded"); + expect(prepareMock).not.toHaveBeenCalled(); + }); + + test("skips file sync for an ignored workspace and caches the stub", async () => { + const sandbox = new Sandbox({ name: "empty-workspace" }); + sandbox.stub.imageAvailable = true; + sandbox.stub.config.image.id = "image-123"; + const syncMock = jest.spyOn(sandbox.stub.syncer, "sync"); + const requestMock = jest + .spyOn(beamClient, "request") + .mockResolvedValue({ data: { ok: true, stubId: "stub-new" } }); + + await expect( + sandbox.stub.prepareRuntime(undefined, EStubType.Sandbox, true, ["*"]) + ).resolves.toBe(true); + + expect(syncMock).not.toHaveBeenCalled(); + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/api/v1/gateway/stubs", + data: expect.objectContaining({ object_id: "" }), + headers: { + "Grpc-Metadata-Preparation-Cache-Key": expect.stringMatching( + /^[0-9a-f]{64}$/ + ), + }, + }) + ); + }); + test("updates network permissions with the sandbox update endpoint", async () => { const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ data: { @@ -170,7 +255,11 @@ describe("Sandbox network parity", () => { const sandbox = new Sandbox({ name: "concurrent-sandbox" }); let releasePreparation!: (prepared: boolean) => void; const preparation = new Promise((resolve) => { - releasePreparation = resolve; + releasePreparation = (prepared) => { + sandbox.stub.stubId = "stub-1"; + sandbox.stub.runtimeReady = prepared; + resolve(prepared); + }; }); const prepareRuntimeMock = jest .spyOn(sandbox.stub, "prepareRuntime") @@ -180,6 +269,9 @@ describe("Sandbox network parity", () => { .spyOn(beamClient, "request") .mockImplementation(async (config) => { if (config.url === "api/v1/gateway/pods") { + if (!config.data?.stubId) { + return { data: { ok: false } }; + } nextContainer += 1; return { data: { @@ -197,6 +289,7 @@ describe("Sandbox network parity", () => { const firstCreate = sandbox.create(); const secondCreate = sandbox.create(); + await new Promise((resolve) => setImmediate(resolve)); expect(prepareRuntimeMock).toHaveBeenCalledTimes(1); releasePreparation(true); @@ -205,7 +298,7 @@ describe("Sandbox network parity", () => { "sandbox-1", "sandbox-2", ]); - expect(requestMock).toHaveBeenCalledTimes(4); + expect(requestMock).toHaveBeenCalledTimes(6); }); test("returns inline exec results without follow-up requests", async () => { @@ -290,8 +383,11 @@ describe("prepareRuntime surfaces real errors via lastError", () => { }); test("file sync exception is surfaced in SandboxConnectionError", async () => { - const sandbox = new Sandbox({ name: "test-sandbox" }); + const sandbox = new Sandbox({ name: "test-sandbox" }, true); sandbox.stub.imageAvailable = true; + jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { ok: false }, + }); jest .spyOn(sandbox.stub.syncer, "sync") From 32dd8e6697e2be0e27257c2bdc2b787b9c9eb240 Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:29:20 -0400 Subject: [PATCH 2/5] Allow callers to defer sandbox readiness --- js/lib/index.ts | 1 + js/lib/resources/abstraction/sandbox.ts | 43 ++++++++++++++++--------- js/tests/sandbox-network.test.ts | 19 +++++++++++ 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/js/lib/index.ts b/js/lib/index.ts index 54f85e9..e6514c6 100644 --- a/js/lib/index.ts +++ b/js/lib/index.ts @@ -86,6 +86,7 @@ export { SandboxFileSystemError, SandboxProcessError, } from "./resources/abstraction/sandbox"; +export type { SandboxCreateOptions } from "./resources/abstraction/sandbox"; // Export Image classes and types export { Image } from "./resources/abstraction/image"; diff --git a/js/lib/resources/abstraction/sandbox.ts b/js/lib/resources/abstraction/sandbox.ts index 464128d..b629cf1 100644 --- a/js/lib/resources/abstraction/sandbox.ts +++ b/js/lib/resources/abstraction/sandbox.ts @@ -25,6 +25,11 @@ export class SandboxFileSystemError extends Error {} /** Error thrown for sandbox process operations. */ export class SandboxProcessError extends Error {} +export interface SandboxCreateOptions { + entrypoint?: string[]; + waitForReady?: boolean; +} + function shellQuote(arg: string): string { if (arg === "") return "''"; // Simple POSIX single-quote escaping @@ -204,7 +209,14 @@ export class Sandbox extends Pod { * * Throws: SandboxConnectionError if the sandbox creation fails. */ - public async create(entrypoint?: string[]): Promise { + public async create( + entrypointOrOptions?: string[] | SandboxCreateOptions, + ): Promise { + const options = Array.isArray(entrypointOrOptions) + ? { entrypoint: entrypointOrOptions } + : entrypointOrOptions; + const entrypoint = options?.entrypoint; + this.stub.config.entrypoint = ["tail", "-f", "/dev/null"]; if (entrypoint && entrypoint.length) { this.stub.config.entrypoint = entrypoint; @@ -266,20 +278,21 @@ export class Sandbox extends Pod { // eslint-disable-next-line no-console console.log(`Sandbox created successfully ===> ${body.containerId}`); - // Connect to the sandbox to ensure it's ready - const connectResp = await beamClient.request({ - method: "POST", - url: `api/v1/gateway/pods/${body.containerId}/connect`, - data: {}, - }); - const connectData = connectResp.data as { - ok: boolean; - errorMsg?: string; - }; - if (!connectData.ok) { - throw new SandboxConnectionError( - connectData.errorMsg || "Failed to connect to sandbox", - ); + if (options?.waitForReady !== false) { + const connectResp = await beamClient.request({ + method: "POST", + url: `api/v1/gateway/pods/${body.containerId}/connect`, + data: {}, + }); + const connectData = connectResp.data as { + ok: boolean; + errorMsg?: string; + }; + if (!connectData.ok) { + throw new SandboxConnectionError( + connectData.errorMsg || "Failed to connect to sandbox", + ); + } } if ((this.stub.config.keepWarmSeconds as number) < 0) { diff --git a/js/tests/sandbox-network.test.ts b/js/tests/sandbox-network.test.ts index 2532561..268abdb 100644 --- a/js/tests/sandbox-network.test.ts +++ b/js/tests/sandbox-network.test.ts @@ -101,6 +101,25 @@ describe("Sandbox network parity", () => { ); }); + test("can return before readiness when the next operation waits for it", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + containerId: "sandbox-1", + stubId: "stub-cached", + }, + }); + + await expect( + new Sandbox({ name: "cached-sandbox" }).create({ waitForReady: false }) + ).resolves.toMatchObject({ containerId: "sandbox-1" }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ url: "api/v1/gateway/pods" }) + ); + }); + test("does not prepare again when a prepared sandbox cannot be scheduled", async () => { const sandbox = new Sandbox({ name: "cached-sandbox" }); const prepareMock = jest.spyOn(sandbox.stub, "prepareRuntime"); From 1e3db80bcbb73a0e3daeaf7247dd3407011cde3a Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:00:26 -0400 Subject: [PATCH 3/5] Terminate sandboxes without reconnecting --- js/lib/resources/abstraction/sandbox.ts | 30 +++++++++---------------- js/tests/sandbox-network.test.ts | 14 ++++++++++++ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/js/lib/resources/abstraction/sandbox.ts b/js/lib/resources/abstraction/sandbox.ts index b629cf1..84634d1 100644 --- a/js/lib/resources/abstraction/sandbox.ts +++ b/js/lib/resources/abstraction/sandbox.ts @@ -114,6 +114,16 @@ export class Sandbox extends Pod { ); } + /** Terminate a sandbox by ID without connecting to it first. */ + public static async terminate(id: string): Promise { + const response = await beamClient.request({ + method: "POST", + url: `api/v1/gateway/containers/${id}/stop`, + data: {}, + }); + return Boolean(response.data?.ok); + } + /** * Create a sandbox instance from a filesystem snapshot. * @@ -224,9 +234,6 @@ export class Sandbox extends Pod { const ignorePatterns = this.syncLocalDir ? undefined : ["*"]; - // eslint-disable-next-line no-console - console.log("Creating sandbox"); - let body = await this.createContainer( this.stub.runtimeReady ? undefined @@ -275,9 +282,6 @@ export class Sandbox extends Pod { ); } - // eslint-disable-next-line no-console - console.log(`Sandbox created successfully ===> ${body.containerId}`); - if (options?.waitForReady !== false) { const connectResp = await beamClient.request({ method: "POST", @@ -295,18 +299,6 @@ export class Sandbox extends Pod { } } - if ((this.stub.config.keepWarmSeconds as number) < 0) { - // eslint-disable-next-line no-console - console.log( - "This sandbox has no timeout, it will run until it is shut down manually.", - ); - } else { - // eslint-disable-next-line no-console - console.log( - `This sandbox will timeout after ${this.stub.config.keepWarmSeconds} seconds.`, - ); - } - return new SandboxInstance( { stubId: this.stub.stubId!, @@ -484,7 +476,7 @@ export class SandboxInstance extends PodInstance { * Terminate the sandbox instance. */ public async terminate(): Promise { - const result = await super.terminate(); + const result = await Sandbox.terminate(this.containerId); if (result) { this.terminated = true; } diff --git a/js/tests/sandbox-network.test.ts b/js/tests/sandbox-network.test.ts index 268abdb..276e83c 100644 --- a/js/tests/sandbox-network.test.ts +++ b/js/tests/sandbox-network.test.ts @@ -120,6 +120,20 @@ describe("Sandbox network parity", () => { ); }); + test("terminates by ID without connecting first", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { ok: true }, + }); + + await expect(Sandbox.terminate("sandbox-1")).resolves.toBe(true); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + method: "POST", + url: "api/v1/gateway/containers/sandbox-1/stop", + data: {}, + }); + }); + test("does not prepare again when a prepared sandbox cannot be scheduled", async () => { const sandbox = new Sandbox({ name: "cached-sandbox" }); const prepareMock = jest.spyOn(sandbox.stub, "prepareRuntime"); From ed85fdfb7fa4e529277a97175ed41ef963bbea11 Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:19:34 -0400 Subject: [PATCH 4/5] fix(js): key prepared sandboxes by image --- js/lib/resources/abstraction/sandbox.ts | 10 ++++----- js/lib/resources/abstraction/stub.ts | 26 ++++++++++++++--------- js/tests/sandbox-network.test.ts | 28 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/js/lib/resources/abstraction/sandbox.ts b/js/lib/resources/abstraction/sandbox.ts index 84634d1..e8db4ce 100644 --- a/js/lib/resources/abstraction/sandbox.ts +++ b/js/lib/resources/abstraction/sandbox.ts @@ -234,11 +234,11 @@ export class Sandbox extends Pod { const ignorePatterns = this.syncLocalDir ? undefined : ["*"]; - let body = await this.createContainer( - this.stub.runtimeReady - ? undefined - : this.stub.preparationCacheKey(EStubType.Sandbox, ignorePatterns), - ); + const preparationCacheKey = + !this.syncLocalDir && !this.stub.runtimeReady + ? this.stub.preparationCacheKey(EStubType.Sandbox, ignorePatterns) + : undefined; + let body = await this.createContainer(preparationCacheKey); if (body.ok && body.stubId) { this.stub.stubCreated = true; this.stub.stubId = body.stubId; diff --git a/js/lib/resources/abstraction/stub.ts b/js/lib/resources/abstraction/stub.ts index 4a5c94e..f8051ee 100644 --- a/js/lib/resources/abstraction/stub.ts +++ b/js/lib/resources/abstraction/stub.ts @@ -57,9 +57,10 @@ export interface StubConfig { allowList?: string[]; } -export interface CreateStubConfig extends Partial { +export type CreateStubConfig = Omit, "image"> & { name: string; -} + image?: Image | string; +}; // Global stub creation state management let _stubCreatedForWorkspace = false; @@ -121,7 +122,8 @@ export class StubBuilder { this.config.name = name; this.config.app = app || name; this.config.authorized = authorized; - this.config.image = image || new Image({}); + this.config.image = + typeof image === "string" ? Image.fromRegistry(image) : image || new Image({}); this.config.callbackUrl = callbackUrl; this.config.cpu = cpu; this.config.memory = memory; @@ -240,7 +242,10 @@ export class StubBuilder { return true; } - const preparationCacheKey = this.preparationCacheKey(stubType, ignorePatterns); + const preparationCacheKey = + ignorePatterns?.length === 1 && ignorePatterns[0] === "*" + ? this.preparationCacheKey(stubType, ignorePatterns) + : undefined; // Build image if not available if (!this.imageAvailable) { @@ -380,9 +385,9 @@ export class StubBuilder { method: "POST", url: "/api/v1/gateway/stubs", data: camelCaseToSnakeCaseKeys(stubRequest), - headers: { - "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey, - }, + headers: preparationCacheKey + ? { "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey } + : undefined, }); stubResponse = response.data; } else { @@ -403,9 +408,9 @@ export class StubBuilder { method: "POST", url: "/api/v1/gateway/stubs", data: camelCaseToSnakeCaseKeys(stubRequest), - headers: { - "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey, - }, + headers: preparationCacheKey + ? { "Grpc-Metadata-Preparation-Cache-Key": preparationCacheKey } + : undefined, }); stubResponse = response.data; setStubCreatedForWorkspace(true); @@ -444,6 +449,7 @@ export class StubBuilder { return createHash("sha256") .update( JSON.stringify({ + version: 1, stubType, config: { ...this.config, diff --git a/js/tests/sandbox-network.test.ts b/js/tests/sandbox-network.test.ts index 276e83c..771fac8 100644 --- a/js/tests/sandbox-network.test.ts +++ b/js/tests/sandbox-network.test.ts @@ -1,4 +1,5 @@ import beamClient from "../lib"; +import { Image } from "../lib/resources/abstraction/image"; import { Sandbox, SandboxConnectionError, SandboxInstance } from "../lib/resources/abstraction/sandbox"; import { EStubType } from "../lib/types/stub"; @@ -101,6 +102,33 @@ describe("Sandbox network parity", () => { ); }); + test("uses distinct preparation keys for distinct images", () => { + const node = new Sandbox({ name: "image-key", image: "node:20" }); + const python = new Sandbox({ name: "image-key", image: "python:3.12" }); + + expect(node.stub.config.image).toBeInstanceOf(Image); + expect( + node.stub.preparationCacheKey(EStubType.Sandbox, ["*"]) + ).not.toBe(python.stub.preparationCacheKey(EStubType.Sandbox, ["*"])); + }); + + test("does not use a prepared stub when syncing local files", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { ok: true, containerId: "sandbox-1", stubId: "stub-1" }, + }); + + await new Sandbox({ name: "synced-sandbox" }, true).create({ + waitForReady: false, + }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "api/v1/gateway/pods", + headers: undefined, + }) + ); + }); + test("can return before readiness when the next operation waits for it", async () => { const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ data: { From 712a2bfd48e9a0310afe202b78eeb25db844f81a Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:20:58 -0400 Subject: [PATCH 5/5] fix(js): remove ambiguous sandbox download aliases --- js/lib/resources/abstraction/sandbox-files.ts | 6 +++++ js/lib/resources/abstraction/sandbox.ts | 27 +++++-------------- js/package-lock.json | 4 +-- js/package.json | 2 +- 4 files changed, 16 insertions(+), 23 deletions(-) create mode 100644 js/lib/resources/abstraction/sandbox-files.ts diff --git a/js/lib/resources/abstraction/sandbox-files.ts b/js/lib/resources/abstraction/sandbox-files.ts new file mode 100644 index 0000000..984b9ce --- /dev/null +++ b/js/lib/resources/abstraction/sandbox-files.ts @@ -0,0 +1,6 @@ +export function sandboxFileContentUrl( + containerId: string, + sandboxPath: string, +): string { + return `api/v1/gateway/pods/${containerId}/files/download/${encodeURIComponent(sandboxPath)}`; +} diff --git a/js/lib/resources/abstraction/sandbox.ts b/js/lib/resources/abstraction/sandbox.ts index e8db4ce..db401fb 100644 --- a/js/lib/resources/abstraction/sandbox.ts +++ b/js/lib/resources/abstraction/sandbox.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import { Pod, PodInstance } from "./pod"; +import { sandboxFileContentUrl } from "./sandbox-files"; import { CreateStubConfig } from "./stub"; import { EStubType } from "../../types/stub"; import type { @@ -1039,7 +1040,7 @@ export class SandboxFileSearchResult { /** * File system interface for managing files within a sandbox. * - * Upload, download, stat, list, and manage files and directories. + * Upload, stat, list, and manage files and directories. */ export class SandboxFileSystem { private sandbox_instance: SandboxInstance; @@ -1092,32 +1093,18 @@ export class SandboxFileSystem { return this.writeBytes(sandboxPath, Buffer.from(content, "utf8"), mode); } - /** Download a file from the sandbox and return its bytes. */ - public async download(sandboxPath: string): Promise { - return this.readBytes(sandboxPath); - } - - /** Download a file from the sandbox to a local path. */ - public async downloadFile( - sandboxPath: string, - localPath: string, - ): Promise { - fs.writeFileSync(localPath, await this.readBytes(sandboxPath)); - } - /** Read a file from the sandbox as bytes. */ public async readBytes(sandboxPath: string): Promise { const resp = await beamClient.request({ method: "GET", - url: `api/v1/gateway/pods/${ - this.sandbox_instance.containerId - }/files/download/${encodeURIComponent(sandboxPath)}`, + url: sandboxFileContentUrl( + this.sandbox_instance.containerId, + sandboxPath, + ), }); const data = resp.data as { ok: boolean; errorMsg?: string; data?: string }; if (!data.ok || !data.data) - throw new SandboxFileSystemError( - data.errorMsg || "Failed to download file", - ); + throw new SandboxFileSystemError(data.errorMsg || "Failed to read file"); return Buffer.from(data.data, "base64"); } diff --git a/js/package-lock.json b/js/package-lock.json index d9c0059..4c6b0c1 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.14", + "version": "1.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@beamcloud/beam-js", - "version": "1.0.14", + "version": "1.0.17", "license": "MIT", "dependencies": { "axios": "^1.16.0", diff --git a/js/package.json b/js/package.json index 2b97731..7c18035 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.14", + "version": "1.0.17", "description": "TypeScript and JavaScript SDK for Beam", "main": "dist/index.js", "module": "dist/index.mjs",