From 89a601b6655610b5fe5489dfe5b0900a6a010fda Mon Sep 17 00:00:00 2001 From: nicoalbanese <49612682+nicoalbanese@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:57:37 +0000 Subject: [PATCH 1/2] feat: expose top-level sandbox helpers accepting sandboxId --- .changeset/bright-cobras-sing.md | 5 + packages/vercel-sandbox/src/index.ts | 14 + packages/vercel-sandbox/src/sandbox.test.ts | 95 ++++++- packages/vercel-sandbox/src/sandbox.ts | 298 +++++++++++++++++++- 4 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 .changeset/bright-cobras-sing.md diff --git a/.changeset/bright-cobras-sing.md b/.changeset/bright-cobras-sing.md new file mode 100644 index 0000000..2655bd8 --- /dev/null +++ b/.changeset/bright-cobras-sing.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Expose top-level sandbox helpers that accept a sandboxId for command and file operations. diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 5540c76..381a1be 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -1,5 +1,19 @@ export { Sandbox, + createSnapshot, + downloadFile, + extendSandboxTimeout, + getCommand, + getSandboxDomain, + mkDir, + mkdir, + readFile, + readFileToBuffer, + runCommand, + stopSandbox, + updateSandboxNetworkPolicy, + writeFile, + writeFiles, type NetworkPolicy, type NetworkPolicyRule, type NetworkTransformer, diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 6c796c9..d3a8246 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -1,10 +1,17 @@ -import { it, beforeEach, afterEach, expect, describe } from "vitest"; +import { it, beforeEach, afterEach, expect, describe, vi } from "vitest"; import { consumeReadable } from "./utils/consume-readable"; -import { Sandbox } from "./sandbox"; +import { + Sandbox, + mkdir, + readFile as readSandboxFile, + runCommand as runSandboxCommand, + writeFile as writeSandboxFile, +} from "./sandbox"; import { APIError } from "./api-client/api-error"; import { mkdtemp, readFile, rm } from "fs/promises"; import { tmpdir } from "os"; import { join, resolve } from "path"; +import { Readable } from "stream"; import ms from "ms"; describe("downloadFile validation", () => { @@ -53,6 +60,90 @@ describe("downloadFile validation", () => { }); }); +describe("top-level sandbox helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runs commands by sandbox ID", async () => { + const command = { exitCode: 0 } as any; + const runCommandMock = vi.fn().mockResolvedValue(command); + const sandbox = { runCommand: runCommandMock } as unknown as Sandbox; + const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + + const result = await runSandboxCommand({ + sandboxId: "sandbox_123", + cmd: "echo", + args: ["hello"], + }); + + expect(result).toBe(command); + expect(getSpy).toHaveBeenCalledWith({ + sandboxId: "sandbox_123", + cmd: "echo", + args: ["hello"], + }); + expect(runCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + cmd: "echo", + args: ["hello"], + }), + ); + }); + + it("creates directories by sandbox ID", async () => { + const mkDirMock = vi.fn().mockResolvedValue(undefined); + const sandbox = { mkDir: mkDirMock } as unknown as Sandbox; + vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + + await mkdir({ + sandboxId: "sandbox_123", + path: "tmp/nested", + }); + + expect(mkDirMock).toHaveBeenCalledWith("tmp/nested", { + signal: undefined, + }); + }); + + it("reads files by sandbox ID", async () => { + const stream = Readable.from([Buffer.from("Hello")]); + const readFileMock = vi.fn().mockResolvedValue(stream); + const sandbox = { readFile: readFileMock } as unknown as Sandbox; + vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + + const result = await readSandboxFile({ + sandboxId: "sandbox_123", + path: "hello.txt", + cwd: "/tmp", + }); + + expect(result).toBe(stream); + expect(readFileMock).toHaveBeenCalledWith( + { path: "hello.txt", cwd: "/tmp" }, + { signal: undefined }, + ); + }); + + it("writes a single file by sandbox ID", async () => { + const content = Buffer.from("Hello"); + const writeFilesMock = vi.fn().mockResolvedValue(undefined); + const sandbox = { writeFiles: writeFilesMock } as unknown as Sandbox; + vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + + await writeSandboxFile({ + sandboxId: "sandbox_123", + path: "hello.txt", + content, + }); + + expect(writeFilesMock).toHaveBeenCalledWith( + [{ path: "hello.txt", content }], + { signal: undefined }, + ); + }); +}); + describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Sandbox", () => { const PORTS = [3000, 4000]; let sandbox: Sandbox; diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index c93c96a..e185a45 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -2,7 +2,7 @@ import type { SandboxMetaData, SandboxRouteData } from "./api-client"; import { type Writable } from "stream"; import { pipeline } from "stream/promises"; import { createWriteStream } from "fs"; -import { mkdir } from "fs/promises"; +import { mkdir as mkdirLocal } from "fs/promises"; import { dirname, resolve } from "path"; import { APIClient } from "./api-client"; import { Command, CommandFinished } from "./command"; @@ -115,6 +115,100 @@ interface GetSandboxParams { signal?: AbortSignal; } +interface SandboxAccessOptions extends WithFetchOptions { + token?: string; + teamId?: string; + projectId?: string; +} + +interface SandboxOperationParams extends GetSandboxParams, SandboxAccessOptions {} + +interface GetCommandBySandboxIdParams extends SandboxOperationParams { + cmdId: string; +} + +interface MkDirBySandboxIdParams extends SandboxOperationParams { + path: string; +} + +interface ReadFileBySandboxIdParams extends SandboxOperationParams { + path: string; + cwd?: string; +} + +interface WriteFilesBySandboxIdParams extends SandboxOperationParams { + files: { path: string; content: Buffer }[]; +} + +interface WriteFileBySandboxIdParams extends SandboxOperationParams { + path: string; + content: Buffer; +} + +interface DownloadFileBySandboxIdParams extends SandboxOperationParams { + src: { path: string; cwd?: string }; + dst: { path: string; cwd?: string }; + mkdirRecursive?: boolean; +} + +interface GetSandboxDomainParams extends SandboxOperationParams { + port: number; +} + +interface StopSandboxParams extends SandboxOperationParams { + blocking?: boolean; +} + +interface UpdateSandboxNetworkPolicyParams extends SandboxOperationParams { + networkPolicy: NetworkPolicy; +} + +interface ExtendSandboxTimeoutParams extends SandboxOperationParams { + duration: number; +} + +interface CreateSnapshotBySandboxIdParams extends SandboxOperationParams { + expiration?: number; +} + +function getSandboxAccessCredentials( + params?: SandboxAccessOptions, +): Pick | null { + if ( + typeof params?.token === "string" && + typeof params?.teamId === "string" + ) { + return { + token: params.token, + teamId: params.teamId, + }; + } + + if (params?.token !== undefined || params?.teamId !== undefined) { + const missing = [ + typeof params?.token === "string" ? null : "token", + typeof params?.teamId === "string" ? null : "teamId", + ].filter((value) => value !== null); + + throw new Error( + `Missing credentials parameters to access the Vercel API: ${missing.join(", ")}`, + ); + } + + return null; +} + +async function createSandboxClient(params?: SandboxAccessOptions) { + const credentials = + getSandboxAccessCredentials(params) ?? (await getCredentials(params)); + + return new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params?.fetch, + }); +} + /** @inline */ interface RunCommandParams { /** @@ -310,15 +404,9 @@ export class Sandbox { * @returns A promise resolving to the {@link Sandbox}. */ static async get( - params: WithPrivate & - WithFetchOptions, + params: WithPrivate, ): Promise { - const credentials = await getCredentials(params); - const client = new APIClient({ - teamId: credentials.teamId, - token: credentials.token, - fetch: params.fetch, - }); + const client = await createSandboxClient(params); const privateParams = getPrivateParams(params); const sandbox = await client.getSandbox({ @@ -594,7 +682,7 @@ export class Sandbox { try { const dstPath = resolve(dst.cwd ?? "", dst.path); if (opts?.mkdirRecursive) { - await mkdir(dirname(dstPath), { recursive: true }); + await mkdirLocal(dirname(dstPath), { recursive: true }); } await pipeline(stream, createWriteStream(dstPath), { signal: opts?.signal, @@ -782,3 +870,193 @@ class DisposableSandbox extends Sandbox implements AsyncDisposable { await this.stop(); } } + +async function loadSandbox(params: WithPrivate) { + return Sandbox.get(params); +} + +/** + * Retrieve a previously run command from an existing sandbox by ID. + */ +export async function getCommand( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.getCommand(params.cmdId, { signal: params.signal }); +} + +/** + * Start executing a command in an existing sandbox by ID. + */ +export async function runCommand( + params: WithPrivate, +): Promise; +export async function runCommand( + params: WithPrivate, +): Promise; +export async function runCommand( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.runCommand({ + cmd: params.cmd, + args: params.args, + cwd: params.cwd, + env: params.env, + sudo: params.sudo, + detached: params.detached, + stdout: params.stdout, + stderr: params.stderr, + signal: params.signal, + }); +} + +/** + * Create a directory in an existing sandbox by ID. + */ +export async function mkDir( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + await sandbox.mkDir(params.path, { + signal: params.signal, + }); +} + +/** + * Alias for {@link mkDir}. + */ +export async function mkdir( + params: WithPrivate, +): Promise { + await mkDir(params); +} + +/** + * Read a file from an existing sandbox by ID as a stream. + */ +export async function readFile( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.readFile( + { + path: params.path, + cwd: params.cwd, + }, + { signal: params.signal }, + ); +} + +/** + * Read a file from an existing sandbox by ID as a buffer. + */ +export async function readFileToBuffer( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.readFileToBuffer( + { + path: params.path, + cwd: params.cwd, + }, + { signal: params.signal }, + ); +} + +/** + * Download a file from an existing sandbox by ID to the local filesystem. + */ +export async function downloadFile( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.downloadFile(params.src, params.dst, { + mkdirRecursive: params.mkdirRecursive, + signal: params.signal, + }); +} + +/** + * Write multiple files to an existing sandbox by ID. + */ +export async function writeFiles( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + await sandbox.writeFiles(params.files, { + signal: params.signal, + }); +} + +/** + * Write a single file to an existing sandbox by ID. + */ +export async function writeFile( + params: WithPrivate, +): Promise { + await writeFiles({ + ...params, + files: [{ path: params.path, content: params.content }], + }); +} + +/** + * Resolve the public domain for a port exposed by an existing sandbox. + */ +export async function getSandboxDomain( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.domain(params.port); +} + +/** + * Stop an existing sandbox by ID. + */ +export async function stopSandbox( + params: WithPrivate, +) { + const sandbox = await loadSandbox(params); + return sandbox.stop({ + signal: params.signal, + blocking: params.blocking, + }); +} + +/** + * Update the network policy for an existing sandbox by ID. + */ +export async function updateSandboxNetworkPolicy( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.updateNetworkPolicy(params.networkPolicy, { + signal: params.signal, + }); +} + +/** + * Extend the timeout of an existing sandbox by ID. + */ +export async function extendSandboxTimeout( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + await sandbox.extendTimeout(params.duration, { + signal: params.signal, + }); +} + +/** + * Create a snapshot from an existing sandbox by ID. + */ +export async function createSnapshot( + params: WithPrivate, +): Promise { + const sandbox = await loadSandbox(params); + return sandbox.snapshot({ + expiration: params.expiration, + signal: params.signal, + }); +} From a7a65ff8f7924f35b12539039ea4a322396129e6 Mon Sep 17 00:00:00 2001 From: nicoalbanese <49612682+nicoalbanese@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:43:45 +0000 Subject: [PATCH 2/2] feat: split sandbox operations into dedicated module --- .changeset/bright-cobras-sing.md | 2 +- packages/vercel-sandbox/src/index.ts | 10 +- .../vercel-sandbox/src/sandbox-operations.ts | 507 ++++++++++++++++ packages/vercel-sandbox/src/sandbox.test.ts | 166 +++-- packages/vercel-sandbox/src/sandbox.ts | 568 +++--------------- 5 files changed, 704 insertions(+), 549 deletions(-) create mode 100644 packages/vercel-sandbox/src/sandbox-operations.ts diff --git a/.changeset/bright-cobras-sing.md b/.changeset/bright-cobras-sing.md index 2655bd8..eadc7e4 100644 --- a/.changeset/bright-cobras-sing.md +++ b/.changeset/bright-cobras-sing.md @@ -2,4 +2,4 @@ "@vercel/sandbox": minor --- -Expose top-level sandbox helpers that accept a sandboxId for command and file operations. +Expose sandboxId-first top-level helpers and move the standalone sandbox operations into a dedicated module. diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 381a1be..f1228ed 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -1,5 +1,10 @@ export { Sandbox, + type NetworkPolicy, + type NetworkPolicyRule, + type NetworkTransformer, +} from "./sandbox"; +export { createSnapshot, downloadFile, extendSandboxTimeout, @@ -14,10 +19,7 @@ export { updateSandboxNetworkPolicy, writeFile, writeFiles, - type NetworkPolicy, - type NetworkPolicyRule, - type NetworkTransformer, -} from "./sandbox"; +} from "./sandbox-operations"; export { Snapshot } from "./snapshot"; export { Command, CommandFinished } from "./command"; export { StreamError } from "./api-client/api-error"; diff --git a/packages/vercel-sandbox/src/sandbox-operations.ts b/packages/vercel-sandbox/src/sandbox-operations.ts new file mode 100644 index 0000000..7b3376e --- /dev/null +++ b/packages/vercel-sandbox/src/sandbox-operations.ts @@ -0,0 +1,507 @@ +import { createWriteStream } from "fs"; +import { mkdir as mkdirLocal } from "fs/promises"; +import { dirname, resolve } from "path"; +import { type Writable } from "stream"; +import { pipeline } from "stream/promises"; +import { APIClient } from "./api-client"; +import { type WithFetchOptions } from "./api-client/api-client"; +import { Command, CommandFinished } from "./command"; +import { type NetworkPolicy } from "./network-policy"; +import { Snapshot } from "./snapshot"; +import { consumeReadable } from "./utils/consume-readable"; +import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; +import { type Credentials, getCredentials } from "./utils/get-credentials"; + +export interface RunCommandParams { + /** + * The command to execute. + */ + cmd: string; + /** + * Arguments to pass to the command. + */ + args?: string[]; + /** + * Working directory to execute the command in. + */ + cwd?: string; + /** + * Environment variables to set for this command. + */ + env?: Record; + /** + * If true, execute this command with root privileges. Defaults to false. + */ + sudo?: boolean; + /** + * If true, the command will return without waiting for `exitCode`. + */ + detached?: boolean; + /** + * A `Writable` stream where `stdout` from the command will be piped. + */ + stdout?: Writable; + /** + * A `Writable` stream where `stderr` from the command will be piped. + */ + stderr?: Writable; + /** + * An AbortSignal to cancel the command execution. + */ + signal?: AbortSignal; +} + +export interface SandboxAccessOptions extends WithFetchOptions { + client?: APIClient; + token?: string; + teamId?: string; + projectId?: string; +} + +interface SandboxClientOptions { + client?: APIClient; +} + +interface SandboxSignalOptions extends SandboxClientOptions { + signal?: AbortSignal; +} + +interface DownloadFileOptions extends SandboxSignalOptions { + mkdirRecursive?: boolean; +} + +interface StopSandboxOptions extends SandboxSignalOptions { + blocking?: boolean; +} + +interface CreateSnapshotOptions extends SandboxSignalOptions { + expiration?: number; +} + +function getSandboxAccessCredentials( + params?: SandboxAccessOptions, +): Pick | null { + if (params?.client) { + return null; + } + + if ( + typeof params?.token === "string" && + typeof params?.teamId === "string" + ) { + return { + token: params.token, + teamId: params.teamId, + }; + } + + if (params?.token !== undefined || params?.teamId !== undefined) { + const missing = [ + typeof params?.token === "string" ? null : "token", + typeof params?.teamId === "string" ? null : "teamId", + ].filter((value) => value !== null); + + throw new Error( + `Missing credentials parameters to access the Vercel API: ${missing.join(", ")}`, + ); + } + + return null; +} + +export async function createSandboxClient( + params?: SandboxAccessOptions, +): Promise { + if (params?.client) { + return params.client; + } + + const credentials = + getSandboxAccessCredentials(params) ?? (await getCredentials(params)); + + return new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params?.fetch, + }); +} + +async function getSandboxDetails( + sandboxId: string, + opts?: SandboxSignalOptions, +) { + const client = await createSandboxClient(opts); + const response = await client.getSandbox({ + sandboxId, + signal: opts?.signal, + }); + + return { + client, + sandbox: response.json.sandbox, + routes: response.json.routes, + }; +} + +async function runSandboxCommand( + client: APIClient, + sandboxId: string, + params: RunCommandParams, +) { + const wait = params.detached ? false : true; + const streamLogs = (command: Command) => { + if (params.stdout || params.stderr) { + (async () => { + try { + for await (const log of command.logs({ signal: params.signal })) { + if (log.stream === "stdout") { + params.stdout?.write(log.data); + } else if (log.stream === "stderr") { + params.stderr?.write(log.data); + } + } + } catch (err) { + if (params.signal?.aborted) { + return; + } + throw err; + } + })(); + } + }; + + if (wait) { + const commandStream = await client.runCommand({ + sandboxId, + command: params.cmd, + args: params.args ?? [], + cwd: params.cwd, + env: params.env ?? {}, + sudo: params.sudo ?? false, + wait: true, + signal: params.signal, + }); + + const command = new Command({ + client, + sandboxId, + cmd: commandStream.command, + }); + + streamLogs(command); + + const finished = await commandStream.finished; + return new CommandFinished({ + client, + sandboxId, + cmd: finished, + exitCode: finished.exitCode ?? 0, + }); + } + + const commandResponse = await client.runCommand({ + sandboxId, + command: params.cmd, + args: params.args ?? [], + cwd: params.cwd, + env: params.env ?? {}, + sudo: params.sudo ?? false, + signal: params.signal, + }); + + const command = new Command({ + client, + sandboxId, + cmd: commandResponse.json.command, + }); + + streamLogs(command); + + return command; +} + +/** + * Retrieve a previously run command from an existing sandbox by ID. + */ +export async function getCommand( + sandboxId: string, + cmdId: string, + opts?: SandboxSignalOptions, +): Promise { + const client = await createSandboxClient(opts); + const response = await client.getCommand({ + sandboxId, + cmdId, + signal: opts?.signal, + }); + + return new Command({ + client, + sandboxId, + cmd: response.json.command, + }); +} + +/** + * Start executing a command in an existing sandbox by ID. + */ +export async function runCommand( + sandboxId: string, + command: string, + args?: string[], + opts?: SandboxSignalOptions, +): Promise; +export async function runCommand( + sandboxId: string, + params: RunCommandParams & { detached: true }, + opts?: SandboxClientOptions, +): Promise; +export async function runCommand( + sandboxId: string, + params: RunCommandParams, + opts?: SandboxClientOptions, +): Promise; +export async function runCommand( + sandboxId: string, + commandOrParams: string | RunCommandParams, + argsOrOptions?: string[] | SandboxSignalOptions, + maybeOptions?: SandboxSignalOptions, +): Promise { + if (typeof commandOrParams === "string") { + const args = Array.isArray(argsOrOptions) ? argsOrOptions : undefined; + const options = + Array.isArray(argsOrOptions) || argsOrOptions === undefined + ? maybeOptions + : argsOrOptions; + const client = await createSandboxClient(options); + + return runSandboxCommand(client, sandboxId, { + cmd: commandOrParams, + args, + signal: options?.signal, + }); + } + + const options = !Array.isArray(argsOrOptions) ? argsOrOptions : maybeOptions; + const client = await createSandboxClient(options); + return runSandboxCommand(client, sandboxId, commandOrParams); +} + +/** + * Create a directory in an existing sandbox by ID. + */ +export async function mkDir( + sandboxId: string, + path: string, + opts?: SandboxSignalOptions, +): Promise { + const client = await createSandboxClient(opts); + await client.mkDir({ + sandboxId, + path, + signal: opts?.signal, + }); +} + +/** + * Alias for {@link mkDir}. + */ +export async function mkdir( + sandboxId: string, + path: string, + opts?: SandboxSignalOptions, +): Promise { + await mkDir(sandboxId, path, opts); +} + +/** + * Read a file from an existing sandbox by ID as a stream. + */ +export async function readFile( + sandboxId: string, + file: { path: string; cwd?: string }, + opts?: SandboxSignalOptions, +): Promise { + const client = await createSandboxClient(opts); + return client.readFile({ + sandboxId, + path: file.path, + cwd: file.cwd, + signal: opts?.signal, + }); +} + +/** + * Read a file from an existing sandbox by ID as a buffer. + */ +export async function readFileToBuffer( + sandboxId: string, + file: { path: string; cwd?: string }, + opts?: SandboxSignalOptions, +): Promise { + const stream = await readFile(sandboxId, file, opts); + + if (stream === null) { + return null; + } + + return consumeReadable(stream); +} + +/** + * Download a file from an existing sandbox by ID to the local filesystem. + */ +export async function downloadFile( + sandboxId: string, + src: { path: string; cwd?: string }, + dst: { path: string; cwd?: string }, + opts?: DownloadFileOptions, +): Promise { + if (!src?.path) { + throw new Error("downloadFile: source path is required"); + } + + if (!dst?.path) { + throw new Error("downloadFile: destination path is required"); + } + + const stream = await readFile(sandboxId, src, opts); + + if (stream === null) { + return null; + } + + try { + const dstPath = resolve(dst.cwd ?? "", dst.path); + if (opts?.mkdirRecursive) { + await mkdirLocal(dirname(dstPath), { recursive: true }); + } + await pipeline(stream, createWriteStream(dstPath), { + signal: opts?.signal, + }); + return dstPath; + } finally { + if ("destroy" in stream && typeof stream.destroy === "function") { + stream.destroy(); + } + } +} + +/** + * Write multiple files to an existing sandbox by ID. + */ +export async function writeFiles( + sandboxId: string, + files: { path: string; content: Buffer }[], + opts?: SandboxSignalOptions, +): Promise { + const { client, sandbox } = await getSandboxDetails(sandboxId, opts); + + await client.writeFiles({ + sandboxId, + cwd: sandbox.cwd, + extractDir: "/", + files, + signal: opts?.signal, + }); +} + +/** + * Write a single file to an existing sandbox by ID. + */ +export async function writeFile( + sandboxId: string, + file: { path: string; content: Buffer }, + opts?: SandboxSignalOptions, +): Promise { + await writeFiles(sandboxId, [file], opts); +} + +/** + * Resolve the public domain for a port exposed by an existing sandbox. + */ +export async function getSandboxDomain( + sandboxId: string, + port: number, + opts?: SandboxSignalOptions, +): Promise { + const { routes } = await getSandboxDetails(sandboxId, opts); + const route = routes.find((candidate) => candidate.port === port); + + if (!route) { + throw new Error(`No route for port ${port}`); + } + + return `https://${route.subdomain}.vercel.run`; +} + +/** + * Stop an existing sandbox by ID. + */ +export async function stopSandbox( + sandboxId: string, + opts?: StopSandboxOptions, +): Promise { + const client = await createSandboxClient(opts); + const response = await client.stopSandbox({ + sandboxId, + signal: opts?.signal, + blocking: opts?.blocking, + }); + + return convertSandbox(response.json.sandbox); +} + +/** + * Update the network policy for an existing sandbox by ID. + */ +export async function updateSandboxNetworkPolicy( + sandboxId: string, + networkPolicy: NetworkPolicy, + opts?: SandboxSignalOptions, +): Promise { + const client = await createSandboxClient(opts); + const response = await client.updateNetworkPolicy({ + sandboxId, + networkPolicy, + signal: opts?.signal, + }); + + return convertSandbox(response.json.sandbox).networkPolicy!; +} + +/** + * Extend the timeout of an existing sandbox by ID. + */ +export async function extendSandboxTimeout( + sandboxId: string, + duration: number, + opts?: SandboxSignalOptions, +): Promise { + const client = await createSandboxClient(opts); + await client.extendTimeout({ + sandboxId, + duration, + signal: opts?.signal, + }); +} + +/** + * Create a snapshot from an existing sandbox by ID. + */ +export async function createSnapshot( + sandboxId: string, + opts?: CreateSnapshotOptions, +): Promise { + const client = await createSandboxClient(opts); + const response = await client.createSnapshot({ + sandboxId, + expiration: opts?.expiration, + signal: opts?.signal, + }); + + return new Snapshot({ + client, + snapshot: response.json.snapshot, + }); +} diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index d3a8246..f94c2d4 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -1,12 +1,13 @@ import { it, beforeEach, afterEach, expect, describe, vi } from "vitest"; import { consumeReadable } from "./utils/consume-readable"; +import { Sandbox } from "./sandbox"; +import * as sandboxOperations from "./sandbox-operations"; import { - Sandbox, mkdir, readFile as readSandboxFile, runCommand as runSandboxCommand, writeFile as writeSandboxFile, -} from "./sandbox"; +} from "./sandbox-operations"; import { APIError } from "./api-client/api-error"; import { mkdtemp, readFile, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -66,82 +67,155 @@ describe("top-level sandbox helpers", () => { }); it("runs commands by sandbox ID", async () => { - const command = { exitCode: 0 } as any; - const runCommandMock = vi.fn().mockResolvedValue(command); - const sandbox = { runCommand: runCommandMock } as unknown as Sandbox; - const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); - - const result = await runSandboxCommand({ - sandboxId: "sandbox_123", - cmd: "echo", + const command = { + id: "cmd_123", + name: "echo", args: ["hello"], - }); + cwd: "/vercel/sandbox", + sandboxId: "sandbox_123", + startedAt: Date.now(), + exitCode: 0, + }; + const client = { + runCommand: vi.fn().mockResolvedValue({ + command, + finished: Promise.resolve(command), + }), + } as any; - expect(result).toBe(command); - expect(getSpy).toHaveBeenCalledWith({ + const result = await runSandboxCommand( + "sandbox_123", + "echo", + ["hello"], + { client }, + ); + + expect(result.exitCode).toBe(0); + expect(client.runCommand).toHaveBeenCalledWith({ sandboxId: "sandbox_123", - cmd: "echo", + command: "echo", args: ["hello"], + cwd: undefined, + env: {}, + sudo: false, + wait: true, + signal: undefined, }); - expect(runCommandMock).toHaveBeenCalledWith( - expect.objectContaining({ - cmd: "echo", - args: ["hello"], - }), - ); }); it("creates directories by sandbox ID", async () => { - const mkDirMock = vi.fn().mockResolvedValue(undefined); - const sandbox = { mkDir: mkDirMock } as unknown as Sandbox; - vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + const client = { + mkDir: vi.fn().mockResolvedValue(undefined), + } as any; + + await mkdir("sandbox_123", "tmp/nested", { client }); - await mkdir({ + expect(client.mkDir).toHaveBeenCalledWith({ sandboxId: "sandbox_123", path: "tmp/nested", - }); - - expect(mkDirMock).toHaveBeenCalledWith("tmp/nested", { signal: undefined, }); }); it("reads files by sandbox ID", async () => { const stream = Readable.from([Buffer.from("Hello")]); - const readFileMock = vi.fn().mockResolvedValue(stream); - const sandbox = { readFile: readFileMock } as unknown as Sandbox; - vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + const client = { + readFile: vi.fn().mockResolvedValue(stream), + } as any; + + const result = await readSandboxFile( + "sandbox_123", + { path: "hello.txt", cwd: "/tmp" }, + { client }, + ); - const result = await readSandboxFile({ + expect(result).toBe(stream); + expect(client.readFile).toHaveBeenCalledWith({ sandboxId: "sandbox_123", path: "hello.txt", cwd: "/tmp", + signal: undefined, }); - - expect(result).toBe(stream); - expect(readFileMock).toHaveBeenCalledWith( - { path: "hello.txt", cwd: "/tmp" }, - { signal: undefined }, - ); }); it("writes a single file by sandbox ID", async () => { const content = Buffer.from("Hello"); - const writeFilesMock = vi.fn().mockResolvedValue(undefined); - const sandbox = { writeFiles: writeFilesMock } as unknown as Sandbox; - vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + const client = { + getSandbox: vi.fn().mockResolvedValue({ + json: { + sandbox: { cwd: "/vercel/sandbox" }, + routes: [], + }, + }), + writeFiles: vi.fn().mockResolvedValue(undefined), + } as any; + + await writeSandboxFile( + "sandbox_123", + { path: "hello.txt", content }, + { client }, + ); - await writeSandboxFile({ + expect(client.getSandbox).toHaveBeenCalledWith({ sandboxId: "sandbox_123", - path: "hello.txt", - content, + signal: undefined, + }); + expect(client.writeFiles).toHaveBeenCalledWith({ + sandboxId: "sandbox_123", + cwd: "/vercel/sandbox", + extractDir: "/", + files: [{ path: "hello.txt", content }], + signal: undefined, + }); + }); +}); + +describe("Sandbox delegates to standalone helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("delegates runCommand with the instance client", async () => { + const client = {} as any; + const sandbox = new Sandbox({ + client, + routes: [], + sandbox: { id: "sandbox_123" } as any, }); + const command = { exitCode: 0 } as any; + const runCommandSpy = vi + .spyOn(sandboxOperations, "runCommand") + .mockResolvedValue(command); + + const result = await sandbox.runCommand({ cmd: "echo" }); - expect(writeFilesMock).toHaveBeenCalledWith( - [{ path: "hello.txt", content }], - { signal: undefined }, + expect(result).toBe(command); + expect(runCommandSpy).toHaveBeenCalledWith( + "sandbox_123", + { cmd: "echo" }, + { client }, ); }); + + it("delegates writeFiles with the instance client", async () => { + const client = {} as any; + const files = [{ path: "hello.txt", content: Buffer.from("Hello") }]; + const sandbox = new Sandbox({ + client, + routes: [], + sandbox: { id: "sandbox_123" } as any, + }); + const writeFilesSpy = vi + .spyOn(sandboxOperations, "writeFiles") + .mockResolvedValue(undefined); + + await sandbox.writeFiles(files); + + expect(writeFilesSpy).toHaveBeenCalledWith("sandbox_123", files, { + signal: undefined, + client, + }); + }); }); describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Sandbox", () => { diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index e185a45..72b9052 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -1,23 +1,33 @@ import type { SandboxMetaData, SandboxRouteData } from "./api-client"; -import { type Writable } from "stream"; -import { pipeline } from "stream/promises"; -import { createWriteStream } from "fs"; -import { mkdir as mkdirLocal } from "fs/promises"; -import { dirname, resolve } from "path"; import { APIClient } from "./api-client"; -import { Command, CommandFinished } from "./command"; -import { type Credentials, getCredentials } from "./utils/get-credentials"; -import { getPrivateParams, WithPrivate } from "./utils/types"; import { WithFetchOptions } from "./api-client/api-client"; +import { Command, CommandFinished } from "./command"; import { RUNTIMES } from "./constants"; +import { + createSandboxClient, + createSnapshot as createSnapshotBySandboxId, + downloadFile as downloadFileBySandboxId, + extendSandboxTimeout as extendSandboxTimeoutBySandboxId, + getCommand as getCommandBySandboxId, + mkDir as mkDirBySandboxId, + readFile as readFileBySandboxId, + readFileToBuffer as readFileToBufferBySandboxId, + runCommand as runCommandBySandboxId, + type RunCommandParams, + type SandboxAccessOptions, + stopSandbox as stopSandboxBySandboxId, + updateSandboxNetworkPolicy as updateSandboxNetworkPolicyBySandboxId, + writeFiles as writeFilesBySandboxId, +} from "./sandbox-operations"; import { Snapshot } from "./snapshot"; -import { consumeReadable } from "./utils/consume-readable"; +import { type Credentials, getCredentials } from "./utils/get-credentials"; +import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; +import { getPrivateParams, WithPrivate } from "./utils/types"; import { type NetworkPolicy, type NetworkPolicyRule, type NetworkTransformer, } from "./network-policy"; -import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; @@ -115,140 +125,6 @@ interface GetSandboxParams { signal?: AbortSignal; } -interface SandboxAccessOptions extends WithFetchOptions { - token?: string; - teamId?: string; - projectId?: string; -} - -interface SandboxOperationParams extends GetSandboxParams, SandboxAccessOptions {} - -interface GetCommandBySandboxIdParams extends SandboxOperationParams { - cmdId: string; -} - -interface MkDirBySandboxIdParams extends SandboxOperationParams { - path: string; -} - -interface ReadFileBySandboxIdParams extends SandboxOperationParams { - path: string; - cwd?: string; -} - -interface WriteFilesBySandboxIdParams extends SandboxOperationParams { - files: { path: string; content: Buffer }[]; -} - -interface WriteFileBySandboxIdParams extends SandboxOperationParams { - path: string; - content: Buffer; -} - -interface DownloadFileBySandboxIdParams extends SandboxOperationParams { - src: { path: string; cwd?: string }; - dst: { path: string; cwd?: string }; - mkdirRecursive?: boolean; -} - -interface GetSandboxDomainParams extends SandboxOperationParams { - port: number; -} - -interface StopSandboxParams extends SandboxOperationParams { - blocking?: boolean; -} - -interface UpdateSandboxNetworkPolicyParams extends SandboxOperationParams { - networkPolicy: NetworkPolicy; -} - -interface ExtendSandboxTimeoutParams extends SandboxOperationParams { - duration: number; -} - -interface CreateSnapshotBySandboxIdParams extends SandboxOperationParams { - expiration?: number; -} - -function getSandboxAccessCredentials( - params?: SandboxAccessOptions, -): Pick | null { - if ( - typeof params?.token === "string" && - typeof params?.teamId === "string" - ) { - return { - token: params.token, - teamId: params.teamId, - }; - } - - if (params?.token !== undefined || params?.teamId !== undefined) { - const missing = [ - typeof params?.token === "string" ? null : "token", - typeof params?.teamId === "string" ? null : "teamId", - ].filter((value) => value !== null); - - throw new Error( - `Missing credentials parameters to access the Vercel API: ${missing.join(", ")}`, - ); - } - - return null; -} - -async function createSandboxClient(params?: SandboxAccessOptions) { - const credentials = - getSandboxAccessCredentials(params) ?? (await getCredentials(params)); - - return new APIClient({ - teamId: credentials.teamId, - token: credentials.token, - fetch: params?.fetch, - }); -} - -/** @inline */ -interface RunCommandParams { - /** - * The command to execute - */ - cmd: string; - /** - * Arguments to pass to the command - */ - args?: string[]; - /** - * Working directory to execute the command in - */ - cwd?: string; - /** - * Environment variables to set for this command - */ - env?: Record; - /** - * If true, execute this command with root privileges. Defaults to false. - */ - sudo?: boolean; - /** - * If true, the command will return without waiting for `exitCode` - */ - detached?: boolean; - /** - * A `Writable` stream where `stdout` from the command will be piped - */ - stdout?: Writable; - /** - * A `Writable` stream where `stderr` from the command will be piped - */ - stderr?: Writable; - /** - * An AbortSignal to cancel the command execution - */ - signal?: AbortSignal; -} - /** * A Sandbox is an isolated Linux MicroVM to run commands in. * @@ -448,16 +324,9 @@ export class Sandbox { cmdId: string, opts?: { signal?: AbortSignal }, ): Promise { - const command = await this.client.getCommand({ - sandboxId: this.sandbox.id, - cmdId, + return getCommandBySandboxId(this.sandbox.id, cmdId, { signal: opts?.signal, - }); - - return new Command({ client: this.client, - sandboxId: this.sandbox.id, - cmd: command.json.command, }); } @@ -500,88 +369,13 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ): Promise { return typeof commandOrParams === "string" - ? this._runCommand({ cmd: commandOrParams, args, signal: opts?.signal }) - : this._runCommand(commandOrParams); - } - - /** - * Internal helper to start a command in the sandbox. - * - * @param params - Command execution parameters. - * @returns A {@link Command} or {@link CommandFinished}, depending on `detached`. - * @internal - */ - async _runCommand(params: RunCommandParams) { - const wait = params.detached ? false : true; - const getLogs = (command: Command) => { - if (params.stdout || params.stderr) { - (async () => { - try { - for await (const log of command.logs({ signal: params.signal })) { - if (log.stream === "stdout") { - params.stdout?.write(log.data); - } else if (log.stream === "stderr") { - params.stderr?.write(log.data); - } - } - } catch (err) { - if (params.signal?.aborted) { - return; - } - throw err; - } - })(); - } - } - - if (wait) { - const commandStream = await this.client.runCommand({ - sandboxId: this.sandbox.id, - command: params.cmd, - args: params.args ?? [], - cwd: params.cwd, - env: params.env ?? {}, - sudo: params.sudo ?? false, - wait: true, - signal: params.signal, - }); - - const command = new Command({ - client: this.client, - sandboxId: this.sandbox.id, - cmd: commandStream.command, - }); - - getLogs(command); - - const finished = await commandStream.finished; - return new CommandFinished({ - client: this.client, - sandboxId: this.sandbox.id, - cmd: finished, - exitCode: finished.exitCode ?? 0, - }); - } - - const commandResponse = await this.client.runCommand({ - sandboxId: this.sandbox.id, - command: params.cmd, - args: params.args ?? [], - cwd: params.cwd, - env: params.env ?? {}, - sudo: params.sudo ?? false, - signal: params.signal, - }); - - const command = new Command({ - client: this.client, - sandboxId: this.sandbox.id, - cmd: commandResponse.json.command, - }); - - getLogs(command); - - return command; + ? runCommandBySandboxId(this.sandbox.id, commandOrParams, args, { + signal: opts?.signal, + client: this.client, + }) + : runCommandBySandboxId(this.sandbox.id, commandOrParams, { + client: this.client, + }); } /** @@ -592,10 +386,9 @@ export class Sandbox { * @param opts.signal - An AbortSignal to cancel the operation. */ async mkDir(path: string, opts?: { signal?: AbortSignal }): Promise { - await this.client.mkDir({ - sandboxId: this.sandbox.id, - path: path, + await mkDirBySandboxId(this.sandbox.id, path, { signal: opts?.signal, + client: this.client, }); } @@ -611,11 +404,9 @@ export class Sandbox { file: { path: string; cwd?: string }, opts?: { signal?: AbortSignal }, ): Promise { - return this.client.readFile({ - sandboxId: this.sandbox.id, - path: file.path, - cwd: file.cwd, + return readFileBySandboxId(this.sandbox.id, file, { signal: opts?.signal, + client: this.client, }); } @@ -631,18 +422,10 @@ export class Sandbox { file: { path: string; cwd?: string }, opts?: { signal?: AbortSignal }, ): Promise { - const stream = await this.client.readFile({ - sandboxId: this.sandbox.id, - path: file.path, - cwd: file.cwd, + return readFileToBufferBySandboxId(this.sandbox.id, file, { signal: opts?.signal, + client: this.client, }); - - if (stream === null) { - return null; - } - - return consumeReadable(stream); } /** @@ -660,37 +443,11 @@ export class Sandbox { dst: { path: string; cwd?: string }, opts?: { mkdirRecursive?: boolean; signal?: AbortSignal }, ): Promise { - if (!src?.path) { - throw new Error("downloadFile: source path is required"); - } - - if (!dst?.path) { - throw new Error("downloadFile: destination path is required"); - } - - const stream = await this.client.readFile({ - sandboxId: this.sandbox.id, - path: src.path, - cwd: src.cwd, + return downloadFileBySandboxId(this.sandbox.id, src, dst, { + mkdirRecursive: opts?.mkdirRecursive, signal: opts?.signal, + client: this.client, }); - - if (stream === null) { - return null; - } - - try { - const dstPath = resolve(dst.cwd ?? "", dst.path); - if (opts?.mkdirRecursive) { - await mkdirLocal(dirname(dstPath), { recursive: true }); - } - await pipeline(stream, createWriteStream(dstPath), { - signal: opts?.signal, - }); - return dstPath; - } finally { - stream.destroy() - } } /** @@ -706,13 +463,10 @@ export class Sandbox { async writeFiles( files: { path: string; content: Buffer }[], opts?: { signal?: AbortSignal }, - ) { - return this.client.writeFiles({ - sandboxId: this.sandbox.id, - cwd: this.sandbox.cwd, - extractDir: "/", - files: files, + ): Promise { + await writeFilesBySandboxId(this.sandbox.id, files, { signal: opts?.signal, + client: this.client, }); } @@ -741,12 +495,11 @@ export class Sandbox { * @returns The sandbox metadata at the time the stop was acknowledged, or after fully stopped if `blocking` is true. */ async stop(opts?: { signal?: AbortSignal; blocking?: boolean }): Promise { - const response = await this.client.stopSandbox({ - sandboxId: this.sandbox.id, + this.sandbox = await stopSandboxBySandboxId(this.sandbox.id, { signal: opts?.signal, blocking: opts?.blocking, + client: this.client, }); - this.sandbox = convertSandbox(response.json.sandbox); return this.sandbox; } @@ -785,15 +538,21 @@ export class Sandbox { networkPolicy: NetworkPolicy, opts?: { signal?: AbortSignal }, ): Promise { - const response = await this.client.updateNetworkPolicy({ - sandboxId: this.sandbox.id, - networkPolicy: networkPolicy, - signal: opts?.signal, - }); + const updatedNetworkPolicy = await updateSandboxNetworkPolicyBySandboxId( + this.sandbox.id, + networkPolicy, + { + signal: opts?.signal, + client: this.client, + }, + ); - // Update the internal sandbox metadata with the new timeout value - this.sandbox = convertSandbox(response.json.sandbox); - return this.sandbox.networkPolicy!; + this.sandbox = { + ...this.sandbox, + networkPolicy: updatedNetworkPolicy, + }; + + return updatedNetworkPolicy; } /** @@ -816,14 +575,15 @@ export class Sandbox { duration: number, opts?: { signal?: AbortSignal }, ): Promise { - const response = await this.client.extendTimeout({ - sandboxId: this.sandbox.id, - duration, + await extendSandboxTimeoutBySandboxId(this.sandbox.id, duration, { signal: opts?.signal, + client: this.client, }); - // Update the internal sandbox metadata with the new timeout value - this.sandbox = convertSandbox(response.json.sandbox); + this.sandbox = { + ...this.sandbox, + timeout: this.sandbox.timeout + duration, + }; } /** @@ -841,18 +601,19 @@ export class Sandbox { expiration?: number; signal?: AbortSignal; }): Promise { - const response = await this.client.createSnapshot({ - sandboxId: this.sandbox.id, + const snapshot = await createSnapshotBySandboxId(this.sandbox.id, { expiration: opts?.expiration, signal: opts?.signal, + client: this.client, }); - this.sandbox = convertSandbox(response.json.sandbox); - - return new Snapshot({ - client: this.client, - snapshot: response.json.snapshot, + const refreshedSandbox = await this.client.getSandbox({ + sandboxId: this.sandbox.id, + signal: opts?.signal, }); + this.sandbox = convertSandbox(refreshedSandbox.json.sandbox); + + return snapshot; } } @@ -871,192 +632,3 @@ class DisposableSandbox extends Sandbox implements AsyncDisposable { } } -async function loadSandbox(params: WithPrivate) { - return Sandbox.get(params); -} - -/** - * Retrieve a previously run command from an existing sandbox by ID. - */ -export async function getCommand( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.getCommand(params.cmdId, { signal: params.signal }); -} - -/** - * Start executing a command in an existing sandbox by ID. - */ -export async function runCommand( - params: WithPrivate, -): Promise; -export async function runCommand( - params: WithPrivate, -): Promise; -export async function runCommand( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.runCommand({ - cmd: params.cmd, - args: params.args, - cwd: params.cwd, - env: params.env, - sudo: params.sudo, - detached: params.detached, - stdout: params.stdout, - stderr: params.stderr, - signal: params.signal, - }); -} - -/** - * Create a directory in an existing sandbox by ID. - */ -export async function mkDir( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - await sandbox.mkDir(params.path, { - signal: params.signal, - }); -} - -/** - * Alias for {@link mkDir}. - */ -export async function mkdir( - params: WithPrivate, -): Promise { - await mkDir(params); -} - -/** - * Read a file from an existing sandbox by ID as a stream. - */ -export async function readFile( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.readFile( - { - path: params.path, - cwd: params.cwd, - }, - { signal: params.signal }, - ); -} - -/** - * Read a file from an existing sandbox by ID as a buffer. - */ -export async function readFileToBuffer( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.readFileToBuffer( - { - path: params.path, - cwd: params.cwd, - }, - { signal: params.signal }, - ); -} - -/** - * Download a file from an existing sandbox by ID to the local filesystem. - */ -export async function downloadFile( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.downloadFile(params.src, params.dst, { - mkdirRecursive: params.mkdirRecursive, - signal: params.signal, - }); -} - -/** - * Write multiple files to an existing sandbox by ID. - */ -export async function writeFiles( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - await sandbox.writeFiles(params.files, { - signal: params.signal, - }); -} - -/** - * Write a single file to an existing sandbox by ID. - */ -export async function writeFile( - params: WithPrivate, -): Promise { - await writeFiles({ - ...params, - files: [{ path: params.path, content: params.content }], - }); -} - -/** - * Resolve the public domain for a port exposed by an existing sandbox. - */ -export async function getSandboxDomain( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.domain(params.port); -} - -/** - * Stop an existing sandbox by ID. - */ -export async function stopSandbox( - params: WithPrivate, -) { - const sandbox = await loadSandbox(params); - return sandbox.stop({ - signal: params.signal, - blocking: params.blocking, - }); -} - -/** - * Update the network policy for an existing sandbox by ID. - */ -export async function updateSandboxNetworkPolicy( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.updateNetworkPolicy(params.networkPolicy, { - signal: params.signal, - }); -} - -/** - * Extend the timeout of an existing sandbox by ID. - */ -export async function extendSandboxTimeout( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - await sandbox.extendTimeout(params.duration, { - signal: params.signal, - }); -} - -/** - * Create a snapshot from an existing sandbox by ID. - */ -export async function createSnapshot( - params: WithPrivate, -): Promise { - const sandbox = await loadSandbox(params); - return sandbox.snapshot({ - expiration: params.expiration, - signal: params.signal, - }); -}