From a09e29215b27101223ca977c5522d4d62d3fcb3a Mon Sep 17 00:00:00 2001 From: Andy <76787794+AndyW22@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:35:56 +0000 Subject: [PATCH 01/41] feat(sdk): add name + snapshotOnShutdown params (#67) Add the `name` and `snapshotOnShutdown` params which we forward to the new `v1/sandboxes/named` endpoint. This sets up basic testing of our endpoint. Merging to the `named-sandboxes` development branch --- .../src/api-client/api-client.ts | 6 +++++- packages/vercel-sandbox/src/sandbox.ts | 21 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index aeb02302..21d5819c 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -150,6 +150,7 @@ export class APIClient extends BaseClient { async createSandbox( params: WithPrivate<{ + name?: string; ports?: number[]; projectId: string; source?: @@ -165,6 +166,7 @@ export class APIClient extends BaseClient { | { type: "snapshot"; snapshotId: string }; timeout?: number; resources?: { vcpus: number }; + snapshotOnShutdown?: boolean; runtime?: RUNTIMES | (string & {}); networkPolicy?: NetworkPolicy; env?: Record; @@ -174,7 +176,7 @@ export class APIClient extends BaseClient { const privateParams = getPrivateParams(params); return parseOrThrow( SandboxAndRoutesResponse, - await this.request("/v1/sandboxes", { + await this.request("/v1/sandboxes/named", { method: "POST", body: JSON.stringify({ projectId: params.projectId, @@ -183,6 +185,8 @@ export class APIClient extends BaseClient { timeout: params.timeout, resources: params.resources, runtime: params.runtime, + name: params.name, + snapshotOnShutdown: params.snapshotOnShutdown, networkPolicy: params.networkPolicy ? toAPINetworkPolicy(params.networkPolicy) : undefined, diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index c93c96aa..09d8fcd6 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -13,9 +13,9 @@ import { RUNTIMES } from "./constants"; import { Snapshot } from "./snapshot"; import { consumeReadable } from "./utils/consume-readable"; import { - type NetworkPolicy, - type NetworkPolicyRule, - type NetworkTransformer, + type NetworkPolicy, + type NetworkPolicyRule, + type NetworkTransformer, } from "./network-policy"; import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; @@ -23,6 +23,10 @@ export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; /** @inline */ export interface BaseCreateSandboxParams { + /** + * The name of the sandbox. If omitted, a random name will be generated. + */ + name?: string; /** * The source of the sandbox. * @@ -95,6 +99,11 @@ export interface BaseCreateSandboxParams { * An AbortSignal to cancel sandbox creation. */ signal?: AbortSignal; + + /** + * Whether to enable snapshots on shutdown. Defaults to true. + */ + snapshotOnShutdown?: boolean; } export type CreateSandboxParams = @@ -293,6 +302,8 @@ export class Sandbox { networkPolicy: params?.networkPolicy, env: params?.env, signal: params?.signal, + name: params?.name, + snapshotOnShutdown: params?.snapshotOnShutdown, ...privateParams, }); @@ -444,7 +455,7 @@ export class Sandbox { } })(); } - } + }; if (wait) { const commandStream = await this.client.runCommand({ @@ -601,7 +612,7 @@ export class Sandbox { }); return dstPath; } finally { - stream.destroy() + stream.destroy(); } } From 7407ec9ec419144ae49b0eb2704cb5cf2267b7f3 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Tue, 3 Mar 2026 17:35:58 +0100 Subject: [PATCH 02/41] feat(vercel-sandbox): support named sandboxes and sessions (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important --- - This is PR contains the `changeset` required to ship a pre-release (`beta`) package with a breaking change. Merging the PR will not automatically create the pre-release tag. - This PR will be merged to `named-sandboxes`, not the main branch. - All the changes from `sandbox` package are to make it compile. We are not yet supporting these changes in the CLI; this PR is only about the `@vercel/sandbox` package. Breaking Changes --- **1:** `Sandbox.get()` - parameter renamed and semantics changed. - Before: `Sandbox.get({ sandboxId: string })` - After: `Sandbox.get({ name: string, resume?: boolean }`. The sandbox will also be automatically resumed if it was stopped. **2:** `Sandbox.list()` - the return items have changed. This method returns the list of sandboxes, and we are missing the following fields: - `id` (we use `name` instead) - `requestedAt`: the sandbox does not request a session. - `status`: it is specific from the session. (WIP - Working on providing this one) **3:** `Sandbox.list()` - the pagination has changed. We are using a cursor based token instead. - Before, the pagination returned: `{ count: number, next: number | null, prev: number | null }` - After, the pagination returns: `{ count: number, next: string | null, total: number }` **4:** Auto-resume any operation. - Before: If a sandbox session was stopped, all operations threw an error. - After: If the session is stopped/stopping, operations silently create a new session and retry. **5:** Timeout / NetworkPolicy are not from the session Before, the Timeout / NetworkPolicy parameters where from the session, and were automatically updated when the methods `extendTimeout()` or `updateNetworkPolicy()` were called. Now, the returned ones are from the NamedSandbox (base values to create a sandbox), and do not reflect the updated values for that session. **6:** Create - semantics changed - Before: `Sandbox.create()` - After: `Sandbox.create({ name: string, snapshotOnShutdown?: boolean }`. The sandbox will also be automatically persistent by default. Non-breaking changes --- ### New methods on Sandbox - currentSession() — returns the underlying Session instance. - update(params) — PATCH the named sandbox config (resources, runtime, timeout, networkPolicy). - delete(opts?) — delete the named sandbox (with optional preserveSnapshots). - listSessions(params?) — list all sessions created for this named sandbox. - listSnapshots(params?) — list all snapshots belonging to this named sandbox. ### New getters on Sandbox - name, snapshotOnShutdown, region, vcpus, memory, runtime - Aggregate usage metrics: totalEgressBytes, totalIngressBytes, totalActiveCpuDurationMs, totalDurationMs - updatedAt ### New query filters - listSandboxes() now accepts an optional name param to filter by named sandbox. - listSnapshots() now accepts an optional name param to filter by named sandbox. --------- Co-authored-by: Tom Lienard --- .changeset/fair-pears-dream.md | 5 + .changeset/pre.json | 18 + examples/ai-example/app/actions.tsx | 6 +- examples/ai-example/app/api/logs/route.ts | 2 +- packages/sandbox/src/commands/config.ts | 6 +- packages/sandbox/src/commands/cp.ts | 4 +- packages/sandbox/src/commands/create.ts | 2 +- packages/sandbox/src/commands/exec.ts | 4 +- packages/sandbox/src/commands/list.ts | 10 +- packages/sandbox/src/commands/snapshot.ts | 4 +- packages/sandbox/src/commands/stop.ts | 2 +- .../interactive-shell/interactive-shell.ts | 2 +- .../src/api-client/api-client.test.ts | 287 ++++++++ .../src/api-client/api-client.ts | 137 +++- .../src/api-client/validators.ts | 43 ++ packages/vercel-sandbox/src/index.ts | 7 +- packages/vercel-sandbox/src/sandbox.test.ts | 90 ++- packages/vercel-sandbox/src/sandbox.ts | 639 +++++++++-------- packages/vercel-sandbox/src/session.ts | 665 ++++++++++++++++++ 19 files changed, 1612 insertions(+), 321 deletions(-) create mode 100644 .changeset/fair-pears-dream.md create mode 100644 .changeset/pre.json create mode 100644 packages/vercel-sandbox/src/session.ts diff --git a/.changeset/fair-pears-dream.md b/.changeset/fair-pears-dream.md new file mode 100644 index 00000000..5f17c6ab --- /dev/null +++ b/.changeset/fair-pears-dream.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": major +--- + +Introduce named and long-lived sandboxes diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..e15644f5 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,18 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "ai-example": "0.1.1", + "charts-python-example": "0.1.4", + "dev-server-example": "0.1.4", + "sandbox-filesystem-snapshots": "0.0.8", + "install-packages-example": "0.1.4", + "private-repo-example": "0.1.4", + "sandbox-basics-example": "0.1.4", + "@vercel/pty-tunnel": "2.0.3", + "@vercel/pty-tunnel-server": "0.0.2", + "sandbox": "2.5.3", + "@vercel/sandbox": "1.7.1" + }, + "changesets": [] +} diff --git a/examples/ai-example/app/actions.tsx b/examples/ai-example/app/actions.tsx index 02342cd8..801686c1 100644 --- a/examples/ai-example/app/actions.tsx +++ b/examples/ai-example/app/actions.tsx @@ -10,7 +10,7 @@ export async function createSandbox() { }); return { - id: sandbox.sandboxId, + id: sandbox.name, routes: sandbox.routes, url: sandbox.domain(3000), }; @@ -21,7 +21,7 @@ export async function uploadFiles(params: { files: { path: string; content: string }[]; }) { const sandbox = await Sandbox.get({ - sandboxId: params.sandboxId, + name: params.sandboxId, }); const files = params.files.map((file) => ({ @@ -60,7 +60,7 @@ export async function runCommand(params: { detached?: boolean; }) { const sandbox = await Sandbox.get({ - sandboxId: params.sandboxId, + name: params.sandboxId, }); const cmd = await sandbox.runCommand({ diff --git a/examples/ai-example/app/api/logs/route.ts b/examples/ai-example/app/api/logs/route.ts index fba965d9..388ba59c 100644 --- a/examples/ai-example/app/api/logs/route.ts +++ b/examples/ai-example/app/api/logs/route.ts @@ -14,7 +14,7 @@ export async function POST(request: Request) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { - const sandbox = await Sandbox.get({ sandboxId }); + const sandbox = await Sandbox.get({ name: sandboxId }); const command = await sandbox.getCommand(cmdId); for await (const log of command.logs()) { diff --git a/packages/sandbox/src/commands/config.ts b/packages/sandbox/src/commands/config.ts index e048ba50..737ff9a2 100644 --- a/packages/sandbox/src/commands/config.ts +++ b/packages/sandbox/src/commands/config.ts @@ -58,7 +58,7 @@ const networkPolicyCommand = cmd.command({ typeof sandboxId !== "string" ? sandboxId : await sandboxClient.get({ - sandboxId, + name: sandboxId, projectId: project, teamId: team, token, @@ -67,7 +67,7 @@ const networkPolicyCommand = cmd.command({ if (!["pending", "running"].includes(sandbox.status)) { console.error( [ - `Sandbox ${sandbox.sandboxId} is not available (status: ${sandbox.status}).`, + `Sandbox ${sandbox.name} is not available (status: ${sandbox.status}).`, `${chalk.bold("hint:")} Only 'pending' or 'running' sandboxes can execute commands.`, "├▶ Use `sandbox list` to check sandbox status.", "╰▶ Use `sandbox create` to create a new sandbox.", @@ -84,7 +84,7 @@ const networkPolicyCommand = cmd.command({ process.stderr.write( "✅ Network policy updated for sandbox " + - chalk.cyan(sandbox.sandboxId) + + chalk.cyan(sandbox.name) + "\n", ); const mode = typeof response === "string" ? response : "restricted"; diff --git a/packages/sandbox/src/commands/cp.ts b/packages/sandbox/src/commands/cp.ts index 4aea324b..65d02419 100644 --- a/packages/sandbox/src/commands/cp.ts +++ b/packages/sandbox/src/commands/cp.ts @@ -63,7 +63,7 @@ export const cp = cmd.command({ }) } else { const sandbox = await sandboxClient.get({ - sandboxId: source.sandboxId, + name: source.sandboxId, teamId: scope.team, token: scope.token, projectId: scope.project, @@ -95,7 +95,7 @@ export const cp = cmd.command({ await fs.writeFile(dest.path, sourceFile); } else { const sandbox = await sandboxClient.get({ - sandboxId: dest.sandboxId, + name: dest.sandboxId, teamId: scope.team, projectId: scope.project, token: scope.token, diff --git a/packages/sandbox/src/commands/create.ts b/packages/sandbox/src/commands/create.ts index 31d5bf4b..2f6bb83e 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -145,7 +145,7 @@ export const create = cmd.command({ const hasPorts = routes.length > 0; process.stderr.write("✅ Sandbox "); - process.stdout.write(chalk.cyan(sandbox.sandboxId)); + process.stdout.write(chalk.cyan(sandbox.name)); process.stderr.write(" created.\n"); process.stderr.write( chalk.dim(" │ ") + "team: " + chalk.cyan(teamDisplay) + "\n", diff --git a/packages/sandbox/src/commands/exec.ts b/packages/sandbox/src/commands/exec.ts index a08ca5f3..fee4381e 100644 --- a/packages/sandbox/src/commands/exec.ts +++ b/packages/sandbox/src/commands/exec.ts @@ -90,7 +90,7 @@ export const exec = cmd.command({ typeof sandboxId !== "string" ? sandboxId : await sandboxClient.get({ - sandboxId, + name: sandboxId, projectId: project, teamId: team, token, @@ -100,7 +100,7 @@ export const exec = cmd.command({ if (!["pending", "running"].includes(sandbox.status)) { console.error( [ - `Sandbox ${sandbox.sandboxId} is not available (status: ${sandbox.status}).`, + `Sandbox ${sandbox.name} is not available (status: ${sandbox.status}).`, `${chalk.bold("hint:")} Only 'pending' or 'running' sandboxes can execute commands.`, "├▶ Use `sandbox list` to check sandbox status.", "╰▶ Use `sandbox create` to create a new sandbox.", diff --git a/packages/sandbox/src/commands/list.ts b/packages/sandbox/src/commands/list.ts index 43945dcc..f62a1bc6 100644 --- a/packages/sandbox/src/commands/list.ts +++ b/packages/sandbox/src/commands/list.ts @@ -51,7 +51,7 @@ export const list = cmd.command({ type Column = { value: (s: SandboxRow) => string | number; color?: (s: SandboxRow) => ChalkInstance }; const columns: Record = { - ID: { value: (s) => s.id }, + ID: { value: (s) => s.name }, STATUS: { value: (s) => s.status, color: (s) => SandboxStatusColor[s.status] ?? chalk.reset, @@ -65,13 +65,13 @@ export const list = cmd.command({ TIMEOUT: { value: (s) => timeAgo(s.createdAt + s.timeout), }, - SNAPSHOT: { value: (s) => s.sourceSnapshotId ?? "-" } + SNAPSHOT: { value: (s) => s.currentSnapshotId ?? "-" } }; if (all) { - columns.CPU = { value: (s) => s.activeCpuDurationMs ? formatRunDuration(s.activeCpuDurationMs) : "-" }; + columns.CPU = { value: (s) => s.totalActiveCpuDurationMs ? formatRunDuration(s.totalActiveCpuDurationMs) : "-" }; columns["NETWORK (OUT/IN)"] = { - value: (s) => s.networkTransfer ? - `${formatBytes(s.networkTransfer.egress)} / ${formatBytes(s.networkTransfer.ingress)}` : "- / -", + value: (s) => (s.totalEgressBytes || s.totalIngressBytes) ? + `${formatBytes(s.totalEgressBytes ?? 0)} / ${formatBytes(s.totalIngressBytes ?? 0)}` : "- / -", }; } diff --git a/packages/sandbox/src/commands/snapshot.ts b/packages/sandbox/src/commands/snapshot.ts index 32d32830..38f72087 100644 --- a/packages/sandbox/src/commands/snapshot.ts +++ b/packages/sandbox/src/commands/snapshot.ts @@ -54,7 +54,7 @@ export const snapshot = cmd.command({ typeof sandboxId !== "string" ? sandboxId : await sandboxClient.get({ - sandboxId, + name: sandboxId, projectId: project, teamId: team, token, @@ -63,7 +63,7 @@ export const snapshot = cmd.command({ if (!["running"].includes(sandbox.status)) { console.error( [ - `Sandbox ${sandbox.sandboxId} is not available (status: ${sandbox.status}).`, + `Sandbox ${sandbox.name} is not available (status: ${sandbox.status}).`, `${chalk.bold("hint:")} Only 'running' sandboxes can be snapshotted.`, "├▶ Use `sandbox list` to check sandbox status.", "╰▶ Use `sandbox create` to create a new sandbox.", diff --git a/packages/sandbox/src/commands/stop.ts b/packages/sandbox/src/commands/stop.ts index 6e340ba8..48c6c9f3 100644 --- a/packages/sandbox/src/commands/stop.ts +++ b/packages/sandbox/src/commands/stop.ts @@ -30,7 +30,7 @@ export const stop = cmd.command({ token, teamId: team, projectId: project, - sandboxId, + name: sandboxId, }); await sandbox.stop(); }, diff --git a/packages/sandbox/src/interactive-shell/interactive-shell.ts b/packages/sandbox/src/interactive-shell/interactive-shell.ts index d6c86fa2..617e88de 100644 --- a/packages/sandbox/src/interactive-shell/interactive-shell.ts +++ b/packages/sandbox/src/interactive-shell/interactive-shell.ts @@ -319,7 +319,7 @@ async function attach({ client.close(); console.error( - chalk.dim(`\n╰▶ connection to ▲ ${sandbox.sandboxId} closed.`), + chalk.dim(`\n╰▶ connection to ▲ ${sandbox.name} closed.`), ); } diff --git a/packages/vercel-sandbox/src/api-client/api-client.test.ts b/packages/vercel-sandbox/src/api-client/api-client.test.ts index 8b66d706..3edfd499 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.test.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.test.ts @@ -412,6 +412,293 @@ describe("APIClient", () => { }); }); + describe("getNamedSandbox", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeNamedSandbox = () => ({ + name: "my-sandbox", + snapshotOnShutdown: true, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSandboxId: "sbx_123", + }); + + const makeSandbox = () => ({ + id: "sbx_123", + memory: 2048, + vcpus: 1, + region: "iad1", + runtime: "node24", + timeout: 300000, + status: "running", + requestedAt: Date.now(), + createdAt: Date.now(), + cwd: "/", + updatedAt: Date.now(), + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("fetches a named sandbox by name and projectId", async () => { + const body = { + namedSandbox: makeNamedSandbox(), + sandbox: makeSandbox(), + routes: [{ url: "https://example.com", subdomain: "sbx", port: 3000 }], + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.getNamedSandbox({ + name: "my-sandbox", + projectId: "proj_123", + }); + + expect(result.json.namedSandbox.name).toBe("my-sandbox"); + expect(result.json.sandbox.id).toBe("sbx_123"); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("/v1/sandboxes/named/my-sandbox"); + expect(url).toContain("projectId=proj_123"); + }); + + it("passes resume query param when provided", async () => { + const body = { + namedSandbox: makeNamedSandbox(), + sandbox: makeSandbox(), + routes: [], + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.getNamedSandbox({ + name: "my-sandbox", + projectId: "proj_123", + resume: true, + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("resume=true"); + }); + }); + + describe("listNamedSandboxes", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeNamedSandbox = (name: string) => ({ + name, + snapshotOnShutdown: false, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSandboxId: "sbx_123", + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("lists named sandboxes with pagination", async () => { + const body = { + namedSandboxes: [makeNamedSandbox("sb-1"), makeNamedSandbox("sb-2")], + pagination: { count: 2, next: null, total: 2 }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.listNamedSandboxes({ + projectId: "proj_123", + }); + + expect(result.json.sandboxes).toHaveLength(2); + expect(result.json.sandboxes[0].name).toBe("sb-1"); + expect(result.json.pagination.total).toBe(2); + }); + + it("passes all query params", async () => { + const body = { + namedSandboxes: [], + pagination: { count: 0, next: null, total: 0 }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.listNamedSandboxes({ + projectId: "proj_123", + limit: 5, + sortBy: "name", + namePrefix: "test-", + cursor: "abc", + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("project=proj_123"); + expect(url).toContain("limit=5"); + expect(url).toContain("sortBy=name"); + expect(url).toContain("namePrefix=test-"); + expect(url).toContain("cursor=abc"); + }); + }); + + describe("updateNamedSandbox", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeNamedSandbox = () => ({ + name: "my-sandbox", + snapshotOnShutdown: true, + region: "iad1", + vcpus: 2, + memory: 4096, + runtime: "node24", + timeout: 600000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSandboxId: "sbx_123", + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("sends PATCH with update fields", async () => { + const body = { namedSandbox: makeNamedSandbox() }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.updateNamedSandbox({ + name: "my-sandbox", + projectId: "proj_123", + snapshotOnShutdown: true, + timeout: 600000, + }); + + expect(result.json.namedSandbox.name).toBe("my-sandbox"); + + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toContain("/v1/sandboxes/named/my-sandbox"); + expect(url).toContain("projectId=proj_123"); + expect(opts.method).toBe("PATCH"); + + const parsedBody = JSON.parse(opts.body); + expect(parsedBody.snapshotOnShutdown).toBe(true); + expect(parsedBody.timeout).toBe(600000); + }); + }); + + describe("deleteNamedSandbox", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeNamedSandbox = () => ({ + name: "my-sandbox", + snapshotOnShutdown: false, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSandboxId: "sbx_123", + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("sends DELETE with projectId and preserveSandboxes=false", async () => { + const body = { namedSandbox: makeNamedSandbox() }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.deleteNamedSandbox({ + name: "my-sandbox", + projectId: "proj_123", + }); + + expect(result.json.namedSandbox.name).toBe("my-sandbox"); + + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toContain("/v1/sandboxes/named/my-sandbox"); + expect(url).toContain("projectId=proj_123"); + expect(url).toContain("preserveSandboxes=false"); + expect(opts.method).toBe("DELETE"); + }); + + it("passes preserveSnapshots when provided", async () => { + const body = { namedSandbox: makeNamedSandbox() }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.deleteNamedSandbox({ + name: "my-sandbox", + projectId: "proj_123", + preserveSnapshots: true, + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("preserveSnapshots=true"); + }); + }); + describe("createSnapshot", () => { let client: APIClient; let mockFetch: ReturnType; diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index 21d5819c..ae445b2a 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -5,21 +5,24 @@ import { type RequestParams, } from "./base-client"; import { -CommandFinishedData, +type CommandFinishedData, SandboxAndRoutesResponse, SandboxResponse, CommandResponse, CommandFinishedResponse, EmptyResponse, LogLine, - LogLineStdout, - LogLineStderr, + type LogLineStdout, + type LogLineStderr, SandboxesResponse, SnapshotsResponse, ExtendTimeoutResponse, UpdateNetworkPolicyResponse, SnapshotResponse, CreateSnapshotResponse, + NamedSandboxAndSessionResponse, + NamedSandboxesPaginationResponse, + UpdateNamedSandboxResponse, type CommandData, } from "./validators"; import { APIError, StreamError } from "./api-error"; @@ -32,10 +35,10 @@ import os from "os"; import { Readable } from "stream"; import { normalizePath } from "../utils/normalizePath"; import { getVercelOidcToken } from "@vercel/oidc"; -import { NetworkPolicy } from "../network-policy"; -import { toAPINetworkPolicy, fromAPINetworkPolicy } from "../utils/network-policy"; -import { getPrivateParams, WithPrivate } from "../utils/types"; -import { RUNTIMES } from "../constants"; +import type { NetworkPolicy } from "../network-policy"; +import { toAPINetworkPolicy } from "../utils/network-policy"; +import { getPrivateParams, type WithPrivate } from "../utils/types"; +import type { RUNTIMES } from "../constants"; import { setTimeout } from "node:timers/promises"; interface Claims { @@ -175,7 +178,7 @@ export class APIClient extends BaseClient { ) { const privateParams = getPrivateParams(params); return parseOrThrow( - SandboxAndRoutesResponse, + NamedSandboxAndSessionResponse, await this.request("/v1/sandboxes/named", { method: "POST", body: JSON.stringify({ @@ -270,7 +273,7 @@ export class APIClient extends BaseClient { const iterator = jsonlinesStream[Symbol.asyncIterator](); const commandChunk = await iterator.next(); - const { command } = CommandResponse.parse(commandChunk.value); + const { command } = CommandResponse.parse(commandChunk.value); const finished = (async () => { const finishedChunk = await iterator.next(); @@ -376,6 +379,10 @@ export class APIClient extends BaseClient { * @example "my-project" */ projectId: string; + /** + * Filter sandboxes by named sandbox name. + */ + name?: string; /** * Maximum number of sandboxes to list from a request. * @example 10 @@ -398,6 +405,7 @@ export class APIClient extends BaseClient { await this.request(`/v1/sandboxes`, { query: { project: params.projectId, + name: params.name, limit: params.limit, since: typeof params.since === "number" @@ -420,6 +428,10 @@ export class APIClient extends BaseClient { * @example "my-project" */ projectId: string; + /** + * Filter snapshots by named sandbox name. + */ + name?: string; /** * Maximum number of snapshots to list from a request. * @example 10 @@ -442,6 +454,7 @@ export class APIClient extends BaseClient { await this.request(`/v1/sandboxes/snapshots`, { query: { project: params.projectId, + name: params.name, limit: params.limit, since: typeof params.since === "number" @@ -698,6 +711,112 @@ export class APIClient extends BaseClient { await this.request(url, { signal: params.signal }), ); } + + async getNamedSandbox(params: { + name: string; + projectId: string; + resume?: boolean; + signal?: AbortSignal; + }) { + const query: Record = { + projectId: params.projectId, + }; + if (params.resume !== undefined) { + query.resume = String(params.resume); + } + return parseOrThrow( + NamedSandboxAndSessionResponse, + await this.request(`/v1/sandboxes/named/${encodeURIComponent(params.name)}`, { + query, + signal: params.signal, + }), + ); + } + + async listNamedSandboxes(params: { + projectId: string; + limit?: number; + sortBy?: "createdAt" | "name"; + namePrefix?: string; + cursor?: string; + signal?: AbortSignal; + }) { + const result = await parseOrThrow( + NamedSandboxesPaginationResponse, + await this.request(`/v1/sandboxes/named`, { + query: { + project: params.projectId, + limit: params.limit, + sortBy: params.sortBy, + namePrefix: params.namePrefix, + cursor: params.cursor, + }, + method: "GET", + signal: params.signal, + }), + ); + + return { + ...result, + json: { + sandboxes: result.json.namedSandboxes, + pagination: result.json.pagination, + }, + }; + } + + async updateNamedSandbox(params: { + name: string; + projectId: string; + snapshotOnShutdown?: boolean; + resources?: { vcpus?: number; memory?: number }; + runtime?: RUNTIMES | (string & {}); + timeout?: number; + networkPolicy?: NetworkPolicy; + signal?: AbortSignal; + }) { + return parseOrThrow( + UpdateNamedSandboxResponse, + await this.request(`/v1/sandboxes/named/${encodeURIComponent(params.name)}`, { + method: "PATCH", + query: { + projectId: params.projectId, + }, + body: JSON.stringify({ + snapshotOnShutdown: params.snapshotOnShutdown, + resources: params.resources, + runtime: params.runtime, + timeout: params.timeout, + networkPolicy: params.networkPolicy + ? toAPINetworkPolicy(params.networkPolicy) + : undefined, + }), + signal: params.signal, + }), + ); + } + + async deleteNamedSandbox(params: { + name: string; + projectId: string; + preserveSnapshots?: boolean; + signal?: AbortSignal; + }) { + return parseOrThrow( + UpdateNamedSandboxResponse, + await this.request(`/v1/sandboxes/named/${encodeURIComponent(params.name)}`, { + method: "DELETE", + query: { + projectId: params.projectId, + preserveSandboxes: "false", + preserveSnapshots: params.preserveSnapshots !== undefined + ? String(params.preserveSnapshots) + : undefined, + }, + signal: params.signal, + }), + ); + } } async function pipe( diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index ec4173b4..5600f8e7 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -183,3 +183,46 @@ export const CreateSnapshotResponse = z.object({ export const SnapshotResponse = z.object({ snapshot: Snapshot, }); + +export const NamedSandbox = z.object({ + name: z.string(), + snapshotOnShutdown: z.boolean(), + region: z.string(), + vcpus: z.number(), + memory: z.number(), + runtime: z.string(), + timeout: z.number(), + networkPolicy: NetworkPolicyValidator.optional(), + totalEgressBytes: z.number().optional(), + totalIngressBytes: z.number().optional(), + totalActiveCpuDurationMs: z.number().optional(), + totalDurationMs: z.number().optional(), + createdAt: z.number(), + updatedAt: z.number(), + currentSandboxId: z.string(), + currentSnapshotId: z.string().optional(), + status: Sandbox.shape.status, +}); + +export type NamedSandboxMetaData = z.infer; + +export const NamedSandboxAndSessionResponse = z.object({ + namedSandbox: NamedSandbox, + sandbox: Sandbox, + routes: z.array(SandboxRoute), +}); + +export const CursorPagination = z.object({ + count: z.number(), + next: z.string().nullable(), + total: z.number(), +}); + +export const NamedSandboxesPaginationResponse = z.object({ + namedSandboxes: z.array(NamedSandbox), + pagination: CursorPagination, +}); + +export const UpdateNamedSandboxResponse = z.object({ + namedSandbox: NamedSandbox, +}); diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 5540c76e..5a398c98 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -1,9 +1,12 @@ export { - Sandbox, type NetworkPolicy, + Sandbox, +} from "./sandbox"; +export { + Session, type NetworkPolicyRule, type NetworkTransformer, -} from "./sandbox"; +} from "./session"; export { Snapshot } from "./snapshot"; export { Command, CommandFinished } from "./command"; export { StreamError } from "./api-client/api-error"; diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 6c796c9d..ebb38271 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -12,7 +12,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + namedSandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile(undefined as any, { path: "/tmp/out" }), @@ -23,7 +25,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + namedSandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile({ path: "" }, { path: "/tmp/out" }), @@ -34,7 +38,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + namedSandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile({ path: "file.txt" }, undefined as any), @@ -45,7 +51,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + namedSandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile({ path: "file.txt" }, { path: "" }), @@ -193,11 +201,19 @@ for (const port of ports) { }); it("allows extending the sandbox timeout", async () => { - const originalTimeout = sandbox.timeout; + const session = sandbox.currentSession(); + const originalTimeout = session.timeout; const extensionDuration = ms("5m"); await sandbox.extendTimeout(extensionDuration); - expect(sandbox.timeout).toEqual(originalTimeout + extensionDuration); + expect(session.timeout).toEqual(originalTimeout + extensionDuration); + }); + + it("auto-resumes a stopped session when running a command", async () => { + await sandbox.stop({ blocking: true }); + const result = await sandbox.runCommand("echo", ["resumed!"]); + expect(result.exitCode).toBe(0); + expect(await result.stdout()).toContain("resumed!"); }); it("raises an error when the timeout cannot be updated", async () => { @@ -214,4 +230,64 @@ for (const port of ports) { }); } }); -}); + + it("returns not found when getting a deleted sandbox", async () => { + const sandbox = await Sandbox.create(); + const name = sandbox.name; + await sandbox.delete(); + + try { + await Sandbox.get({ name }); + expect.fail("Expected Sandbox.get to throw an error"); + } catch (error) { + expect(error).toBeInstanceOf(APIError); + expect(error).toMatchObject({ + response: { status: 404 }, + }); + } + }); + + it("lists two sessions after stop and resume", async () => { + const sandbox = await Sandbox.create(); + await sandbox.stop({ blocking: true }); + + const resumed = await Sandbox.get({ name: sandbox.name }); + const { json } = await resumed.listSessions(); + + expect(json.sandboxes).toHaveLength(2); + + const currentSessionId = resumed.currentSession().sessionId; + const match = json.sandboxes.find((s) => s.id === currentSessionId); + expect(match).toBeDefined(); + }); + + it("lists one snapshot after creating one", async () => { + const sandbox = await Sandbox.create(); + await sandbox.snapshot(); + + const { json } = await sandbox.listSnapshots(); + expect(json.snapshots).toHaveLength(1); + }); + + it("reflects updated resources after update", async () => { + const sandbox = await Sandbox.create(); + await sandbox.stop({ blocking: true }); + + await sandbox.update({ resources: { vcpus: 4, memory: 8192 } }); + + const updated = await Sandbox.get({ + name: sandbox.name, + resume: false, + }); + expect(updated.vcpus).toBe(4); + expect(updated.memory).toBe(8192); + }); + + it("appears in the sandbox list after creation", async () => { + const sandbox = await Sandbox.create(); + await sandbox.stop(); + const { json } = await Sandbox.list({ limit: 1 }); + expect(json.sandboxes).toHaveLength(1); + expect(json.sandboxes[0].name).toBe(sandbox.name); + }); +}); \ No newline at end of file diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 09d8fcd6..60f4ee85 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -1,25 +1,21 @@ -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 { dirname, resolve } from "path"; +import type { SandboxMetaData, SandboxRouteData, NamedSandboxMetaData } from "./api-client"; import { APIClient } from "./api-client"; -import { Command, CommandFinished } from "./command"; +import { APIError } from "./api-client/api-error"; import { type Credentials, getCredentials } from "./utils/get-credentials"; -import { getPrivateParams, WithPrivate } from "./utils/types"; -import { WithFetchOptions } from "./api-client/api-client"; -import { RUNTIMES } from "./constants"; -import { Snapshot } from "./snapshot"; -import { consumeReadable } from "./utils/consume-readable"; -import { - type NetworkPolicy, - type NetworkPolicyRule, - type NetworkTransformer, +import { getPrivateParams, type WithPrivate } from "./utils/types"; +import type { WithFetchOptions } from "./api-client/api-client"; +import type { RUNTIMES } from "./constants"; +import { Session, type RunCommandParams } from "./session"; +import type { Command, CommandFinished } from "./command"; +import type { Snapshot } from "./snapshot"; +import type { ConvertedSandbox } from "./utils/convert-sandbox"; +import type { + NetworkPolicy, } from "./network-policy"; -import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; +import { fromAPINetworkPolicy } from "./utils/network-policy"; +import { setTimeout } from "node:timers/promises"; -export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; +export type { NetworkPolicy }; /** @inline */ export interface BaseCreateSandboxParams { @@ -68,19 +64,16 @@ export interface BaseCreateSandboxParams { * 2048 MB of memory per vCPU. */ resources?: { vcpus: number }; - /** * The runtime of the sandbox, currently only `node24`, `node22` and `python3.13` are supported. * If not specified, the default runtime `node24` will be used. */ runtime?: RUNTIMES | (string & {}); - /** * Network policy to define network restrictions for the sandbox. * Defaults to full internet access if not specified. */ networkPolicy?: NetworkPolicy; - /** * Default environment variables for the sandbox. * These are inherited by all commands unless overridden with @@ -99,7 +92,6 @@ export interface BaseCreateSandboxParams { * An AbortSignal to cancel sandbox creation. */ signal?: AbortSignal; - /** * Whether to enable snapshots on shutdown. Defaults to true. */ @@ -115,134 +107,197 @@ export type CreateSandboxParams = /** @inline */ interface GetSandboxParams { /** - * Unique identifier of the sandbox. + * The name of the sandbox. + */ + name: string; + /** + * Whether to resume an existing session. Defaults to true. */ - sandboxId: string; + resume?: boolean; /** * An AbortSignal to cancel the operation. */ signal?: AbortSignal; } -/** @inline */ -interface RunCommandParams { +function isSandboxStoppedError(err: unknown): boolean { + return err instanceof APIError && err.response.status === 410; +} + +function isSandboxStoppingError(err: unknown): boolean { + return ( + err instanceof APIError && + err.response.status === 422 && + (err.json as any)?.error?.code === "sandbox_stopping" + ); +} + +/** + * A Sandbox is a persistent, isolated Linux MicroVMs to run commands in. + * Use {@link Sandbox.create} or {@link Sandbox.get} to construct. + * @hideconstructor + */ +export class Sandbox { + private readonly client: APIClient; + private readonly projectId: string; + /** - * The command to execute + * Internal Session instance for the current VM. */ - cmd: string; + private session: Session; + /** - * Arguments to pass to the command + * Internal metadata about the named sandbox. */ - args?: string[]; + private namedSandbox: NamedSandboxMetaData; + /** - * Working directory to execute the command in + * The name of this sandbox. */ - cwd?: string; + public get name(): string { + return this.namedSandbox.name; + } + /** - * Environment variables to set for this command + * Routes from ports to subdomains. + * @hidden */ - env?: Record; + public get routes(): SandboxRouteData[] { + return this.session.routes; + } + /** - * If true, execute this command with root privileges. Defaults to false. + * Whether this sandbox snapshots on shutdown. */ - sudo?: boolean; + public get snapshotOnShutdown(): boolean { + return this.namedSandbox.snapshotOnShutdown; + } + /** - * If true, the command will return without waiting for `exitCode` + * The region this sandbox runs in. */ - detached?: boolean; + public get region(): string { + return this.namedSandbox.region; + } + /** - * A `Writable` stream where `stdout` from the command will be piped + * Number of virtual CPUs allocated. */ - stdout?: Writable; + public get vcpus(): number { + return this.namedSandbox.vcpus; + } + /** - * A `Writable` stream where `stderr` from the command will be piped + * Memory allocated in MB. */ - stderr?: Writable; + public get memory(): number { + return this.namedSandbox.memory; + } + + /** Runtime identifier (e.g. "node24", "python3.13"). */ + public get runtime(): string { + return this.namedSandbox.runtime; + } + /** - * An AbortSignal to cancel the command execution + * Cumulative egress bytes across all sessions. */ - signal?: AbortSignal; -} - -/** - * A Sandbox is an isolated Linux MicroVM to run commands in. - * - * Use {@link Sandbox.create} or {@link Sandbox.get} to construct. - * @hideconstructor - */ -export class Sandbox { - private readonly client: APIClient; + public get totalEgressBytes(): number | undefined { + return this.namedSandbox.totalEgressBytes; + } /** - * Routes from ports to subdomains. - /* @hidden + * Cumulative ingress bytes across all sessions. */ - public readonly routes: SandboxRouteData[]; + public get totalIngressBytes(): number | undefined { + return this.namedSandbox.totalIngressBytes; + } /** - * Unique ID of this sandbox. + * Cumulative active CPU duration in milliseconds across all sessions. */ - public get sandboxId(): string { - return this.sandbox.id; + public get totalActiveCpuDurationMs(): number | undefined { + return this.namedSandbox.totalActiveCpuDurationMs; } - public get interactivePort(): number | undefined { - return this.sandbox.interactivePort ?? undefined; + /** + * Cumulative wall-clock duration in milliseconds across all sessions. + */ + public get totalDurationMs(): number | undefined { + return this.namedSandbox.totalDurationMs; } /** - * The status of the sandbox. + * When this sandbox was last updated. */ - public get status(): SandboxMetaData["status"] { - return this.sandbox.status; + public get updatedAt(): Date { + return new Date(this.namedSandbox.updatedAt); } /** - * The creation date of the sandbox. + * When this sandbox was created. */ public get createdAt(): Date { - return new Date(this.sandbox.createdAt); + return new Date(this.namedSandbox.createdAt); + } + + /** + * Interactive port. + */ + public get interactivePort(): number | undefined { + return this.session.interactivePort; + } + + /** + * The status of the current session. + */ + public get status(): SandboxMetaData["status"] { + return this.session.status; } /** - * The timeout of the sandbox in milliseconds. + * The default timeout of this sandbox in milliseconds. */ public get timeout(): number { - return this.sandbox.timeout; + return this.namedSandbox.timeout; } /** - * The network policy of the sandbox. + * The default network policy of this sandbox. */ public get networkPolicy(): NetworkPolicy | undefined { - return this.sandbox.networkPolicy; + return this.namedSandbox.networkPolicy + ? fromAPINetworkPolicy(this.namedSandbox.networkPolicy) + : undefined; } /** - * If the sandbox was created from a snapshot, the ID of that snapshot. + * If the session was created from a snapshot, the ID of that snapshot. */ public get sourceSnapshotId(): string | undefined { - return this.sandbox.sourceSnapshotId; + return this.session.sourceSnapshotId; } /** - * The amount of CPU used by the sandbox. Only reported once the VM is stopped. + * The current snapshot ID of this sandbox, if any. */ - public get activeCpuUsageMs(): number | undefined { - return this.sandbox.activeCpuDurationMs; + public get currentSnapshotId(): string | undefined { + return this.namedSandbox.currentSnapshotId; } /** - * The amount of network data used by the sandbox. Only reported once the VM is stopped. + * The amount of CPU used by the session. Only reported once the VM is stopped. */ - public get networkTransfer(): {ingress: number, egress: number} | undefined { - return this.sandbox.networkTransfer; + public get activeCpuUsageMs(): number | undefined { + return this.session.activeCpuUsageMs; } /** - * Internal metadata about this sandbox. + * The amount of network data used by the session. Only reported once the VM is stopped. */ - private sandbox: ConvertedSandbox; + public get networkTransfer(): {ingress: number, egress: number} | undefined { + return this.session.networkTransfer; + } /** * Allow to get a list of sandboxes for a team narrowed to the given params. @@ -250,7 +305,7 @@ export class Sandbox { * the next page of results. */ static async list( - params?: Partial[0]> & + params?: Partial[0]> & Partial & WithFetchOptions, ) { @@ -260,7 +315,7 @@ export class Sandbox { token: credentials.token, fetch: params?.fetch, }); - return client.listSandboxes({ + return client.listNamedSandboxes({ ...credentials, ...params, }); @@ -292,7 +347,7 @@ export class Sandbox { }); const privateParams = getPrivateParams(params); - const sandbox = await client.createSandbox({ + const response = await client.createSandbox({ source: params?.source, projectId: credentials.projectId, ports: params?.ports ?? [], @@ -309,13 +364,15 @@ export class Sandbox { return new DisposableSandbox({ client, - sandbox: sandbox.json.sandbox, - routes: sandbox.json.routes, + session: response.json.sandbox, + namedSandbox: response.json.namedSandbox, + routes: response.json.routes, + projectId: credentials.projectId, }); } /** - * Retrieve an existing sandbox. + * Retrieve an existing sandbox and resume its session. * * @param params - Get parameters and optional credentials. * @returns A promise resolving to the {@link Sandbox}. @@ -331,59 +388,109 @@ export class Sandbox { fetch: params.fetch, }); - const privateParams = getPrivateParams(params); - const sandbox = await client.getSandbox({ - sandboxId: params.sandboxId, + const response = await client.getNamedSandbox({ + name: params.name, + projectId: credentials.projectId, + resume: params.resume, signal: params.signal, - ...privateParams, }); return new Sandbox({ client, - sandbox: sandbox.json.sandbox, - routes: sandbox.json.routes, + session: response.json.sandbox, + namedSandbox: response.json.namedSandbox, + routes: response.json.routes, + projectId: credentials.projectId, }); } constructor({ client, routes, - sandbox, + session, + namedSandbox, + projectId, }: { client: APIClient; routes: SandboxRouteData[]; - sandbox: SandboxMetaData; + session: SandboxMetaData; + namedSandbox: NamedSandboxMetaData; + projectId: string; }) { this.client = client; - this.routes = routes; - this.sandbox = convertSandbox(sandbox); + this.session = new Session({ client, routes, session }); + this.namedSandbox = namedSandbox; + this.projectId = projectId; } /** - * Get a previously run command by its ID. + * Get the current session (the running VM) for this sandbox. * - * @param cmdId - ID of the command to retrieve - * @param opts - Optional parameters. - * @param opts.signal - An AbortSignal to cancel the operation. - * @returns A {@link Command} instance representing the command + * @returns The {@link Session} instance. */ - async getCommand( - cmdId: string, - opts?: { signal?: AbortSignal }, - ): Promise { - const command = await this.client.getCommand({ - sandboxId: this.sandbox.id, - cmdId, - signal: opts?.signal, - }); + currentSession(): Session { + return this.session; + } - return new Command({ + /** + * Resume this sandbox by creating a new session via `getNamedSandbox`. + */ + private async resume(signal?: AbortSignal): Promise { + const response = await this.client.getNamedSandbox({ + name: this.namedSandbox.name, + projectId: this.projectId, + resume: true, + signal, + }); + this.session = new Session({ client: this.client, - sandboxId: this.sandbox.id, - cmd: command.json.command, + routes: response.json.routes, + session: response.json.sandbox, }); } + /** + * Poll until the current session reaches a terminal state, then resume. + */ + private async waitForStopAndResume(signal?: AbortSignal): Promise { + const pollingInterval = 500; + let status = this.session.status; + + while (status === "stopping" || status === "snapshotting") { + await setTimeout(pollingInterval, undefined, { signal }); + const poll = await this.client.getSandbox({ + sandboxId: this.session.sessionId, + signal, + }); + this.session = new Session({ + client: this.client, + routes: poll.json.routes, + session: poll.json.sandbox, + }); + status = poll.json.sandbox.status; + } + await this.resume(signal); + } + + /** + * Execute `fn`, and if the session is stopped/stopping, resume and retry. + */ + private async withResume(fn: () => Promise, signal?: AbortSignal): Promise { + try { + return await fn(); + } catch (err) { + if (isSandboxStoppedError(err)) { + await this.resume(signal); + return fn(); + } + if (isSandboxStoppingError(err)) { + await this.waitForStopAndResume(signal); + return fn(); + } + throw err; + } + } + /** * Start executing a command in this sandbox. * @@ -398,7 +505,6 @@ export class Sandbox { args?: string[], opts?: { signal?: AbortSignal }, ): Promise; - /** * Start executing a command in detached mode. * @@ -415,6 +521,7 @@ export class Sandbox { * @param params - The command parameters. * @returns A {@link CommandFinished} result once execution is done. */ + async runCommand(params: RunCommandParams): Promise; async runCommand( @@ -422,9 +529,11 @@ export class Sandbox { args?: string[], opts?: { signal?: AbortSignal }, ): Promise { - return typeof commandOrParams === "string" - ? this._runCommand({ cmd: commandOrParams, args, signal: opts?.signal }) - : this._runCommand(commandOrParams); + const signal = typeof commandOrParams === "string" ? opts?.signal : commandOrParams.signal; + return this.withResume( + () => this.session.runCommand(commandOrParams as any, args, opts), + signal, + ); } /** @@ -434,77 +543,15 @@ export class Sandbox { * @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); + async getCommand( + cmdId: string, + opts?: { signal?: AbortSignal }, + ): Promise { - return command; + return this.withResume( + () => this.session.getCommand(cmdId, opts), + opts?.signal, + ); } /** @@ -515,11 +562,11 @@ 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, - signal: opts?.signal, - }); + + return this.withResume( + () => this.session.mkDir(path, opts), + opts?.signal, + ); } /** @@ -534,12 +581,11 @@ 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, - signal: opts?.signal, - }); + + return this.withResume( + () => this.session.readFile(file, opts), + opts?.signal, + ); } /** @@ -554,18 +600,11 @@ 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, - signal: opts?.signal, - }); - - if (stream === null) { - return null; - } - return consumeReadable(stream); + return this.withResume( + () => this.session.readFileToBuffer(file, opts), + opts?.signal, + ); } /** @@ -583,37 +622,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, - signal: opts?.signal, - }); - - if (stream === null) { - return null; - } - try { - const dstPath = resolve(dst.cwd ?? "", dst.path); - if (opts?.mkdirRecursive) { - await mkdir(dirname(dstPath), { recursive: true }); - } - await pipeline(stream, createWriteStream(dstPath), { - signal: opts?.signal, - }); - return dstPath; - } finally { - stream.destroy(); - } + return this.withResume( + () => this.session.downloadFile(src, dst, opts), + opts?.signal, + ); } /** @@ -630,13 +643,11 @@ export class Sandbox { files: { path: string; content: Buffer }[], opts?: { signal?: AbortSignal }, ) { - return this.client.writeFiles({ - sandboxId: this.sandbox.id, - cwd: this.sandbox.cwd, - extractDir: "/", - files: files, - signal: opts?.signal, - }); + + return this.withResume( + () => this.session.writeFiles(files, opts), + opts?.signal, + ); } /** @@ -647,12 +658,7 @@ export class Sandbox { * @throws If the port has no associated route */ domain(p: number): string { - const route = this.routes.find(({ port }) => port == p); - if (route) { - return `https://${route.subdomain}.vercel.run`; - } else { - throw new Error(`No route for port ${p}`); - } + return this.session.domain(p); } /** @@ -664,13 +670,7 @@ 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, - signal: opts?.signal, - blocking: opts?.blocking, - }); - this.sandbox = convertSandbox(response.json.sandbox); - return this.sandbox; + return this.session.stop(opts); } /** @@ -708,15 +708,11 @@ export class Sandbox { networkPolicy: NetworkPolicy, opts?: { signal?: AbortSignal }, ): Promise { - const response = await this.client.updateNetworkPolicy({ - sandboxId: this.sandbox.id, - networkPolicy: networkPolicy, - signal: opts?.signal, - }); - // Update the internal sandbox metadata with the new timeout value - this.sandbox = convertSandbox(response.json.sandbox); - return this.sandbox.networkPolicy!; + return this.withResume( + () => this.session.updateNetworkPolicy(networkPolicy, opts), + opts?.signal, + ); } /** @@ -739,14 +735,11 @@ export class Sandbox { duration: number, opts?: { signal?: AbortSignal }, ): Promise { - const response = await this.client.extendTimeout({ - sandboxId: this.sandbox.id, - duration, - signal: opts?.signal, - }); - // Update the internal sandbox metadata with the new timeout value - this.sandbox = convertSandbox(response.json.sandbox); + return this.withResume( + () => this.session.extendTimeout(duration, opts), + opts?.signal, + ); } /** @@ -764,17 +757,99 @@ export class Sandbox { expiration?: number; signal?: AbortSignal; }): Promise { - const response = await this.client.createSnapshot({ - sandboxId: this.sandbox.id, - expiration: opts?.expiration, + + return this.withResume( + () => this.session.snapshot(opts), + opts?.signal, + ); + } + + /** + * Update the named sandbox configuration. Only provided fields are modified. + * + * @param params - Fields to update. + * @param opts - Optional abort signal. + */ + async update( + params: { + resources?: { vcpus?: number; memory?: number }; + runtime?: RUNTIMES | (string & {}); + timeout?: number; + networkPolicy?: NetworkPolicy; + }, + opts?: { signal?: AbortSignal }, + ): Promise { + + const response = await this.client.updateNamedSandbox({ + name: this.namedSandbox.name, + projectId: this.projectId, + resources: params.resources, + runtime: params.runtime, + timeout: params.timeout, + networkPolicy: params.networkPolicy, signal: opts?.signal, }); + this.namedSandbox = response.json.namedSandbox; + } - this.sandbox = convertSandbox(response.json.sandbox); + /** + * Delete this named sandbox. + * + * After deletion the instance becomes inert — all further API calls will + * throw immediately. + */ + async delete(opts?: { preserveSnapshots?: boolean; signal?: AbortSignal }): Promise { + await this.client.deleteNamedSandbox({ + name: this.namedSandbox.name, + projectId: this.projectId, + preserveSnapshots: opts?.preserveSnapshots, + signal: opts?.signal, + }); + } - return new Snapshot({ - client: this.client, - snapshot: response.json.snapshot, + /** + * List sessions (VMs) that have been created for this named sandbox. + * + * @param params - Optional pagination parameters. + * @returns The list of sessions and pagination metadata. + */ + async listSessions(params?: { + limit?: number; + since?: number | Date; + until?: number | Date; + signal?: AbortSignal; + }) { + + return this.client.listSandboxes({ + projectId: this.projectId, + name: this.namedSandbox.name, + limit: params?.limit, + since: params?.since, + until: params?.until, + signal: params?.signal, + }); + } + + /** + * List snapshots that belong to this named sandbox. + * + * @param params - Optional pagination parameters. + * @returns The list of snapshots and pagination metadata. + */ + async listSnapshots(params?: { + limit?: number; + since?: number | Date; + until?: number | Date; + signal?: AbortSignal; + }) { + + return this.client.listSnapshots({ + projectId: this.projectId, + name: this.namedSandbox.name, + limit: params?.limit, + since: params?.since, + until: params?.until, + signal: params?.signal, }); } } diff --git a/packages/vercel-sandbox/src/session.ts b/packages/vercel-sandbox/src/session.ts new file mode 100644 index 00000000..7c703a01 --- /dev/null +++ b/packages/vercel-sandbox/src/session.ts @@ -0,0 +1,665 @@ +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 { dirname, resolve } from "path"; +import type { APIClient } from "./api-client"; +import { Command, CommandFinished } from "./command"; +import { Snapshot } from "./snapshot"; +import { consumeReadable } from "./utils/consume-readable"; +import type { + NetworkPolicy, + NetworkPolicyRule, + NetworkTransformer, +} from "./network-policy"; +import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; + +export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; + +/** @inline */ +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; +} + +/** + * A Session represents a running VM instance within a named {@link Sandbox}. + * + * Obtain a session via {@link Sandbox.currentSession}. + */ +export class Session { + private readonly client: APIClient; + + /** + * Routes from ports to subdomains. + * @hidden + */ + public readonly routes: SandboxRouteData[]; + + /** + * Internal metadata about the current session. + */ + private session: ConvertedSandbox; + + /** + * Unique ID of this session. + */ + public get sessionId(): string { + return this.session.id; + } + + public get interactivePort(): number | undefined { + return this.session.interactivePort ?? undefined; + } + + /** + * The status of this session. + */ + public get status(): SandboxMetaData["status"] { + return this.session.status; + } + + /** + * The creation date of this session. + */ + public get createdAt(): Date { + return new Date(this.session.createdAt); + } + + /** + * The timeout of this session in milliseconds. + */ + public get timeout(): number { + return this.session.timeout; + } + + /** + * The network policy of this session. + */ + public get networkPolicy(): NetworkPolicy | undefined { + return this.session.networkPolicy; + } + + /** + * If the session was created from a snapshot, the ID of that snapshot. + */ + public get sourceSnapshotId(): string | undefined { + return this.session.sourceSnapshotId; + } + + /** + * Memory allocated to this session in MB. + */ + public get memory(): number { + return this.session.memory; + } + + /** + * Number of vCPUs allocated to this session. + */ + public get vcpus(): number { + return this.session.vcpus; + } + + /** + * The region where this session is hosted. + */ + public get region(): string { + return this.session.region; + } + + /** + * Runtime identifier (e.g. "node24", "python3.13"). + */ + public get runtime(): string { + return this.session.runtime; + } + + /** + * The working directory of this session. + */ + public get cwd(): string { + return this.session.cwd; + } + + /** + * When this session was requested. + */ + public get requestedAt(): Date { + return new Date(this.session.requestedAt); + } + + /** + * When this session started running. + */ + public get startedAt(): Date | undefined { + return this.session.startedAt != null ? new Date(this.session.startedAt) : undefined; + } + + /** + * When this session was requested to stop. + */ + public get requestedStopAt(): Date | undefined { + return this.session.requestedStopAt != null ? new Date(this.session.requestedStopAt) : undefined; + } + + /** + * When this session was stopped. + */ + public get stoppedAt(): Date | undefined { + return this.session.stoppedAt != null ? new Date(this.session.stoppedAt) : undefined; + } + + /** + * When this session was aborted. + */ + public get abortedAt(): Date | undefined { + return this.session.abortedAt != null ? new Date(this.session.abortedAt) : undefined; + } + + /** + * The wall-clock duration of this session in milliseconds. + */ + public get duration(): number | undefined { + return this.session.duration; + } + + /** + * When a snapshot was requested for this session. + */ + public get snapshottedAt(): Date | undefined { + return this.session.snapshottedAt != null ? new Date(this.session.snapshottedAt) : undefined; + } + + /** + * When this session was last updated. + */ + public get updatedAt(): Date { + return new Date(this.session.updatedAt); + } + + /** + * The amount of active CPU used by the session. Only reported once the VM is + * stopped. + */ + public get activeCpuUsageMs(): number | undefined { + return this.session.activeCpuDurationMs; + } + + /** + * The amount of network data used by the session. Only reported once the VM + * is stopped. + */ + public get networkTransfer(): {ingress: number, egress: number} | undefined { + return this.session.networkTransfer; + } + + constructor({ + client, + routes, + session, + }: { + client: APIClient; + routes: SandboxRouteData[]; + session: SandboxMetaData; + }) { + this.client = client; + this.routes = routes; + this.session = convertSandbox(session); + } + + /** + * Get a previously run command by its ID. + * + * @param cmdId - ID of the command to retrieve + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A {@link Command} instance representing the command + */ + async getCommand( + cmdId: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const command = await this.client.getCommand({ + sandboxId: this.session.id, + cmdId, + signal: opts?.signal, + }); + + return new Command({ + client: this.client, + sandboxId: this.session.id, + cmd: command.json.command, + }); + } + + /** + * Start executing a command in this session. + * + * @param command - The command to execute. + * @param args - Arguments to pass to the command. + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the command execution. + * @returns A {@link CommandFinished} result once execution is done. + */ + async runCommand( + command: string, + args?: string[], + opts?: { signal?: AbortSignal }, + ): Promise; + + /** + * Start executing a command in detached mode. + * + * @param params - The command parameters. + * @returns A {@link Command} instance for the running command. + */ + async runCommand( + params: RunCommandParams & { detached: true }, + ): Promise; + + /** + * Start executing a command in this session. + * + * @param params - The command parameters. + * @returns A {@link CommandFinished} result once execution is done. + */ + async runCommand(params: RunCommandParams): Promise; + + async runCommand( + commandOrParams: string | RunCommandParams, + args?: string[], + 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 session. + * + * @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.session.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.session.id, + cmd: commandStream.command, + }); + + getLogs(command); + + const finished = await commandStream.finished; + return new CommandFinished({ + client: this.client, + sandboxId: this.session.id, + cmd: finished, + exitCode: finished.exitCode ?? 0, + }); + } + + const commandResponse = await this.client.runCommand({ + sandboxId: this.session.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.session.id, + cmd: commandResponse.json.command, + }); + + getLogs(command); + + return command; + } + + /** + * Create a directory in the filesystem of this session. + * + * @param path - Path of the directory to create + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + */ + async mkDir(path: string, opts?: { signal?: AbortSignal }): Promise { + await this.client.mkDir({ + sandboxId: this.session.id, + path: path, + signal: opts?.signal, + }); + } + + /** + * Read a file from the filesystem of this session as a stream. + * + * @param file - File to read, with path and optional cwd + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves to a ReadableStream containing the file contents, or null if file not found + */ + async readFile( + file: { path: string; cwd?: string }, + opts?: { signal?: AbortSignal }, + ): Promise { + return this.client.readFile({ + sandboxId: this.session.id, + path: file.path, + cwd: file.cwd, + signal: opts?.signal, + }); + } + + /** + * Read a file from the filesystem of this session as a Buffer. + * + * @param file - File to read, with path and optional cwd + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves to the file contents as a Buffer, or null if file not found + */ + async readFileToBuffer( + file: { path: string; cwd?: string }, + opts?: { signal?: AbortSignal }, + ): Promise { + const stream = await this.client.readFile({ + sandboxId: this.session.id, + path: file.path, + cwd: file.cwd, + signal: opts?.signal, + }); + + if (stream === null) { + return null; + } + + return consumeReadable(stream); + } + + /** + * Download a file from the session to the local filesystem. + * + * @param src - Source file on the session, with path and optional cwd + * @param dst - Destination file on the local machine, with path and optional cwd + * @param opts - Optional parameters. + * @param opts.mkdirRecursive - If true, create parent directories for the destination if they don't exist. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns The absolute path to the written file, or null if the source file was not found + */ + async downloadFile( + src: { path: string; cwd?: string }, + 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.session.id, + path: src.path, + cwd: src.cwd, + signal: opts?.signal, + }); + + if (stream === null) { + return null; + } + + try { + const dstPath = resolve(dst.cwd ?? "", dst.path); + if (opts?.mkdirRecursive) { + await mkdir(dirname(dstPath), { recursive: true }); + } + await pipeline(stream, createWriteStream(dstPath), { + signal: opts?.signal, + }); + return dstPath; + } finally { + stream.destroy(); + } + } + + /** + * Write files to the filesystem of this session. + * Defaults to writing to /vercel/sandbox unless an absolute path is specified. + * Writes files using the `vercel-sandbox` user. + * + * @param files - Array of files with path and stream/buffer contents + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves when the files are written + */ + async writeFiles( + files: { path: string; content: Buffer }[], + opts?: { signal?: AbortSignal }, + ) { + return this.client.writeFiles({ + sandboxId: this.session.id, + cwd: this.session.cwd, + extractDir: "/", + files: files, + signal: opts?.signal, + }); + } + + /** + * Get the public domain of a port of this session. + * + * @param p - Port number to resolve + * @returns A full domain (e.g. `https://subdomain.vercel.run`) + * @throws If the port has no associated route + */ + domain(p: number): string { + const route = this.routes.find(({ port }) => port == p); + if (route) { + return `https://${route.subdomain}.vercel.run`; + } else { + throw new Error(`No route for port ${p}`); + } + } + + /** + * Stop this session. + * + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @param opts.blocking - If true, poll until the session has fully stopped and return the final state. + * @returns The session 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.session.id, + signal: opts?.signal, + blocking: opts?.blocking, + }); + this.session = convertSandbox(response.json.sandbox); + return this.session; + } + + /** + * Update the network policy for this session. + * + * @param networkPolicy - The new network policy to apply. + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves when the network policy is updated. + * + * @example + * // Restrict to specific domains + * await session.updateNetworkPolicy({ + * allow: ["*.npmjs.org", "github.com"], + * }); + * + * @example + * // Inject credentials with per-domain transformers + * await session.updateNetworkPolicy({ + * allow: { + * "ai-gateway.vercel.sh": [{ + * transform: [{ + * headers: { authorization: "Bearer ..." } + * }] + * }], + * "*": [] + * } + * }); + * + * @example + * // Deny all network access + * await session.updateNetworkPolicy("deny-all"); + */ + async updateNetworkPolicy( + networkPolicy: NetworkPolicy, + opts?: { signal?: AbortSignal }, + ): Promise { + const response = await this.client.updateNetworkPolicy({ + sandboxId: this.session.id, + networkPolicy: networkPolicy, + signal: opts?.signal, + }); + + // Update the internal session metadata with the new network policy + this.session = convertSandbox(response.json.sandbox); + return this.session.networkPolicy!; + } + + /** + * Extend the timeout of the session by the specified duration. + * + * This allows you to extend the lifetime of a session up until the maximum + * execution timeout for your plan. + * + * @param duration - The duration in milliseconds to extend the timeout by + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves when the timeout is extended + * + * @example + * const sandbox = await Sandbox.create({ timeout: ms('10m') }); + * const session = sandbox.currentSession(); + * // Extends timeout by 5 minutes, to a total of 15 minutes. + * await session.extendTimeout(ms('5m')); + */ + async extendTimeout( + duration: number, + opts?: { signal?: AbortSignal }, + ): Promise { + const response = await this.client.extendTimeout({ + sandboxId: this.session.id, + duration, + signal: opts?.signal, + }); + + // Update the internal session metadata with the new timeout value + this.session = convertSandbox(response.json.sandbox); + } + + /** + * Create a snapshot from this currently running session. New sandboxes can + * then be created from this snapshot using {@link Sandbox.create}. + * + * Note: this session will be stopped as part of the snapshot creation process. + * + * @param opts - Optional parameters. + * @param opts.expiration - Optional expiration time in milliseconds. Use 0 for no expiration at all. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves to the Snapshot instance + */ + async snapshot(opts?: { + expiration?: number; + signal?: AbortSignal; + }): Promise { + const response = await this.client.createSnapshot({ + sandboxId: this.session.id, + expiration: opts?.expiration, + signal: opts?.signal, + }); + + this.session = convertSandbox(response.json.sandbox); + + return new Snapshot({ + client: this.client, + snapshot: response.json.snapshot, + }); + } +} From 540bc327bab8c7729433885e5a8a1061d9e57d69 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Wed, 4 Mar 2026 09:02:15 +0100 Subject: [PATCH 03/41] feat(vercel/sandbox): beta release for named sandboxes (#74) Create a public `beta` release for NamedSandboxes. This is a breaking change, so we are also creating the tag `2.0.0-beta`. All these changes are still behind the `named-sandboxes` branch and won't be merged into main until validated and tested. --- .changeset/pre.json | 4 +++- .github/workflows/publish.yml | 11 +++++++++++ packages/vercel-sandbox/CHANGELOG.md | 6 ++++++ packages/vercel-sandbox/package.json | 2 +- packages/vercel-sandbox/src/version.ts | 2 +- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index e15644f5..ef303303 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -14,5 +14,7 @@ "sandbox": "2.5.3", "@vercel/sandbox": "1.7.1" }, - "changesets": [] + "changesets": [ + "fair-pears-dream" + ] } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dd956ede..cc22fbbc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - named-sandboxes pull_request: env: @@ -48,3 +49,13 @@ jobs: with: version: pnpm version:prepare publish: pnpm release + + - name: Create Beta Release Pull Request + uses: changesets/action@v1 + if: github.ref == 'refs/heads/named-sandboxes' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} + with: + version: pnpm version:prepare + publish: pnpm release --tag beta diff --git a/packages/vercel-sandbox/CHANGELOG.md b/packages/vercel-sandbox/CHANGELOG.md index 0492ada1..4756b052 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -1,5 +1,11 @@ # @vercel/sandbox +## 2.0.0-beta.0 + +### Major Changes + +- Introduce named and long-lived sandboxes ([`7407ec9ec419144ae49b0eb2704cb5cf2267b7f3`](https://github.com/vercel/sandbox/commit/7407ec9ec419144ae49b0eb2704cb5cf2267b7f3)) + ## 1.8.0 ### Minor Changes diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 3e3af4b0..fcd85188 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/sandbox", - "version": "1.8.0", + "version": "2.0.0-beta.0", "description": "Software Development Kit for Vercel Sandbox", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/vercel-sandbox/src/version.ts b/packages/vercel-sandbox/src/version.ts index 70c078d5..e5e0adf6 100644 --- a/packages/vercel-sandbox/src/version.ts +++ b/packages/vercel-sandbox/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by inject-version.ts -export const VERSION = "1.8.0"; +export const VERSION = "2.0.0-beta.0"; From a4bd866abf178352bfd4df3b4e3e50b0cf2f9d5b Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Wed, 4 Mar 2026 09:14:50 +0100 Subject: [PATCH 04/41] [vercel-sandbox] fix the beta release pipeline (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the beta release pipeline, as we are getting this error: ``` > vercel-sandbox@0.0.0 release /home/runner/work/sandbox/sandbox > changeset publish --tag beta 🦋 error Releasing under custom tag is not allowed in pre mode 🦋 To resolve this exit the pre mode by running `changeset pre exit`  ELIFECYCLE  Command failed with exit code 1. ``` To ensure we are always publishing in beta, I've added a previous check to read the `.changeset/pre.json` and ensure it is a beta. --- .github/workflows/publish.yml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc22fbbc..1200777a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,22 +40,26 @@ jobs: - name: Run tests run: pnpm test + - name: Verify beta pre-release mode + if: github.ref == 'refs/heads/named-sandboxes' + run: | + if [ ! -f .changeset/pre.json ]; then + echo "ERROR: .changeset/pre.json not found. named-sandboxes must be in changeset pre-release mode." + exit 1 + fi + TAG=$(jq -r '.tag' .changeset/pre.json) + if [ "$TAG" != "beta" ]; then + echo "ERROR: Expected pre-release tag 'beta', got '$TAG'." + exit 1 + fi + echo "Verified: changeset pre-release mode is active with tag 'beta'." + - name: Create Release Pull Request uses: changesets/action@v1 - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/named-sandboxes' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} with: version: pnpm version:prepare publish: pnpm release - - - name: Create Beta Release Pull Request - uses: changesets/action@v1 - if: github.ref == 'refs/heads/named-sandboxes' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} - with: - version: pnpm version:prepare - publish: pnpm release --tag beta From 8f91638ea1736f7572621d2b97dc16cc8708f0c8 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 5 Mar 2026 12:20:19 +0100 Subject: [PATCH 05/41] [packages/sandbox] Add support for named sandboxes in the CLI (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CLI support for the named sandboxes. Notes: this will be merge on the `named-sandboxes` branch. When this happens, a new `beta` release will be done for the CLI package. Breaking Changes --- 1. All commands now take sandbox names instead of IDs. 2. sandbox stop no longer has `rm`/`remove` aliases. 3. sandbox run `--remove-after-use` (or just `--rm`) now permanently deletes the sandbox. 4. sandbox `cp` path format changed — Remote paths now use `SANDBOX_NAME:PATH` instead of `SANDBOX_ID:PATH` 5. sandbox list column header renamed — The ID column is now NAME. 6. sandbox snapshots list/get column renamed — SOURCE SANDBOX is now SOURCE SESSION. New Features --- 1. New sandbox `rm` / `remove` command — Permanently deletes one or more sandboxes. 2. New sandbox `sessions list` command (alias `sessions ls`) — Lists all sessions for a given sandbox. 3. New `sandbox config set` subcommand — Update sandbox configuration in a single command. Example: `sandbox config set my-sandbox --runtime node24 --vcpus 2 --timeout 1h`. 4. New `sandbox config get` subcommand — Display the current configuration of a sandbox. 5. New `sandbox create --name` option — Assign a user-chosen name to a sandbox at creation time. 6. New `sandbox create --no-snapshot-on-shutdown` flag — Disable automatic snapshotting when a sandbox shuts down. 7. `sandbox run` now supports resuming — When --name is provided, run first tries to resume an existing sandbox with that name; otherwise it creates the sandbox. 8. New `sandbox list --name-prefix` option — Filter sandboxes by name prefix. 9. New `sandbox list --sort-by` option — Sort sandboxes by createdAt or name. Other Changes --- 1. `sandbox config network-policy` is deprecated — Now emits a warning. --- .changeset/light-results-change.md | 5 + .changeset/pre.json | 4 +- .changeset/stale-hotels-grow.md | 5 + packages/sandbox/CHANGELOG.md | 17 ++ packages/sandbox/docs/index.md | 69 ++++--- packages/sandbox/package.json | 2 +- packages/sandbox/src/app.ts | 6 +- packages/sandbox/src/args/runtime.ts | 10 +- packages/sandbox/src/args/sandbox-id.ts | 20 -- packages/sandbox/src/args/sandbox-name.ts | 20 ++ packages/sandbox/src/client.ts | 2 +- packages/sandbox/src/commands/config.ts | 179 ++++++++++++++++-- packages/sandbox/src/commands/cp.ts | 20 +- packages/sandbox/src/commands/create.ts | 18 +- packages/sandbox/src/commands/exec.ts | 25 +-- packages/sandbox/src/commands/list.ts | 18 +- packages/sandbox/src/commands/remove.ts | 49 +++++ packages/sandbox/src/commands/run.ts | 30 ++- packages/sandbox/src/commands/sessions.ts | 82 ++++++++ packages/sandbox/src/commands/snapshot.ts | 14 +- packages/sandbox/src/commands/snapshots.ts | 10 +- packages/sandbox/src/commands/stop.ts | 27 ++- packages/sandbox/test/commands/cp.test.ts | 8 +- packages/vercel-sandbox/CHANGELOG.md | 6 + packages/vercel-sandbox/package.json | 2 +- .../src/api-client/api-client.test.ts | 12 +- .../src/api-client/api-client.ts | 8 +- .../src/api-client/validators.ts | 2 +- packages/vercel-sandbox/src/sandbox.ts | 10 +- packages/vercel-sandbox/src/version.ts | 2 +- 30 files changed, 522 insertions(+), 160 deletions(-) create mode 100644 .changeset/light-results-change.md create mode 100644 .changeset/stale-hotels-grow.md delete mode 100644 packages/sandbox/src/args/sandbox-id.ts create mode 100644 packages/sandbox/src/args/sandbox-name.ts create mode 100644 packages/sandbox/src/commands/remove.ts create mode 100644 packages/sandbox/src/commands/sessions.ts diff --git a/.changeset/light-results-change.md b/.changeset/light-results-change.md new file mode 100644 index 00000000..d61ad346 --- /dev/null +++ b/.changeset/light-results-change.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Rename snapshotOnShutdown to persistent diff --git a/.changeset/pre.json b/.changeset/pre.json index ef303303..e939a6ac 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -15,6 +15,8 @@ "@vercel/sandbox": "1.7.1" }, "changesets": [ - "fair-pears-dream" + "fair-pears-dream", + "light-results-change", + "stale-hotels-grow" ] } diff --git a/.changeset/stale-hotels-grow.md b/.changeset/stale-hotels-grow.md new file mode 100644 index 00000000..cf17b07e --- /dev/null +++ b/.changeset/stale-hotels-grow.md @@ -0,0 +1,5 @@ +--- +"sandbox": major +--- + +Introduce long-lived sandboxes to the CLI diff --git a/packages/sandbox/CHANGELOG.md b/packages/sandbox/CHANGELOG.md index 1df4e361..80ca83e9 100644 --- a/packages/sandbox/CHANGELOG.md +++ b/packages/sandbox/CHANGELOG.md @@ -1,5 +1,22 @@ # sandbox +## 3.0.0-beta.1 + +### Major Changes + +- Introduce long-lived sandboxes to the CLI + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.1 + +## 3.0.0-beta.0 + +### Major Changes + +- Support named sandboxes + ## 2.5.4 ### Patch Changes diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index 95b9c18c..cc9a7f76 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -1,7 +1,7 @@ ## `sandbox --help` ``` -sandbox 2.5.4 +sandbox 3.0.0-beta.1 ▲ sandbox [options] @@ -9,18 +9,20 @@ For command help, run `sandbox --help` Commands: - ls | list List all sandboxes for the specified account and project. - create Create a sandbox in the specified account and project. - config Update a sandbox configuration - cp | copy Copy files between your local filesystem and a remote sandbox - exec [...args] Execute a command in an existing sandbox - ssh | connect Start an interactive shell in an existing sandbox - rm | stop [...sandbox_id] Stop one or more running sandboxes - run [...args] Create and run a command in a sandbox - snapshot Take a snapshot of the filesystem of a sandbox - snapshots Manage sandbox snapshots - login Log in to the Sandbox CLI - logout Log out of the Sandbox CLI + ls | list List all sandboxes for the specified account and project. + create Create a sandbox in the specified account and project. + config View and update sandbox configuration + cp | copy Copy files between your local filesystem and a remote sandbox + exec [...args] Execute a command in an existing sandbox + ssh | connect Start an interactive shell in an existing sandbox + stop [...name] Stop the current session of one or more sandboxes + rm | remove [...name] Permanently remove one or more sandboxes + run [...args] Create and run a command in a sandbox + snapshot Take a snapshot of the filesystem of a sandbox + snapshots Manage sandbox snapshots + sessions Manage sandbox sessions + login Log in to the Sandbox CLI + logout Log out of the Sandbox CLI Examples: @@ -34,7 +36,7 @@ Examples: – Execute command in an existing sandbox - $ sandbox exec -- npm test + $ sandbox exec -- npm test ``` ## `sandbox list` @@ -51,6 +53,11 @@ Flags: --all, -a Show all sandboxes (default shows just running) [optional] --help, -h show help [optional] +Options: + + --name-prefix Filter sandboxes by name prefix [optional] + --sort-by Sort sandboxes by field [optional] + Auth & Scope: --token A Vercel authentication token. If not provided, will use the token stored in your system from `VERCEL_AUTH_TOKEN` or will start a log in process. [optional] @@ -69,6 +76,7 @@ Create and run a command in a sandbox Options: + --name A user-chosen name for the sandbox. It must be unique per project. [optional] --runtime One of 'node22', 'node24', 'python3.13' [default: node24] --timeout The maximum duration a sandbox can run for. Example: 5m, 1h [default: 5 minutes] --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] @@ -86,7 +94,8 @@ Options: Flags: - --silent Don't write sandbox ID to stdout [optional] + --non-persistent Disable automatic snapshotting on shutdown. [optional] + --silent Don't write sandbox name to stdout [optional] --connect Start an interactive shell session after creating the sandbox [optional] --sudo Give extended privileges to the command. [optional] --interactive, -i Run the command in a secure interactive shell [optional] @@ -118,6 +127,7 @@ Create a sandbox in the specified account and project. Options: + --name A user-chosen name for the sandbox. It must be unique per project. [optional] --runtime One of 'node22', 'node24', 'python3.13' [default: node24] --timeout The maximum duration a sandbox can run for. Example: 5m, 1h [default: 5 minutes] --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] @@ -134,9 +144,10 @@ Options: Flags: - --silent Don't write sandbox ID to stdout [optional] - --connect Start an interactive shell session after creating the sandbox [optional] - --help, -h show help [optional] + --non-persistent Disable automatic snapshotting on shutdown. [optional] + --silent Don't write sandbox name to stdout [optional] + --connect Start an interactive shell session after creating the sandbox [optional] + --help, -h show help [optional] Auth & Scope: @@ -162,9 +173,9 @@ Execute a command in an existing sandbox Arguments: - The ID of the sandbox to execute the command in - The executable to invoke - [...args] arguments to pass to the command + The name of the sandbox + The executable to invoke + [...args] arguments to pass to the command Flags: @@ -193,12 +204,12 @@ stop ▲ sandbox stop [options] -Stop one or more running sandboxes +Stop the current session of one or more sandboxes Arguments: - a sandbox ID to stop - [...sandbox_id] more sandboxes to stop + a sandbox name to stop + [...name] more sandboxes to stop Auth & Scope: @@ -222,8 +233,8 @@ Copy files between your local filesystem and a remote sandbox Arguments: - The source file to copy from local file system, or or a sandbox_id:path from a remote sandbox - The destination file to copy to local file system, or or a sandbox_id:path to a remote sandbox + The source file to copy from local file system, or a sandbox_name:path from a remote sandbox + The destination file to copy to local file system, or a sandbox_name:path to a remote sandbox Auth & Scope: @@ -247,7 +258,7 @@ Start an interactive shell in an existing sandbox Arguments: - The ID of the sandbox to execute the command in + The name of the sandbox Flags: @@ -288,7 +299,7 @@ Options: Arguments: - The ID of the sandbox to execute the command in + The name of the sandbox Auth & Scope: @@ -325,7 +336,7 @@ Update the network policy of a sandbox. Arguments: - The ID of the sandbox to execute the command in + The name of the sandbox Options: diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index dea868f4..efd0d58b 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "sandbox", "description": "Command line interface for Vercel Sandbox", - "version": "2.5.4", + "version": "3.0.0-beta.1", "scripts": { "clean": "rm -rf node_modules dist", "sandbox": "ts-node ./src/sandbox.ts", diff --git a/packages/sandbox/src/app.ts b/packages/sandbox/src/app.ts index ae155f82..a0348eca 100644 --- a/packages/sandbox/src/app.ts +++ b/packages/sandbox/src/app.ts @@ -5,12 +5,14 @@ import { list } from "./commands/list"; import { exec } from "./commands/exec"; import { connect } from "./commands/connect"; import { stop } from "./commands/stop"; +import { remove } from "./commands/remove"; import { cp } from "./commands/cp"; import { login } from "./commands/login"; import { logout } from "./commands/logout"; import { version } from "./pkg"; import { snapshot } from "./commands/snapshot"; import { snapshots } from "./commands/snapshots"; +import { sessions } from "./commands/sessions"; import { config } from "./commands/config"; export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => @@ -26,9 +28,11 @@ export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => exec, connect, stop, + remove, run, snapshot, snapshots, + sessions, ...(!opts?.withoutAuth && { login, logout, @@ -45,7 +49,7 @@ export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => }, { description: "Execute command in an existing sandbox", - command: `sandbox exec -- npm test`, + command: `sandbox exec -- npm test`, }, ], }); diff --git a/packages/sandbox/src/args/runtime.ts b/packages/sandbox/src/args/runtime.ts index ba31133a..9b51a301 100644 --- a/packages/sandbox/src/args/runtime.ts +++ b/packages/sandbox/src/args/runtime.ts @@ -1,11 +1,13 @@ import * as cmd from "cmd-ts"; +export const runtimeType = { + ...cmd.oneOf(["node22", "node24", "python3.13"] as const), + displayName: "runtime", +}; + export const runtime = cmd.option({ long: "runtime", - type: { - ...cmd.oneOf(["node22", "node24", "python3.13"] as const), - displayName: "runtime", - }, + type: runtimeType, defaultValue: () => "node24" as const, defaultValueIsSerializable: true, }); diff --git a/packages/sandbox/src/args/sandbox-id.ts b/packages/sandbox/src/args/sandbox-id.ts deleted file mode 100644 index d48dadbf..00000000 --- a/packages/sandbox/src/args/sandbox-id.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as cmd from "cmd-ts"; -import chalk from "chalk"; - -export const sandboxId = cmd.extendType(cmd.string, { - displayName: "sandbox_id", - description: "The ID of the sandbox to execute the command in", - async from(s) { - if (!s.startsWith("sbx_")) { - throw new Error( - [ - `Malformed sandbox ID: "${s}".`, - `${chalk.bold("hint:")} Sandbox IDs must start with 'sbx_' (e.g., sbx_abc123def456).`, - "╰▶ run `sandbox list` to see available sandboxes.", - ].join("\n"), - ); - } - - return s; - }, -}); diff --git a/packages/sandbox/src/args/sandbox-name.ts b/packages/sandbox/src/args/sandbox-name.ts new file mode 100644 index 00000000..725cd8de --- /dev/null +++ b/packages/sandbox/src/args/sandbox-name.ts @@ -0,0 +1,20 @@ +import * as cmd from "cmd-ts"; +import chalk from "chalk"; + +export const sandboxName = cmd.extendType(cmd.string, { + displayName: "name", + description: "The name of the sandbox", + async from(s) { + if (!s || s.trim().length === 0) { + throw new Error( + [ + `Sandbox name cannot be empty.`, + `${chalk.bold("hint:")} Provide a sandbox name.`, + "╰▶ run `sandbox list` to see available sandboxes.", + ].join("\n"), + ); + } + + return s; + }, +}); diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index 4f5a78d8..545cc402 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -12,7 +12,7 @@ import { z } from "zod"; */ export const sandboxClient: Pick = { get: (params) => - withErrorHandling(Sandbox.get({ fetch: fetchWithUserAgent, ...params })), + withErrorHandling(Sandbox.get({ fetch: fetchWithUserAgent, resume: false, ...params })), create: (params) => withErrorHandling(Sandbox.create({ fetch: fetchWithUserAgent, ...params })), list: (params) => diff --git a/packages/sandbox/src/commands/config.ts b/packages/sandbox/src/commands/config.ts index 737ff9a2..3cf82d8d 100644 --- a/packages/sandbox/src/commands/config.ts +++ b/packages/sandbox/src/commands/config.ts @@ -1,6 +1,6 @@ import * as cmd from "cmd-ts"; -import { Sandbox } from "@vercel/sandbox"; -import { sandboxId } from "../args/sandbox-id"; +import type { Sandbox } from "@vercel/sandbox"; +import { sandboxName } from "../args/sandbox-name"; import { scope } from "../args/scope"; import { sandboxClient } from "../client"; import { @@ -8,8 +8,150 @@ import { networkPolicyMode as networkPolicyModeType, } from "../args/network-policy"; import { buildNetworkPolicy, resolveMode } from "../util/network-policy"; +import { runtimeType } from "../args/runtime"; +import { vcpus } from "../args/vcpus"; +import { Duration } from "../types/duration"; import ora from "ora"; import chalk from "chalk"; +import ms from "ms"; +import { table } from "../util/output"; +import { acquireRelease } from "../util/disposables"; + +const setCommand = cmd.command({ + name: "set", + description: "Update the configuration of a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + vcpus, + runtime: cmd.option({ + long: "runtime", + type: cmd.optional(runtimeType), + description: "Runtime to use: node22, node24, or python3.13", + }), + timeout: cmd.option({ + long: "timeout", + type: cmd.optional(Duration), + description: "The maximum duration a sandbox can run for. Example: 5m, 1h", + }), + ...networkPolicyArgs, + scope, + }, + async handler({ + scope: { token, team, project }, + sandbox: name, + vcpus, + runtime, + timeout, + networkPolicy: networkPolicyMode, + allowedDomains, + allowedCIDRs, + deniedCIDRs, + }) { + const hasNetworkPolicyArgs = + networkPolicyMode !== undefined || + allowedDomains.length > 0 || + allowedCIDRs.length > 0 || + deniedCIDRs.length > 0; + + if ( + vcpus === undefined && + runtime === undefined && + timeout === undefined && + !hasNetworkPolicyArgs + ) { + throw new Error( + [ + `At least one option must be provided.`, + `${chalk.bold("hint:")} Use --vcpus, --runtime, --timeout, or --network-policy to update the sandbox configuration.`, + ].join("\n"), + ); + } + + const networkPolicy = hasNetworkPolicyArgs + ? buildNetworkPolicy({ + networkPolicy: networkPolicyMode, + allowedDomains, + allowedCIDRs, + deniedCIDRs, + }) + : undefined; + + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + await sandbox.update({ + ...(vcpus !== undefined && { resources: { vcpus } }), + ...(runtime !== undefined && { runtime }), + ...(timeout !== undefined && { timeout: ms(timeout) }), + ...(networkPolicy !== undefined && { networkPolicy }), + }); + spinner.succeed( + "Configuration updated for sandbox " + chalk.cyan(name), + ); + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + +const getCommand = cmd.command({ + name: "get", + description: "Display the current configuration of a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to inspect", + }), + scope, + }, + async handler({ scope: { token, team, project }, sandbox: name }) { + const sandbox = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching sandbox configuration...").start(), + (s) => s.stop(), + ); + return sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + })(); + + const memoryFormatter = new Intl.NumberFormat(undefined, { + style: "unit", + unit: "megabyte", + }); + + const rows = [ + { field: "Runtime", value: sandbox.runtime }, + { field: "vCPUs", value: String(sandbox.vcpus) }, + { field: "Memory", value: memoryFormatter.format(sandbox.memory) }, + { field: "Timeout", value: ms(sandbox.timeout, { long: true }) }, + { field: "Region", value: sandbox.region }, + ]; + + console.log( + table({ + rows, + columns: { + FIELD: { value: (r) => r.field, color: () => chalk.bold }, + VALUE: { value: (r) => r.value }, + }, + }), + ); + }, +}); const networkPolicyCommand = cmd.command({ name: "network-policy", @@ -17,7 +159,7 @@ const networkPolicyCommand = cmd.command({ This will fully override the previous configuration.`, args: { sandbox: cmd.positional({ - type: sandboxId as cmd.Type, + type: sandboxName as cmd.Type, }), ...networkPolicyArgs, mode: cmd.option({ @@ -29,13 +171,19 @@ const networkPolicyCommand = cmd.command({ }, async handler({ scope: { token, team, project }, - sandbox: sandboxId, + sandbox: sandboxName, networkPolicy: networkPolicyFlag, mode: modeFlag, allowedDomains, allowedCIDRs, deniedCIDRs, }) { + process.stderr.write( + chalk.yellow( + "Warning: 'config network-policy' is deprecated. Use 'config set --network-policy=...' instead.\n", + ), + ); + const networkPolicyMode = resolveMode(networkPolicyFlag, modeFlag); if ( @@ -55,28 +203,15 @@ const networkPolicyCommand = cmd.command({ }); const sandbox = - typeof sandboxId !== "string" - ? sandboxId + typeof sandboxName !== "string" + ? sandboxName : await sandboxClient.get({ - name: sandboxId, + name: sandboxName, projectId: project, teamId: team, token, }); - if (!["pending", "running"].includes(sandbox.status)) { - console.error( - [ - `Sandbox ${sandbox.name} is not available (status: ${sandbox.status}).`, - `${chalk.bold("hint:")} Only 'pending' or 'running' sandboxes can execute commands.`, - "├▶ Use `sandbox list` to check sandbox status.", - "╰▶ Use `sandbox create` to create a new sandbox.", - ].join("\n"), - ); - process.exitCode = 1; - return; - } - const spinner = ora("Updating network policy...").start(); try { const response = await sandbox.updateNetworkPolicy(networkPolicy); @@ -100,8 +235,10 @@ const networkPolicyCommand = cmd.command({ export const config = cmd.subcommands({ name: "config", - description: "Update a sandbox configuration", + description: "View and update sandbox configuration", cmds: { + set: setCommand, + get: getCommand, "network-policy": networkPolicyCommand, }, }); diff --git a/packages/sandbox/src/commands/cp.ts b/packages/sandbox/src/commands/cp.ts index 65d02419..945b7626 100644 --- a/packages/sandbox/src/commands/cp.ts +++ b/packages/sandbox/src/commands/cp.ts @@ -1,6 +1,6 @@ import { sandboxClient } from "../client"; import * as cmd from "cmd-ts"; -import { sandboxId } from "../args/sandbox-id"; +import { sandboxName } from "../args/sandbox-name"; import fs from "node:fs/promises"; import path from "node:path"; import { scope } from "../args/scope"; @@ -18,12 +18,12 @@ export const parseLocalOrRemotePath = async (input: string) => { throw new Error( [ `Invalid copy path format: "${input}".`, - `${chalk.bold("hint:")} Expected format: SANDBOX_ID:PATH (e.g., sbx_abc123:/home/user/file.txt).`, + `${chalk.bold("hint:")} Expected format: SANDBOX_NAME:PATH (e.g., my-sandbox:/home/user/file.txt).`, "╰▶ Local paths should not contain colons.", ].join("\n"), ); } - return { type: "remote", sandboxId: await sandboxId.from(id), path } as const; + return { type: "remote", sandboxName: await sandboxName.from(id), path } as const; } return { type: "local", path: input } as const; @@ -40,19 +40,19 @@ export const cp = cmd.command({ args: { source: cmd.positional({ displayName: `src`, - description: `The source file to copy from local file system, or or a sandbox_id:path from a remote sandbox`, + description: `The source file to copy from local file system, or a sandbox_name:path from a remote sandbox`, type: localOrRemote, }), dest: cmd.positional({ displayName: `dst`, - description: `The destination file to copy to local file system, or or a sandbox_id:path to a remote sandbox`, + description: `The destination file to copy to local file system, or a sandbox_name:path to a remote sandbox`, type: localOrRemote, }), scope, }, async handler({ scope, source, dest }) { const spinner = ora({ text: `Reading source file (${source.path})...` }).start(); - let sourceFile: Buffer | null = null; + let sourceFile: Buffer | null = null; if (source.type === "local") { sourceFile = await fs.readFile(source.path).catch((err) => { @@ -63,7 +63,7 @@ export const cp = cmd.command({ }) } else { const sandbox = await sandboxClient.get({ - name: source.sandboxId, + name: source.sandboxName, teamId: scope.team, token: scope.token, projectId: scope.project, @@ -79,8 +79,8 @@ export const cp = cmd.command({ const dir = path.dirname(source.path); spinner.fail( [ - `File not found: ${source.path} in sandbox ${source.sandboxId}.`, - `${chalk.bold("hint:")} Verify the file path exists using \`sandbox exec ${source.sandboxId} ls ${dir}\`.`, + `File not found: ${source.path} in sandbox ${source.sandboxName}.`, + `${chalk.bold("hint:")} Verify the file path exists using \`sandbox exec ${source.sandboxName} ls ${dir}\`.`, ].join("\n"), ); } else { @@ -95,7 +95,7 @@ export const cp = cmd.command({ await fs.writeFile(dest.path, sourceFile); } else { const sandbox = await sandboxClient.get({ - name: dest.sandboxId, + name: dest.sandboxName, teamId: scope.team, projectId: scope.project, token: scope.token, diff --git a/packages/sandbox/src/commands/create.ts b/packages/sandbox/src/commands/create.ts index 2f6bb83e..2e0007e9 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -14,6 +14,15 @@ import { buildNetworkPolicy } from "../util/network-policy"; import { ObjectFromKeyValue } from "../args/key-value-pair"; export const args = { + name: cmd.option({ + long: "name", + description: "A user-chosen name for the sandbox. It must be unique per project.", + type: cmd.optional(cmd.string), + }), + nonPersistent: cmd.flag({ + long: "non-persistent", + description: "Disable automatic restore of the filesystem between sessions.", + }), runtime, timeout, vcpus, @@ -41,7 +50,7 @@ export const args = { }), silent: cmd.flag({ long: "silent", - description: "Don't write sandbox ID to stdout", + description: "Don't write sandbox name to stdout", }), snapshot: cmd.option({ long: "snapshot", @@ -75,6 +84,8 @@ export const create = cmd.command({ }, ], async handler({ + name, + nonPersistent, ports, scope, runtime, @@ -96,10 +107,12 @@ export const create = cmd.command({ deniedCIDRs, }); + const persistent = !nonPersistent const resources = vcpus ? { vcpus } : undefined; const spinner = silent ? undefined : ora("Creating sandbox...").start(); const sandbox = snapshot ? await sandboxClient.create({ + name, source: { type: "snapshot", snapshotId: snapshot }, teamId: scope.team, projectId: scope.project, @@ -109,9 +122,11 @@ export const create = cmd.command({ resources, networkPolicy, env: envVars, + persistent, __interactive: true, }) : await sandboxClient.create({ + name, teamId: scope.team, projectId: scope.project, token: scope.token, @@ -121,6 +136,7 @@ export const create = cmd.command({ resources, networkPolicy, env: envVars, + persistent, __interactive: true, }); spinner?.stop(); diff --git a/packages/sandbox/src/commands/exec.ts b/packages/sandbox/src/commands/exec.ts index fee4381e..065cd6ee 100644 --- a/packages/sandbox/src/commands/exec.ts +++ b/packages/sandbox/src/commands/exec.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; import * as cmd from "cmd-ts"; -import { sandboxId } from "../args/sandbox-id"; +import { sandboxName } from "../args/sandbox-name"; import { isatty } from "node:tty"; import { startInteractiveShell } from "../interactive-shell/interactive-shell"; import { printCommand } from "../util/print-command"; @@ -11,7 +11,7 @@ import chalk from "chalk"; export const args = { sandbox: cmd.positional({ - type: sandboxId as cmd.Type, + type: sandboxName as cmd.Type, }), command: cmd.positional({ displayName: "command", @@ -80,36 +80,23 @@ export const exec = cmd.command({ cwd, args, asSudo, - sandbox: sandboxId, + sandbox: sandboxName, scope: { token, team, project }, interactive, envVars, skipExtendingTimeout, }) { const sandbox = - typeof sandboxId !== "string" - ? sandboxId + typeof sandboxName !== "string" + ? sandboxName : await sandboxClient.get({ - name: sandboxId, + name: sandboxName, projectId: project, teamId: team, token, __includeSystemRoutes: true, }); - if (!["pending", "running"].includes(sandbox.status)) { - console.error( - [ - `Sandbox ${sandbox.name} is not available (status: ${sandbox.status}).`, - `${chalk.bold("hint:")} Only 'pending' or 'running' sandboxes can execute commands.`, - "├▶ Use `sandbox list` to check sandbox status.", - "╰▶ Use `sandbox create` to create a new sandbox.", - ].join("\n"), - ); - process.exitCode = 1; - return; - } - if (!interactive) { console.error(printCommand(command, args)); const result = await sandbox.runCommand({ diff --git a/packages/sandbox/src/commands/list.ts b/packages/sandbox/src/commands/list.ts index f62a1bc6..dbf326fb 100644 --- a/packages/sandbox/src/commands/list.ts +++ b/packages/sandbox/src/commands/list.ts @@ -17,9 +17,21 @@ export const list = cmd.command({ short: "a", description: "Show all sandboxes (default shows just running)", }), + namePrefix: cmd.option({ + long: "name-prefix", + description: "Filter sandboxes by name prefix", + type: cmd.optional(cmd.string), + }), + sortBy: cmd.option({ + long: "sort-by", + description: "Sort sandboxes by field. Options: createdAt (default), name", + type: cmd.optional( + cmd.oneOf(["createdAt", "name"] as const), + ), + }), scope, }, - async handler({ scope: { token, team, project }, all }) { + async handler({ scope: { token, team, project }, all, namePrefix, sortBy }) { const sandboxes = await (async () => { using _spinner = acquireRelease( () => ora("Fetching sandboxes...").start(), @@ -31,6 +43,8 @@ export const list = cmd.command({ teamId: team, projectId: project, limit: 100, + ...(namePrefix && { namePrefix }), + ...(sortBy && { sortBy }), }); let sandboxes = json.sandboxes; @@ -51,7 +65,7 @@ export const list = cmd.command({ type Column = { value: (s: SandboxRow) => string | number; color?: (s: SandboxRow) => ChalkInstance }; const columns: Record = { - ID: { value: (s) => s.name }, + NAME: { value: (s) => s.name }, STATUS: { value: (s) => s.status, color: (s) => SandboxStatusColor[s.status] ?? chalk.reset, diff --git a/packages/sandbox/src/commands/remove.ts b/packages/sandbox/src/commands/remove.ts new file mode 100644 index 00000000..241e791b --- /dev/null +++ b/packages/sandbox/src/commands/remove.ts @@ -0,0 +1,49 @@ +import * as cmd from "cmd-ts"; +import { Listr } from "listr2"; +import { sandboxName } from "../args/sandbox-name"; +import { scope } from "../args/scope"; +import { sandboxClient } from "../client"; + +export const remove = cmd.command({ + name: "remove", + aliases: ["rm"], + description: "Permanently remove one or more sandboxes", + args: { + sandboxName: cmd.positional({ + type: sandboxName, + description: "a sandbox name to remove", + }), + sandboxNames: cmd.restPositionals({ + type: sandboxName, + description: "more sandboxes to remove", + }), + preserveSnapshots: cmd.flag({ + long: "preserve-snapshots", + description: "Keep snapshots when removing the sandbox", + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandboxName, + sandboxNames, + preserveSnapshots, + }) { + const tasks = Array.from( + new Set([sandboxName, ...sandboxNames]), + (name) => ({ + title: `Removing sandbox ${name}`, + async task() { + const sandbox = await sandboxClient.get({ + token, + teamId: team, + projectId: project, + name, + }); + await sandbox.delete({ preserveSnapshots }); + }, + }), + ); + await new Listr(tasks, { concurrent: true }).run(); + }, +}); diff --git a/packages/sandbox/src/commands/run.ts b/packages/sandbox/src/commands/run.ts index 4184bc03..95822819 100644 --- a/packages/sandbox/src/commands/run.ts +++ b/packages/sandbox/src/commands/run.ts @@ -1,6 +1,9 @@ import * as cmd from "cmd-ts"; +import { APIError, type Sandbox } from "@vercel/sandbox"; import * as Create from "./create"; import * as Exec from "./exec"; +import { sandboxClient } from "../client"; +import { StyledError } from "../error"; import { omit } from "../util/omit"; const args = { @@ -17,12 +20,35 @@ export const run = cmd.command({ description: "Create and run a command in a sandbox", args, async handler({ removeAfterUse, ...rest }) { - const sandbox = await Create.create.handler({ ...rest }); + let sandbox: Sandbox; + + // Resume an existing sandbox or otherwise create it. + if (rest.name) { + try { + sandbox = await sandboxClient.get({ + name: rest.name, + projectId: rest.scope.project, + teamId: rest.scope.team, + token: rest.scope.token, + resume: true, + __includeSystemRoutes: true, + }); + } catch (error) { + if (error instanceof StyledError && error.cause instanceof APIError && error.cause.response.status === 404) { + sandbox = await Create.create.handler({ ...rest }); + } else { + throw error; + } + } + } else { + sandbox = await Create.create.handler({ ...rest }); + } + try { await Exec.exec.handler({ ...rest, sandbox }); } finally { if (removeAfterUse) { - await sandbox.stop(); + await sandbox.delete(); } } }, diff --git a/packages/sandbox/src/commands/sessions.ts b/packages/sandbox/src/commands/sessions.ts new file mode 100644 index 00000000..a7981448 --- /dev/null +++ b/packages/sandbox/src/commands/sessions.ts @@ -0,0 +1,82 @@ +import * as cmd from "cmd-ts"; +import { subcommands } from "cmd-ts"; +import chalk, { type ChalkInstance } from "chalk"; +import ora from "ora"; +import { sandboxName } from "../args/sandbox-name"; +import { scope } from "../args/scope"; +import { sandboxClient } from "../client"; +import { acquireRelease } from "../util/disposables"; +import { table, timeAgo, formatRunDuration } from "../util/output"; +import type { Sandbox } from "@vercel/sandbox"; + +const list = cmd.command({ + name: "list", + aliases: ["ls"], + description: "List sessions from a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to list sessions for", + }), + scope, + }, + async handler({ scope: { token, team, project }, sandbox: name }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const sessionData = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching sessions...").start(), + (s) => s.stop(), + ); + return sandbox.listSessions(); + })(); + + const sessions = sessionData.json.sandboxes; + + console.log( + table({ + rows: sessions, + columns: { + ID: { value: (s) => s.id }, + STATUS: { + value: (s) => s.status, + color: (s) => SessionStatusColor[s.status] ?? chalk.reset, + }, + CREATED: { value: (s) => timeAgo(s.createdAt) }, + MEMORY: { value: (s) => s.memory }, + VCPUS: { value: (s) => s.vcpus }, + RUNTIME: { value: (s) => s.runtime }, + TIMEOUT: { + value: (s) => timeAgo(s.createdAt + s.timeout), + }, + DURATION: { + value: (s) => s.duration ? formatRunDuration(s.duration) : "-", + }, + }, + }), + ); + }, +}); + +export const sessions = subcommands({ + name: "sessions", + description: "Manage sandbox sessions", + cmds: { + list, + }, +}); + +const SessionStatusColor: Record = { + running: chalk.cyan, + failed: chalk.red, + stopped: chalk.gray.dim, + stopping: chalk.gray, + pending: chalk.magenta, + snapshotting: chalk.blue, + aborted: chalk.gray.dim, +}; diff --git a/packages/sandbox/src/commands/snapshot.ts b/packages/sandbox/src/commands/snapshot.ts index 38f72087..bb03f594 100644 --- a/packages/sandbox/src/commands/snapshot.ts +++ b/packages/sandbox/src/commands/snapshot.ts @@ -1,5 +1,5 @@ import * as cmd from "cmd-ts"; -import { sandboxId } from "../args/sandbox-id"; +import { sandboxName } from "../args/sandbox-name"; import { Sandbox } from "@vercel/sandbox"; import { scope } from "../args/scope"; import { sandboxClient } from "../client"; @@ -23,7 +23,7 @@ export const args = { description: "The expiration time of the snapshot. Use 0 for no expiration.", }), sandbox: cmd.positional({ - type: sandboxId as cmd.Type, + type: sandboxName as cmd.Type, }), scope, } as const; @@ -33,7 +33,7 @@ export const snapshot = cmd.command({ description: "Take a snapshot of the filesystem of a sandbox", args, async handler({ - sandbox: sandboxId, + sandbox: sandboxName, stop, scope: { token, team, project }, silent, @@ -42,7 +42,7 @@ export const snapshot = cmd.command({ if (!stop) { console.error( [ - "Snapshotting a sandbox will automatically stop it.", + "Snapshotting will stop the current session of this sandbox.", `${chalk.bold("hint:")} Confirm with --stop to continue.`, ].join("\n"), ); @@ -51,10 +51,10 @@ export const snapshot = cmd.command({ } const sandbox = - typeof sandboxId !== "string" - ? sandboxId + typeof sandboxName !== "string" + ? sandboxName : await sandboxClient.get({ - name: sandboxId, + name: sandboxName, projectId: project, teamId: team, token, diff --git a/packages/sandbox/src/commands/snapshots.ts b/packages/sandbox/src/commands/snapshots.ts index 41fb3e5d..1a7b2f30 100644 --- a/packages/sandbox/src/commands/snapshots.ts +++ b/packages/sandbox/src/commands/snapshots.ts @@ -1,7 +1,7 @@ import * as cmd from "cmd-ts"; import { subcommands } from "cmd-ts"; import { Listr } from "listr2"; -import chalk, { ChalkInstance } from "chalk"; +import chalk, { type ChalkInstance } from "chalk"; import ora from "ora"; import { scope } from "../args/scope"; import { snapshotId } from "../args/snapshot-id"; @@ -48,7 +48,7 @@ const list = cmd.command({ : timeAgo(s.expiresAt), }, SIZE: { value: (s) => formatBytes(s.sizeBytes) }, - ["SOURCE SANDBOX"]: { value: (s) => s.sourceSandboxId }, + ["SOURCE SESSION"]: { value: (s) => s.sourceSandboxId }, }, }), ); @@ -62,7 +62,7 @@ const get = cmd.command({ scope, snapshotId: cmd.positional({ type: snapshotId, - description: "snapshot ID to retrieve", + description: "Snapshot ID to retrieve", }), }, async handler({ scope: { token, team, project }, snapshotId: id }) { @@ -91,7 +91,7 @@ const get = cmd.command({ CREATED: { value: (s) => timeAgo(s.createdAt) }, EXPIRATION: { value: (s) => s.status === 'deleted' ? chalk.gray.dim('deleted') : timeAgo(s.expiresAt) }, SIZE: { value: (s) => formatBytes(s.sizeBytes) }, - ["SOURCE SANDBOX"]: { value: (s) => s.sourceSandboxId }, + ["SOURCE SESSION"]: { value: (s) => s.sourceSandboxId }, }, }), ); @@ -105,7 +105,7 @@ const remove = cmd.command({ args: { snapshotId: cmd.positional({ type: snapshotId, - description: "snapshot ID to delete", + description: "Snapshot ID to delete", }), snapshotIds: cmd.restPositionals({ type: snapshotId, diff --git a/packages/sandbox/src/commands/stop.ts b/packages/sandbox/src/commands/stop.ts index 48c6c9f3..45b31c99 100644 --- a/packages/sandbox/src/commands/stop.ts +++ b/packages/sandbox/src/commands/stop.ts @@ -1,36 +1,35 @@ import * as cmd from "cmd-ts"; import { Listr } from "listr2"; -import { sandboxId } from "../args/sandbox-id"; +import { sandboxName } from "../args/sandbox-name"; import { scope } from "../args/scope"; import { sandboxClient } from "../client"; export const stop = cmd.command({ name: "stop", - aliases: ["rm", "remove"], - description: "Stop one or more running sandboxes", + description: "Stop the current session of one or more sandboxes", args: { - sandboxId: cmd.positional({ - type: sandboxId, - description: "a sandbox ID to stop", + sandboxName: cmd.positional({ + type: sandboxName, + description: "A sandbox name to stop", }), - sandboxIds: cmd.restPositionals({ - type: sandboxId, - description: "more sandboxes to stop", + sandboxNames: cmd.restPositionals({ + type: sandboxName, + description: "More sandboxes to stop", }), scope, }, - async handler({ scope: { token, team, project }, sandboxId, sandboxIds }) { + async handler({ scope: { token, team, project }, sandboxName, sandboxNames }) { const tasks = Array.from( - new Set([sandboxId, ...sandboxIds]), - (sandboxId) => { + new Set([sandboxName, ...sandboxNames]), + (sandboxName) => { return { - title: `Stopping sandbox ${sandboxId}`, + title: `Stopping sandbox ${sandboxName}`, async task() { const sandbox = await sandboxClient.get({ token, teamId: team, projectId: project, - name: sandboxId, + name: sandboxName, }); await sandbox.stop(); }, diff --git a/packages/sandbox/test/commands/cp.test.ts b/packages/sandbox/test/commands/cp.test.ts index 7daba80e..8e78a7b1 100644 --- a/packages/sandbox/test/commands/cp.test.ts +++ b/packages/sandbox/test/commands/cp.test.ts @@ -11,13 +11,13 @@ describe("copy path parsing", () => { ); }); - test("parses remote paths", async () => { + test("parses remote paths with a sandbox name", async () => { await expect( - parseLocalOrRemotePath("sbx_Z1bhKlvVP1ecxCg2ewRUSU0hg1ik:/etc/os-release"), + parseLocalOrRemotePath("my-sandbox:/home/user/file.txt"), ).resolves.toEqual({ type: "remote", - sandboxId: "sbx_Z1bhKlvVP1ecxCg2ewRUSU0hg1ik", - path: "/etc/os-release", + sandboxName: "my-sandbox", + path: "/home/user/file.txt", }); }); }); diff --git a/packages/vercel-sandbox/CHANGELOG.md b/packages/vercel-sandbox/CHANGELOG.md index 4756b052..658310d0 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -1,5 +1,11 @@ # @vercel/sandbox +## 2.0.0-beta.1 + +### Minor Changes + +- Rename snapshotOnShutdown to persistent + ## 2.0.0-beta.0 ### Major Changes diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index fcd85188..8e033cb6 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/sandbox", - "version": "2.0.0-beta.0", + "version": "2.0.0-beta.1", "description": "Software Development Kit for Vercel Sandbox", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/vercel-sandbox/src/api-client/api-client.test.ts b/packages/vercel-sandbox/src/api-client/api-client.test.ts index 3edfd499..787ae396 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.test.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.test.ts @@ -418,7 +418,7 @@ describe("APIClient", () => { const makeNamedSandbox = () => ({ name: "my-sandbox", - snapshotOnShutdown: true, + persistent: true, region: "iad1", vcpus: 1, memory: 2048, @@ -507,7 +507,7 @@ describe("APIClient", () => { const makeNamedSandbox = (name: string) => ({ name, - snapshotOnShutdown: false, + persistent: false, region: "iad1", vcpus: 1, memory: 2048, @@ -582,7 +582,7 @@ describe("APIClient", () => { const makeNamedSandbox = () => ({ name: "my-sandbox", - snapshotOnShutdown: true, + persistent: true, region: "iad1", vcpus: 2, memory: 4096, @@ -614,7 +614,7 @@ describe("APIClient", () => { const result = await client.updateNamedSandbox({ name: "my-sandbox", projectId: "proj_123", - snapshotOnShutdown: true, + persistent: true, timeout: 600000, }); @@ -626,7 +626,7 @@ describe("APIClient", () => { expect(opts.method).toBe("PATCH"); const parsedBody = JSON.parse(opts.body); - expect(parsedBody.snapshotOnShutdown).toBe(true); + expect(parsedBody.persistent).toBe(true); expect(parsedBody.timeout).toBe(600000); }); }); @@ -637,7 +637,7 @@ describe("APIClient", () => { const makeNamedSandbox = () => ({ name: "my-sandbox", - snapshotOnShutdown: false, + persistent: false, region: "iad1", vcpus: 1, memory: 2048, diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index ae445b2a..c292cd3c 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -169,7 +169,7 @@ export class APIClient extends BaseClient { | { type: "snapshot"; snapshotId: string }; timeout?: number; resources?: { vcpus: number }; - snapshotOnShutdown?: boolean; + persistent?: boolean; runtime?: RUNTIMES | (string & {}); networkPolicy?: NetworkPolicy; env?: Record; @@ -189,7 +189,7 @@ export class APIClient extends BaseClient { resources: params.resources, runtime: params.runtime, name: params.name, - snapshotOnShutdown: params.snapshotOnShutdown, + persistent: params.persistent, networkPolicy: params.networkPolicy ? toAPINetworkPolicy(params.networkPolicy) : undefined, @@ -768,7 +768,7 @@ export class APIClient extends BaseClient { async updateNamedSandbox(params: { name: string; projectId: string; - snapshotOnShutdown?: boolean; + persistent?: boolean; resources?: { vcpus?: number; memory?: number }; runtime?: RUNTIMES | (string & {}); timeout?: number; @@ -783,7 +783,7 @@ export class APIClient extends BaseClient { projectId: params.projectId, }, body: JSON.stringify({ - snapshotOnShutdown: params.snapshotOnShutdown, + persistent: params.persistent, resources: params.resources, runtime: params.runtime, timeout: params.timeout, diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index 5600f8e7..69e2390e 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -186,7 +186,7 @@ export const SnapshotResponse = z.object({ export const NamedSandbox = z.object({ name: z.string(), - snapshotOnShutdown: z.boolean(), + persistent: z.boolean(), region: z.string(), vcpus: z.number(), memory: z.number(), diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 60f4ee85..7ca8288a 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -95,7 +95,7 @@ export interface BaseCreateSandboxParams { /** * Whether to enable snapshots on shutdown. Defaults to true. */ - snapshotOnShutdown?: boolean; + persistent?: boolean; } export type CreateSandboxParams = @@ -167,10 +167,10 @@ export class Sandbox { } /** - * Whether this sandbox snapshots on shutdown. + * Whether the sandbox persists the state. */ - public get snapshotOnShutdown(): boolean { - return this.namedSandbox.snapshotOnShutdown; + public get persistent(): boolean { + return this.namedSandbox.persistent; } /** @@ -358,7 +358,7 @@ export class Sandbox { env: params?.env, signal: params?.signal, name: params?.name, - snapshotOnShutdown: params?.snapshotOnShutdown, + persistent: params?.persistent, ...privateParams, }); diff --git a/packages/vercel-sandbox/src/version.ts b/packages/vercel-sandbox/src/version.ts index e5e0adf6..53dc1cf0 100644 --- a/packages/vercel-sandbox/src/version.ts +++ b/packages/vercel-sandbox/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by inject-version.ts -export const VERSION = "2.0.0-beta.0"; +export const VERSION = "2.0.0-beta.1"; From b4e43036f678be5a76c7671820125f796a2c3b49 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 5 Mar 2026 14:17:05 +0100 Subject: [PATCH 06/41] add debug step to release pipeline (#77) Try to debug why NPM publishing is failing. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 88ffa572..77674463 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build": "turbo run typecheck build", "test": "turbo run test", "version:prepare": "changeset version && turbo run version:bump && pnpm install --no-frozen-lockfile", - "release": "changeset publish" + "release": "changeset publish 2>&1 || echo 'PUBLISH FAILED WITH CODE $?'" }, "lint-staged": { "./{*,{packages,.github}/**/*}.{js,ts}": [ From 04db6fd6e1994e513d1711ac5a48eb07f242581a Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 5 Mar 2026 17:50:48 +0100 Subject: [PATCH 07/41] Named sandboxes revamp config (#78) This PR is a revamp of the update mechanism for CLI/SDK and consolidates it. With the introduction of named sandboxes, we had two types of updates: - Update the sandbox configuration. This will be applied during the next session. - Update the current session: only `network-policy` and `extend-timeout` are supported. Other notes: - During the last PR, I did not run `pnpm version:prepare` with the latest changes and there are modifications in `packages/sandbox/docs/index.md` from existing code that has already been merged. SDK Changes --- - Renamed the session command from `updateNetworkPolicy` to `update`. Not a breaking change because this was introduced in the beta. By moving it to `update` instead of `updateNetworkPolicy`, it gives us room to add new parameters to update the session from without having to do any future breaking change. - Deprecate `updateNetworkPolicy` from the sandbox. This is an existing method prior to the beta and we are just deprecating it - no breaking change. Use `update` instead. - New method `update` that allows you to update `vcpus`, `timeout`, `network-policy`, `persistent`. In the case of `network-policy`, it will update both the sandbox and the current session. For the other parameters, we only support updating the sandbox. New CLI commands --- These new commands extend the already existing `sandbox config set network-policy `: - `sandbox config list` (inspired from `git config --list`). Lists all the configuration parameters from a sandbox that can be changed with `sandbox config set`. - `sandbox config set vcpus ` - `sandbox config set timeout