diff --git a/.changeset/beige-dogs-melt.md b/.changeset/beige-dogs-melt.md new file mode 100644 index 00000000..714efa39 --- /dev/null +++ b/.changeset/beige-dogs-melt.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Add L7 request matchers and forward URLs support to network policy rules. diff --git a/.changeset/bumpy-keys-try.md b/.changeset/bumpy-keys-try.md new file mode 100644 index 00000000..2729cb14 --- /dev/null +++ b/.changeset/bumpy-keys-try.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Rebase from main diff --git a/.changeset/chilly-ducks-crash.md b/.changeset/chilly-ducks-crash.md new file mode 100644 index 00000000..b0fa3a82 --- /dev/null +++ b/.changeset/chilly-ducks-crash.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Support pagination (CLI and SDK) when listing sandboxes, snapshots, sessions diff --git a/.changeset/clean-jeans-build.md b/.changeset/clean-jeans-build.md new file mode 100644 index 00000000..560594c2 --- /dev/null +++ b/.changeset/clean-jeans-build.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Default to `--sort-by=name` when using `--name-prefix` in `sandbox ls` diff --git a/.changeset/cold-falcons-read.md b/.changeset/cold-falcons-read.md new file mode 100644 index 00000000..bdb9befd --- /dev/null +++ b/.changeset/cold-falcons-read.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Fix resume race-condition diff --git a/.changeset/common-corners-leave.md b/.changeset/common-corners-leave.md new file mode 100644 index 00000000..e1d60f14 --- /dev/null +++ b/.changeset/common-corners-leave.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Add support for patch + delete v2 endpoints for named sandboxes. diff --git a/.changeset/cyan-bats-clap.md b/.changeset/cyan-bats-clap.md new file mode 100644 index 00000000..ada8ac93 --- /dev/null +++ b/.changeset/cyan-bats-clap.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": patch +"sandbox": patch +--- + +Add support for tags diff --git a/.changeset/dirty-cows-talk.md b/.changeset/dirty-cows-talk.md new file mode 100644 index 00000000..e70e4caa --- /dev/null +++ b/.changeset/dirty-cows-talk.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Rename sandbox to session, namedSandbox to sandbox diff --git a/.changeset/empty-trains-tie.md b/.changeset/empty-trains-tie.md new file mode 100644 index 00000000..921912e7 --- /dev/null +++ b/.changeset/empty-trains-tie.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": patch +--- + +Fix an 422 error when trying to resume a sandbox after snapshotting 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/fresh-pumas-own.md b/.changeset/fresh-pumas-own.md new file mode 100644 index 00000000..6ecef7ac --- /dev/null +++ b/.changeset/fresh-pumas-own.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Support default snapshot expiration for persistent sandboxes diff --git a/.changeset/fresh-rings-rescue.md b/.changeset/fresh-rings-rescue.md new file mode 100644 index 00000000..7d8a9140 --- /dev/null +++ b/.changeset/fresh-rings-rescue.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Refactor the sandbox update and deprecate old network-policy update diff --git a/.changeset/funny-areas-boil.md b/.changeset/funny-areas-boil.md new file mode 100644 index 00000000..fbd97278 --- /dev/null +++ b/.changeset/funny-areas-boil.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": patch +"sandbox": patch +--- + +Add Node 26 support. diff --git a/.changeset/giant-dogs-report.md b/.changeset/giant-dogs-report.md new file mode 100644 index 00000000..05c0a74a --- /dev/null +++ b/.changeset/giant-dogs-report.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Support a new method: Sandbox.getOrCreate() diff --git a/.changeset/legal-rings-work.md b/.changeset/legal-rings-work.md new file mode 100644 index 00000000..2794045b --- /dev/null +++ b/.changeset/legal-rings-work.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": patch +--- + +Add support for patch + delete v2 endpoints for named sandboxes. 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/open-planets-joke.md b/.changeset/open-planets-joke.md new file mode 100644 index 00000000..7e921660 --- /dev/null +++ b/.changeset/open-planets-joke.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Automatically scale memory to vcpu when updating diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..e77d155b --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,48 @@ +{ + "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", + "workflow-code-runner": "0.1.3" + }, + "changesets": [ + "beige-dogs-melt", + "bumpy-keys-try", + "chilly-ducks-crash", + "clean-jeans-build", + "cold-falcons-read", + "common-corners-leave", + "cyan-bats-clap", + "dirty-cows-talk", + "empty-trains-tie", + "fair-pears-dream", + "fresh-pumas-own", + "fresh-rings-rescue", + "funny-areas-boil", + "giant-dogs-report", + "legal-rings-work", + "light-results-change", + "open-planets-joke", + "public-ears-heal", + "seven-olives-mate", + "slick-colts-learn", + "some-numbers-arrive", + "sour-rocks-grin", + "stale-hotels-grow", + "tangy-schools-like", + "tender-experts-cover", + "tough-roses-vanish", + "wet-goats-jam", + "whole-berries-juggle" + ] +} diff --git a/.changeset/public-ears-heal.md b/.changeset/public-ears-heal.md new file mode 100644 index 00000000..530b126d --- /dev/null +++ b/.changeset/public-ears-heal.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Move to cursor pagination. Support new sortyBy parameter for lists. Support new statusUpdatedAt filter diff --git a/.changeset/seven-olives-mate.md b/.changeset/seven-olives-mate.md new file mode 100644 index 00000000..71329e54 --- /dev/null +++ b/.changeset/seven-olives-mate.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": patch +"sandbox": patch +--- + +Fix bug where the first ssh connection hang diff --git a/.changeset/slick-colts-learn.md b/.changeset/slick-colts-learn.md new file mode 100644 index 00000000..b348c662 --- /dev/null +++ b/.changeset/slick-colts-learn.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Remove support for blocking parameter in .stop() and default to always blocking. Improve CLI output when stopping a sandbox. diff --git a/.changeset/some-numbers-arrive.md b/.changeset/some-numbers-arrive.md new file mode 100644 index 00000000..61b942e8 --- /dev/null +++ b/.changeset/some-numbers-arrive.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Improve timeout hour format and example values diff --git a/.changeset/sour-rocks-grin.md b/.changeset/sour-rocks-grin.md new file mode 100644 index 00000000..10632ce1 --- /dev/null +++ b/.changeset/sour-rocks-grin.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": patch +"sandbox": patch +--- + +Support updading current-snapshot-id of an existing sandbox 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/.changeset/tangy-schools-like.md b/.changeset/tangy-schools-like.md new file mode 100644 index 00000000..3311f6a8 --- /dev/null +++ b/.changeset/tangy-schools-like.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Lists now unwrap the json and return the items and pagination fields directly diff --git a/.changeset/tender-experts-cover.md b/.changeset/tender-experts-cover.md new file mode 100644 index 00000000..9019ca24 --- /dev/null +++ b/.changeset/tender-experts-cover.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Add support for updating tags and displaying tags in config. diff --git a/.changeset/tough-roses-vanish.md b/.changeset/tough-roses-vanish.md new file mode 100644 index 00000000..465f189f --- /dev/null +++ b/.changeset/tough-roses-vanish.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Support new onResume parameter in Sandbox.create and Sandbox.get diff --git a/.changeset/wet-goats-jam.md b/.changeset/wet-goats-jam.md new file mode 100644 index 00000000..9f23be30 --- /dev/null +++ b/.changeset/wet-goats-jam.md @@ -0,0 +1,5 @@ +--- +"sandbox": minor +--- + +support new --name option for snapshots list, support new --stop option for run diff --git a/.changeset/whole-berries-juggle.md b/.changeset/whole-berries-juggle.md new file mode 100644 index 00000000..e6045418 --- /dev/null +++ b/.changeset/whole-berries-juggle.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": patch +"sandbox": patch +--- + +Fix JsDocs, messages and double-error message bug diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f6e9e057..67cf19cb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - named-sandboxes pull_request: env: @@ -42,9 +43,23 @@ 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' id: changesets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -54,7 +69,7 @@ jobs: commitMode: github-api - name: Trigger Vercel CLI Sandbox Sync - if: steps.changesets.outputs.published == 'true' + if: github.ref == 'refs/heads/main' && steps.changesets.outputs.published == 'true' uses: actions/github-script@v7 env: PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} diff --git a/examples/ai-example/app/actions.tsx b/examples/ai-example/app/actions.tsx index 02342cd8..21ef09c1 100644 --- a/examples/ai-example/app/actions.tsx +++ b/examples/ai-example/app/actions.tsx @@ -10,18 +10,18 @@ export async function createSandbox() { }); return { - id: sandbox.sandboxId, + id: sandbox.name, routes: sandbox.routes, url: sandbox.domain(3000), }; } export async function uploadFiles(params: { - sandboxId: string; + sandboxName: string; files: { path: string; content: string }[]; }) { const sandbox = await Sandbox.get({ - sandboxId: params.sandboxId, + name: params.sandboxName, }); const files = params.files.map((file) => ({ @@ -56,11 +56,11 @@ export async function uploadFiles(params: { export async function runCommand(params: { args: string[]; cmd: string; - sandboxId: string; + sandboxName: string; detached?: boolean; }) { const sandbox = await Sandbox.get({ - sandboxId: params.sandboxId, + name: params.sandboxName, }); 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..f5ff0198 100644 --- a/examples/ai-example/app/api/logs/route.ts +++ b/examples/ai-example/app/api/logs/route.ts @@ -5,16 +5,16 @@ export const maxDuration = 120; export async function POST(request: Request) { const body = await request.json(); const cmdId = body.cmdId; - const sandboxId = body.sandboxId; + const sandboxName = body.sandboxName; - if (!cmdId || !sandboxId) { + if (!cmdId || !sandboxName) { return new Response("Missing required parameters", { status: 400 }); } const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { - const sandbox = await Sandbox.get({ sandboxId }); + const sandbox = await Sandbox.get({ name: sandboxName }); const command = await sandbox.getCommand(cmdId); for await (const log of command.logs()) { diff --git a/examples/ai-example/app/page.tsx b/examples/ai-example/app/page.tsx index 9b40046e..3e0ac48e 100644 --- a/examples/ai-example/app/page.tsx +++ b/examples/ai-example/app/page.tsx @@ -15,11 +15,11 @@ export default function SplitScreenChatOptimized() { const [input, setInput] = useState(""); const [pending, startTransition] = useTransition(); const [logs, setLogs] = useState([]); - const [sandboxId, setSandboxId] = useState(null); + const [sandboxName, setSandboxName] = useState(null); const [renderPreview, setRenderPreview] = useState(false); const isReadyRef = useRef(false); - const sandboxIdRef = useRef(null); + const sandboxNameRef = useRef(null); const sandboxRoutesRef = useRef<{ subdomain: string; port: number }[] | null>( null, ); @@ -45,41 +45,41 @@ export default function SplitScreenChatOptimized() { onFinish(message) { const msg = message.message.parts.find((part) => part.type === "text"); startTransition(async () => { - if (sandboxIdRef.current && sandboxRoutesRef.current && msg) { + if (sandboxNameRef.current && sandboxRoutesRef.current && msg) { await uploadFiles({ files: extractCodeBlocks(msg!.text), - sandboxId: sandboxIdRef.current, + sandboxName: sandboxNameRef.current, }); if (!isReadyRef.current) { isReadyRef.current = true; const install = await runCommand({ - sandboxId: sandboxIdRef.current, + sandboxName: sandboxNameRef.current, cmd: "npm", args: ["install", "--loglevel", "info"], detached: true, }); for await (const log of getLogs({ - sandboxId: sandboxIdRef.current, + sandboxName: sandboxNameRef.current, cmdId: install.cmdId, })) { setLogs((prevLogs) => [...prevLogs, log]); } const next = await runCommand({ - sandboxId: sandboxIdRef.current, + sandboxName: sandboxNameRef.current, cmd: "npm", args: ["run", "dev"], detached: true, }); (async () => { - if (!sandboxRoutesRef.current || !sandboxIdRef.current) { + if (!sandboxRoutesRef.current || !sandboxNameRef.current) { console.error("Sandbox routes or ID is missing"); return; } for await (const log of getLogs({ - sandboxId: sandboxIdRef.current, + sandboxName: sandboxNameRef.current, cmdId: next.cmdId, })) { setLogs((prevLogs) => [...prevLogs, log]); @@ -107,19 +107,19 @@ export default function SplitScreenChatOptimized() { const handleFormSubmit = useCallback( (event: React.FormEvent) => { - if (!sandboxId || !url || !routes) { + if (!sandboxName || !url || !routes) { startTransition(async () => { const { id, url, routes } = await createSandbox(); setSandboxUrl(url); setRoutes(routes); - setSandboxId(id); - sandboxIdRef.current = id; + setSandboxName(id); + sandboxNameRef.current = id; sandboxRoutesRef.current = routes; }); } return handleSubmit(event); }, - [sandboxId, url, routes, handleSubmit], + [sandboxName, url, routes, handleSubmit], ); const handleReload = useCallback(() => { @@ -225,7 +225,7 @@ export default function SplitScreenChatOptimized() { ); } -async function* getLogs(params: { cmdId: string; sandboxId: string }) { +async function* getLogs(params: { cmdId: string; sandboxName: string }) { const response = await fetch("/api/logs", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/examples/filesystem-snapshots/filesystem-snapshots.ts b/examples/filesystem-snapshots/filesystem-snapshots.ts index df157e1d..6baea63e 100644 --- a/examples/filesystem-snapshots/filesystem-snapshots.ts +++ b/examples/filesystem-snapshots/filesystem-snapshots.ts @@ -20,8 +20,8 @@ async function main() { console.log('Created snapshot successfully!\n'); console.log('Listing all snapshots:'); - const snapshots = await Snapshot.list(); - for (const snapshot of snapshots.json.snapshots) { + const { snapshots } = await Snapshot.list(); + for (const snapshot of snapshots) { console.log(`- ${snapshot.id}`); console.log(` Created at: ${new Date(snapshot.createdAt).toISOString()}`); console.log(` Size: ${Math.round(snapshot.sizeBytes / 1024 / 1024)} MB\n`); diff --git a/examples/sandbox-basics/sandbox-basics.ts b/examples/sandbox-basics/sandbox-basics.ts index 5055ee52..8dadc764 100644 --- a/examples/sandbox-basics/sandbox-basics.ts +++ b/examples/sandbox-basics/sandbox-basics.ts @@ -8,7 +8,7 @@ async function main() { timeout: 300000, // 5 minutes }); - console.log('Sandbox created successfully!\n'); + console.log(`Sandbox ${sandbox.name} created successfully!\n`); // 1. Check current working directory const pwdResult = await sandbox.runCommand('pwd'); @@ -155,23 +155,38 @@ echo "Process completed normally" console.log('Created files:'); console.log(await treeResult.stdout()); - // 10. Check resource usage + // 10. Stop the sandbox + console.log('Stopping the sandbox ...'); + await sandbox.stop(); + + // 11. Modify the sandbox configuration + await sandbox.update({ resources: { vcpus: 2 }}); + + // 12. Resume the sandbox from where you left off + console.log('Resuming the sandbox with 2 vCPU'); + const resumedSandbox = await Sandbox.get({ name: sandbox.name }); + const treeResultAfterResume = await resumedSandbox.runCommand('find', ['test', '-type', 'f']); + console.log('Created files (after resuming the sandbox):'); + console.log(await treeResultAfterResume.stdout()); + + // 13. Check resource usage console.log('Resource Usage:'); // Check disk usage - const dfResult = await sandbox.runCommand('df', ['-h']); + const dfResult = await resumedSandbox.runCommand('df', ['-h']); console.log('Disk usage:'); console.log(await dfResult.stdout()); // Check memory usage - const freeResult = await sandbox.runCommand('free', ['-h']); + const freeResult = await resumedSandbox.runCommand('free', ['-h']); console.log('Memory usage:'); console.log(await freeResult.stdout()); + // 14. Delete the sandbox + console.log('Deleting the sandbox ...'); + await resumedSandbox.delete(); + console.log('Sandbox basics completed!'); - - // Clean up - await sandbox.stop(); } main().catch(console.error); \ No newline at end of file diff --git a/package.json b/package.json index 5b43a1a9..e48f25e9 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}": [ diff --git a/packages/sandbox/CHANGELOG.md b/packages/sandbox/CHANGELOG.md index 53356eb0..96beb75e 100644 --- a/packages/sandbox/CHANGELOG.md +++ b/packages/sandbox/CHANGELOG.md @@ -1,5 +1,207 @@ # sandbox +## 3.0.0-beta.21 + +### Patch Changes + +- Add Node 26 support. ([#181](https://github.com/vercel/sandbox/pull/181)) + +- Updated dependencies [[`b4126273497e08057bec448e965f3f157856254b`](https://github.com/vercel/sandbox/commit/b4126273497e08057bec448e965f3f157856254b)]: + - @vercel/sandbox@2.0.0-beta.19 + +## 3.0.0-beta.20 + +### Patch Changes + +- Updated dependencies [[`0c62cab5ab16af355ca57d1a19ab1e6e70899060`](https://github.com/vercel/sandbox/commit/0c62cab5ab16af355ca57d1a19ab1e6e70899060)]: + - @vercel/sandbox@2.0.0-beta.18 + +## 3.0.0-beta.19 + +### Minor Changes + +- Support pagination (CLI and SDK) when listing sandboxes, snapshots, sessions + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.17 + +## 3.0.0-beta.18 + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.16 + +## 3.0.0-beta.17 + +### Minor Changes + +- Remove support for blocking parameter in .stop() and default to always blocking. Improve CLI output when stopping a sandbox. + +### Patch Changes + +- Improve timeout hour format and example values ([#156](https://github.com/vercel/sandbox/pull/156)) + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.15 + +## 3.0.0-beta.16 + +### Patch Changes + +- Support updading current-snapshot-id of an existing sandbox + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.14 + +## 3.0.0-beta.15 + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.13 + +## 3.0.0-beta.14 + +### Minor Changes + +- Rebase from main + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.12 + +## 3.0.0-beta.13 + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.11 + +## 3.0.0-beta.12 + +### Minor Changes + +- Support default snapshot expiration for persistent sandboxes + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.10 + +## 3.0.0-beta.11 + +### Patch Changes + +- Default to `--sort-by=name` when using `--name-prefix` in `sandbox ls` ([#113](https://github.com/vercel/sandbox/pull/113)) + +## 3.0.0-beta.10 + +### Patch Changes + +- Add support for updating tags and display tags in config + +## 3.0.0-beta.9 + +### Minor Changes + +- Move to cursor pagination. Support new sortyBy parameter for lists. Support new statusUpdatedAt filter + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.9 + +## 3.0.0-beta.8 + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.8 + +## 3.0.0-beta.7 + +### Patch Changes + +- Fix resume race-condition ([#97](https://github.com/vercel/sandbox/pull/97)) + +- Fix bug where the first ssh connection hang ([#98](https://github.com/vercel/sandbox/pull/98)) + +- Fix JsDocs, messages and double-error message bug ([#94](https://github.com/vercel/sandbox/pull/94)) + +- Updated dependencies [[`d4ac7da0362f06e9095261eb36f802cf2c862b6d`](https://github.com/vercel/sandbox/commit/d4ac7da0362f06e9095261eb36f802cf2c862b6d), [`851f5106054adb4ff61806dded1712bf9c917451`](https://github.com/vercel/sandbox/commit/851f5106054adb4ff61806dded1712bf9c917451)]: + - @vercel/sandbox@2.0.0-beta.7 + +## 3.0.0-beta.6 + +### Minor Changes + +- Rename sandbox to session, namedSandbox to sandbox + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.5 + +## 3.0.0-beta.5 + +### Patch Changes + +- Add support for patch + delete v2 endpoints for named sandboxes. + +- Updated dependencies [[`67d41ad2c78913125c25d86acd9ee6a505170ca1`](https://github.com/vercel/sandbox/commit/67d41ad2c78913125c25d86acd9ee6a505170ca1)]: + - @vercel/sandbox@2.0.0-beta.4 + +## 3.0.0-beta.4 + +### Minor Changes + +- Support new --stop option for exec/run, support new --name option for snaphosts list + +- support new --name option for snapshots list, support new --stop option for run + +## 3.0.0-beta.3 + +### Minor Changes + +- Automatically scale memory to vcpu when updating + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.3 + +## 3.0.0-beta.2 + +### Minor Changes + +- Refactor the sandbox update and deprecate old network-policy update + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.2 + +## 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.12 ### Patch Changes diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index 1782ae66..c18cec01 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -1,7 +1,7 @@ ## `sandbox --help` ``` -sandbox 2.5.12 +sandbox 3.0.0-beta.21 ▲ 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,15 @@ 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. Options: createdAt (default), name, statusUpdatedAt [optional] + --sort-order Sort order. Options: asc, desc (default) [optional] + --tag Filter sandboxes by tag. Format: "key=value" + --limit Maximum number of sandboxes per page (default 50). [optional] + --cursor Pagination cursor from a previous 'More results' hint. [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,30 +80,35 @@ Create and run a command in a sandbox Options: - --runtime One of 'node22', 'node24', 'node26', 'python3.13' [default: node24] - --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] - --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] - --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run - --snapshot, -s Start the sandbox from a snapshot ID [optional] - --env , -e= Environment variables to set for the command - --network-policy Network policy mode: "allow-all" or "deny-all" + --name A user-chosen name for the sandbox. It must be unique per project. [optional] + --runtime One of 'node22', 'node24', 'node26', 'python3.13' [default: node24] + --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] + --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] + --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run + --snapshot, -s Start the sandbox from a snapshot ID [optional] + --env , -e= Environment variables to set for the command + --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) + --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] + --network-policy Network policy mode: "allow-all" or "deny-all" - allow-all: sandbox can access any website/domain - deny-all: sandbox has no network access Omit this option and use --allowed-domain / --allowed-cidr / --denied-cidr for custom policies. [optional] - --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. - --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. - --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. - --workdir, -w The working directory to run the command in [optional] + --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. + --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. + --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. + --workdir, -w The working directory to run the command in [optional] Flags: - --silent Don't write sandbox ID to stdout [optional] + --non-persistent Disable automatic restore of the filesystem between sessions. [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] --no-extend-timeout Do not extend the sandbox timeout while running an interactive command. Only affects interactive executions. [optional] --tty, -t Allocate a tty for an interactive command. This is a no-op. [optional] --rm Automatically remove the sandbox when the command exits. [optional] + --stop Stop the sandbox when the command exits. [optional] --help, -h show help [optional] Auth & Scope: @@ -118,25 +134,29 @@ Create a sandbox in the specified account and project. Options: - --runtime One of 'node22', 'node24', 'node26', 'python3.13' [default: node24] - --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] - --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] - --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run - --snapshot, -s Start the sandbox from a snapshot ID [optional] - --env , -e= Default environment variables for sandbox commands - --network-policy Network policy mode: "allow-all" or "deny-all" + --name A user-chosen name for the sandbox. It must be unique per project. [optional] + --runtime One of 'node22', 'node24', 'node26', 'python3.13' [default: node24] + --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] + --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] + --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run + --snapshot, -s Start the sandbox from a snapshot ID [optional] + --env , -e= Default environment variables for sandbox commands + --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) + --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] + --network-policy Network policy mode: "allow-all" or "deny-all" - allow-all: sandbox can access any website/domain - deny-all: sandbox has no network access Omit this option and use --allowed-domain / --allowed-cidr / --denied-cidr for custom policies. [optional] - --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. - --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. - --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. + --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. + --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. + --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. 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 restore of the filesystem between sessions. [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 +182,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 +213,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 +242,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 +267,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 +308,7 @@ Options: Arguments: - The ID of the sandbox to execute the command in + The name of the sandbox Auth & Scope: @@ -313,40 +333,25 @@ Commands: rm | delete [...snapshot_id] Delete one or more snapshots. ``` -## `sandbox config network-policy` +## `sandbox config` ``` -network-policy - -▲ sandbox config network-policy [options] - -Update the network policy of a sandbox. - This will fully override the previous configuration. - -Arguments: - - The ID of the sandbox to execute the command in +sandbox config -Options: +▲ sandbox config [options] - --network-policy Network policy mode: "allow-all" or "deny-all" - - allow-all: sandbox can access any website/domain - - deny-all: sandbox has no network access - Omit this option and use --allowed-domain / --allowed-cidr / --denied-cidr for custom policies. [optional] - --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. - --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. - --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. - --mode Alias for --network-policy. [optional] +For command help, run `sandbox config --help` -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] - --project The project name or ID to associate with the command. Can be inferred from VERCEL_OIDC_TOKEN. [optional] - --scope The scope/team to associate with the command. Can be inferred from VERCEL_OIDC_TOKEN. [alias: --team] [optional] - -Flags: +Commands: - --help, -h show help [optional] + list Display the current configuration of a sandbox + vcpus Update the vCPU count of a sandbox + timeout Update the timeout of a sandbox (will be applied to all new sessions) + persistent Enable or disable automatic restore of the filesystem between sessions + network-policy Update the network policy of a sandbox + snapshot-expiration Update the default snapshot expiration of a sandbox + current-snapshot Update the current snapshot of a sandbox + tags Update the tags of a sandbox. Replaces all existing tags with the provided tags. ``` ## `sandbox login` diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index c2d73465..7ad40bf0 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.12", + "version": "3.0.0-beta.21", "scripts": { "clean": "rm -rf node_modules dist", "sandbox": "ts-node ./src/sandbox.ts", diff --git a/packages/sandbox/scripts/print-usage.ts b/packages/sandbox/scripts/print-usage.ts index f89c98fc..f23259e0 100644 --- a/packages/sandbox/scripts/print-usage.ts +++ b/packages/sandbox/scripts/print-usage.ts @@ -15,7 +15,7 @@ const docs = { "sandbox connect": "connect --help", "sandbox snapshot": "snapshot --help", "sandbox snapshots": "snapshots --help", - "sandbox config network-policy": "config network-policy --help", + "sandbox config": "config --help", "sandbox login": "login --help", "sandbox logout": "logout --help", }; 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 65786312..c76d6c8b 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", "node26", "python3.13"] as const), + displayName: "runtime", +}; + export const runtime = cmd.option({ long: "runtime", - type: { - ...cmd.oneOf(["node22", "node24", "node26", "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/args/vcpus.ts b/packages/sandbox/src/args/vcpus.ts index 0823bfb7..2bf3a31e 100644 --- a/packages/sandbox/src/args/vcpus.ts +++ b/packages/sandbox/src/args/vcpus.ts @@ -1,20 +1,20 @@ import * as cmd from "cmd-ts"; +export const vcpusType = cmd.extendType(cmd.number, { + displayName: "COUNT", + async from(n) { + if (!Number.isInteger(n) || n < 1) { + throw new Error( + `Invalid vCPU count: ${n}. Must be a positive integer.`, + ); + } + return n; + }, +}); + export const vcpus = cmd.option({ long: "vcpus", - type: cmd.optional( - cmd.extendType(cmd.number, { - displayName: "COUNT", - async from(n) { - if (!Number.isInteger(n) || n < 1) { - throw new Error( - `Invalid vCPU count: ${n}. Must be a positive integer.`, - ); - } - return n; - }, - }), - ), + type: cmd.optional(vcpusType), description: "Number of vCPUs to allocate (each vCPU includes 2048 MB of memory)", }); 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 e048ba50..2de7c555 100644 --- a/packages/sandbox/src/commands/config.ts +++ b/packages/sandbox/src/commands/config.ts @@ -1,6 +1,7 @@ import * as cmd from "cmd-ts"; -import { Sandbox } from "@vercel/sandbox"; -import { sandboxId } from "../args/sandbox-id"; +import { APIError, type Sandbox } from "@vercel/sandbox"; +import { sandboxName } from "../args/sandbox-name"; +import { snapshotId } from "../args/snapshot-id"; import { scope } from "../args/scope"; import { sandboxClient } from "../client"; import { @@ -8,16 +9,310 @@ import { networkPolicyMode as networkPolicyModeType, } from "../args/network-policy"; import { buildNetworkPolicy, resolveMode } from "../util/network-policy"; +import { vcpusType } from "../args/vcpus"; +import { Duration } from "../types/duration"; +import { SnapshotExpiration } from "../types/snapshot-expiration"; +import { ObjectFromKeyValue } from "../args/key-value-pair"; import ora from "ora"; import chalk from "chalk"; +import ms from "ms"; +import { table } from "../util/output"; +import { acquireRelease } from "../util/disposables"; +import { StyledError } from "../error"; + +const vcpusCommand = cmd.command({ + name: "vcpus", + description: "Update the vCPU count of a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + count: cmd.positional({ + type: vcpusType, + description: + "Number of vCPUs to allocate (each vCPU includes 2048 MB of memory)", + }), + scope, + }, + async handler({ scope: { token, team, project }, sandbox: name, count }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + await sandbox.update({ resources: { vcpus: count } }); + spinner.stop(); + + process.stderr.write( + "✅ Configuration updated for sandbox " + + chalk.cyan(name) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + "vcpus: " + chalk.cyan(count) + "\n", + ); + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + +const timeoutCommand = cmd.command({ + name: "timeout", + description: "Update the timeout of a sandbox (will be applied to all new sessions)", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + duration: cmd.positional({ + type: Duration, + description: "The maximum duration a sandbox can run for. Example: 5m, 1h", + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandbox: name, + duration, + }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + await sandbox.update({ timeout: ms(duration) }); + spinner.stop(); + + process.stderr.write( + "✅ Configuration updated for sandbox " + + chalk.cyan(name) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + "timeout: " + chalk.cyan(duration) + "\n", + ); + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + +const persistentCommand = cmd.command({ + name: "persistent", + description: "Enable or disable automatic restore of the filesystem between sessions", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + value: cmd.positional({ + type: { ...cmd.oneOf(["true", "false"]), displayName: "true|false" }, + description: "Enable or disable automatic restore of the filesystem between sessions", + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandbox: name, + value, + }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + await sandbox.update({ persistent: value === "true" }); + spinner.stop(); + + process.stderr.write( + "✅ Configuration updated for sandbox " + + chalk.cyan(name) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + "persistent: " + chalk.cyan(value) + "\n", + ); + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + +const snapshotExpirationCommand = cmd.command({ + name: "snapshot-expiration", + description: "Update the default snapshot expiration of a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + duration: cmd.positional({ + type: SnapshotExpiration, + description: 'Snapshot expiration duration (e.g. 7d, 30d) or "none" for no expiration', + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandbox: name, + duration, + }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + await sandbox.update({ snapshotExpiration: ms(duration) }); + spinner.stop(); + + const display = ms(duration) === 0 ? "none" : duration; + process.stderr.write( + "✅ Configuration updated for sandbox " + + chalk.cyan(name) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + "snapshot-expiration: " + chalk.cyan(display) + "\n", + ); + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + +const currentSnapshotCommand = cmd.command({ + name: "current-snapshot", + description: "Update the current snapshot of a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + snapshotId: cmd.positional({ + type: snapshotId, + description: "Snapshot ID to set as the current snapshot", + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandbox: name, + snapshotId, + }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + await sandbox.update({ currentSnapshotId: snapshotId }); + spinner.stop(); + + process.stderr.write( + "✅ Configuration updated for sandbox " + + chalk.cyan(name) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + "current-snapshot: " + chalk.cyan(snapshotId) + "\n", + ); + } catch (error) { + spinner.stop(); + if ( + error instanceof APIError && + error.response.status === 404 + ) { + throw new StyledError( + `Snapshot '${snapshotId}' was not found or does not belong to this project.`, + error, + ); + } + throw error; + } + }, +}); + +const listCommand = cmd.command({ + name: "list", + 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 networkPolicy = typeof sandbox.networkPolicy === "string" ? sandbox.networkPolicy : "restricted"; + const tagsDisplay = sandbox.tags && Object.keys(sandbox.tags).length > 0 + ? Object.entries(sandbox.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : "-"; + const rows = [ + { field: "vCPUs", value: String(sandbox.vcpus ?? "-") }, + { field: "Timeout", value: sandbox.timeout != null ? ms(sandbox.timeout, { long: true }) : "-" }, + { field: "Persistent", value: String(sandbox.persistent) }, + { field: "Network policy", value: String(networkPolicy) }, + { field: "Snapshot expiration", value: sandbox.snapshotExpiration != null && sandbox.snapshotExpiration > 0 ? ms(sandbox.snapshotExpiration, { long: true }) : sandbox.snapshotExpiration === 0 ? "none" : "-" }, + { field: "Current snapshot", value: sandbox.currentSnapshotId ?? "-" }, + { field: "Tags", value: tagsDisplay }, + ]; + + 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", - description: `Update the network policy of a sandbox. - This will fully override the previous configuration.`, + description: `Update the network policy of a sandbox`, args: { sandbox: cmd.positional({ - type: sandboxId as cmd.Type, + type: sandboxName as cmd.Type, }), ...networkPolicyArgs, mode: cmd.option({ @@ -29,7 +324,7 @@ const networkPolicyCommand = cmd.command({ }, async handler({ scope: { token, team, project }, - sandbox: sandboxId, + sandbox: sandboxName, networkPolicy: networkPolicyFlag, mode: modeFlag, allowedDomains, @@ -55,39 +350,26 @@ const networkPolicyCommand = cmd.command({ }); const sandbox = - typeof sandboxId !== "string" - ? sandboxId + typeof sandboxName !== "string" + ? sandboxName : await sandboxClient.get({ - sandboxId, + name: sandboxName, projectId: project, teamId: team, token, }); - if (!["pending", "running"].includes(sandbox.status)) { - console.error( - [ - `Sandbox ${sandbox.sandboxId} 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); + await sandbox.update({ networkPolicy }); spinner.stop(); process.stderr.write( "✅ Network policy updated for sandbox " + - chalk.cyan(sandbox.sandboxId) + + chalk.cyan(sandbox.name) + "\n", ); - const mode = typeof response === "string" ? response : "restricted"; + const mode = typeof networkPolicy === "string" ? networkPolicy : "restricted"; process.stderr.write( chalk.dim(" ╰ ") + "mode: " + chalk.cyan(mode) + "\n", ); @@ -98,10 +380,69 @@ const networkPolicyCommand = cmd.command({ }, }); +const tagsCommand = cmd.command({ + name: "tags", + description: "Update the tags of a sandbox. Replaces all existing tags with the provided tags.", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + tags: cmd.multioption({ + long: "tag", + short: "t", + type: ObjectFromKeyValue, + description: "Key-value tags to set (e.g. --tag env=staging). Omit to clear all tags.", + }), + scope, + }, + async handler({ scope: { token, team, project }, sandbox: name, tags }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const tagsObj = Object.keys(tags).length > 0 ? tags : {}; + + const spinner = ora("Updating sandbox tags...").start(); + try { + await sandbox.update({ tags: tagsObj }); + spinner.stop(); + + process.stderr.write( + "✅ Tags updated for sandbox " + chalk.cyan(name) + "\n", + ); + const entries = Object.entries(tagsObj); + if (entries.length === 0) { + process.stderr.write(chalk.dim(" ╰ ") + "all tags cleared\n"); + } else { + for (let i = 0; i < entries.length; i++) { + const [k, v] = entries[i]; + const isLast = i === entries.length - 1; + const prefix = isLast ? chalk.dim(" ╰ ") : chalk.dim(" │ "); + process.stderr.write(prefix + chalk.cyan(k) + "=" + chalk.cyan(v) + "\n"); + } + } + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + export const config = cmd.subcommands({ name: "config", - description: "Update a sandbox configuration", + description: "View and update sandbox configuration", cmds: { + list: listCommand, + vcpus: vcpusCommand, + timeout: timeoutCommand, + persistent: persistentCommand, "network-policy": networkPolicyCommand, + "snapshot-expiration": snapshotExpirationCommand, + "current-snapshot": currentSnapshotCommand, + tags: tagsCommand, }, }); diff --git a/packages/sandbox/src/commands/cp.ts b/packages/sandbox/src/commands/cp.ts index 4aea324b..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({ - sandboxId: 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({ - sandboxId: 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 31d5bf4b..f5557254 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -12,8 +12,18 @@ import * as Exec from "./exec"; import { networkPolicyArgs } from "../args/network-policy"; import { buildNetworkPolicy } from "../util/network-policy"; import { ObjectFromKeyValue } from "../args/key-value-pair"; +import { SnapshotExpiration } from "../types/snapshot-expiration"; 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 +51,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", @@ -60,6 +70,17 @@ export const args = { type: ObjectFromKeyValue, description: "Default environment variables for sandbox commands", }), + tags: cmd.multioption({ + long: "tag", + short: "t", + type: ObjectFromKeyValue, + description: "Key-value tags to associate with the sandbox (e.g. --tag env=staging)", + }), + snapshotExpiration: cmd.option({ + long: "snapshot-expiration", + type: cmd.optional(SnapshotExpiration), + description: 'Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d', + }), ...networkPolicyArgs, scope, } as const; @@ -75,6 +96,8 @@ export const create = cmd.command({ }, ], async handler({ + name, + nonPersistent, ports, scope, runtime, @@ -84,6 +107,8 @@ export const create = cmd.command({ snapshot, connect, envVars, + tags, + snapshotExpiration, networkPolicy: networkPolicyMode, allowedDomains, allowedCIDRs, @@ -96,10 +121,13 @@ export const create = cmd.command({ deniedCIDRs, }); + const persistent = !nonPersistent const resources = vcpus ? { vcpus } : undefined; + const tagsObj = Object.keys(tags).length > 0 ? tags : 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 +137,13 @@ export const create = cmd.command({ resources, networkPolicy, env: envVars, + tags: tagsObj, + persistent, + snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, __interactive: true, }) : await sandboxClient.create({ + name, teamId: scope.team, projectId: scope.project, token: scope.token, @@ -121,6 +153,9 @@ export const create = cmd.command({ resources, networkPolicy, env: envVars, + tags: tagsObj, + persistent, + snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, __interactive: true, }); spinner?.stop(); @@ -145,7 +180,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..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({ - sandboxId, + name: sandboxName, projectId: project, teamId: team, token, __includeSystemRoutes: true, }); - if (!["pending", "running"].includes(sandbox.status)) { - console.error( - [ - `Sandbox ${sandbox.sandboxId} 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 43945dcc..a85ef2a8 100644 --- a/packages/sandbox/src/commands/list.ts +++ b/packages/sandbox/src/commands/list.ts @@ -5,7 +5,8 @@ import { scope } from "../args/scope"; import chalk, { ChalkInstance } from "chalk"; import ora from "ora"; import { acquireRelease } from "../util/disposables"; -import { table, timeAgo, formatBytes, formatRunDuration } from "../util/output"; +import { table, timeAgo, formatBytes, formatRunDuration, formatNextCursorHint } from "../util/output"; +import { ObjectFromKeyValue } from "../args/key-value-pair"; export const list = cmd.command({ name: "list", @@ -17,31 +18,73 @@ 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, statusUpdatedAt", + type: cmd.optional( + cmd.oneOf(["createdAt", "name", "statusUpdatedAt"] as const), + ), + }), + sortOrder: cmd.option({ + long: "sort-order", + description: "Sort order. Options: asc, desc (default)", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), + tags: cmd.multioption({ + long: "tag", + description: 'Filter sandboxes by tag. Format: "key=value"', + type: ObjectFromKeyValue, + }), + limit: cmd.option({ + long: "limit", + description: "Maximum number of sandboxes per page (default 50).", + type: cmd.optional(cmd.number), + }), + cursor: cmd.option({ + long: "cursor", + description: "Pagination cursor from a previous 'More results' hint.", + type: cmd.optional(cmd.string), + }), scope, }, - async handler({ scope: { token, team, project }, all }) { - const sandboxes = await (async () => { + async handler({ scope: { token, team, project }, all, namePrefix, sortBy, sortOrder, tags, limit, cursor }) { + if (namePrefix) { + if (sortBy && sortBy !== "name") { + console.error(chalk.red("Error: --sort-by must be 'name' when using --name-prefix")); + return; + } + + sortBy = 'name'; + } + + const { sandboxes, pagination } = await (async () => { using _spinner = acquireRelease( () => ora("Fetching sandboxes...").start(), (s) => s.stop(), ); - const { json } = await sandboxClient.list({ + return sandboxClient.list({ token, teamId: team, projectId: project, - limit: 100, + limit: limit ?? 50, + ...(cursor && { cursor }), + ...(namePrefix && { namePrefix }), + ...(sortBy && { sortBy }), + ...(sortOrder && { sortOrder }), + ...(Object.keys(tags).length > 0 && { tags }), }); - - let sandboxes = json.sandboxes; - - if (!all) { - sandboxes = sandboxes.filter((x) => x.status === "running"); - } - - return sandboxes; })(); + const displayedSandboxes = all + ? sandboxes + : sandboxes.filter((x) => x.status === "running"); + const memoryFormatter = new Intl.NumberFormat(undefined, { style: "unit", unit: "megabyte", @@ -51,7 +94,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 }, + NAME: { value: (s) => s.name }, STATUS: { value: (s) => s.status, color: (s) => SandboxStatusColor[s.status] ?? chalk.reset, @@ -59,23 +102,28 @@ export const list = cmd.command({ CREATED: { value: (s) => timeAgo(s.createdAt), }, - MEMORY: { value: (s) => memoryFormatter.format(s.memory) }, - VCPUS: { value: (s) => s.vcpus }, - RUNTIME: { value: (s) => s.runtime }, + MEMORY: { value: (s) => s.memory != null ? memoryFormatter.format(s.memory) : "-" }, + VCPUS: { value: (s) => s.vcpus ?? "-" }, + RUNTIME: { value: (s) => s.runtime ?? "-" }, TIMEOUT: { - value: (s) => timeAgo(s.createdAt + s.timeout), + value: (s) => s.timeout != null ? timeAgo(s.createdAt + s.timeout) : "-", }, - SNAPSHOT: { value: (s) => s.sourceSnapshotId ?? "-" } + SNAPSHOT: { value: (s) => s.currentSnapshotId ?? "-" }, + TAGS: { value: (s) => s.tags && Object.keys(s.tags).length > 0 ? Object.entries(s.tags).map(([k, v]) => `${k}:${v}`).join(", ") : "-" } }; 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)}` : "- / -", }; } - console.log(table({ rows: sandboxes, columns })); + console.log(table({ rows: displayedSandboxes, columns })); + + if (pagination.next !== null) { + console.log(formatNextCursorHint(pagination.next)); + } }, }); diff --git a/packages/sandbox/src/commands/remove.ts b/packages/sandbox/src/commands/remove.ts new file mode 100644 index 00000000..ebc7eb80 --- /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", + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandboxName, + sandboxNames, + }) { + 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(); + }, + }), + ); + try { + await new Listr(tasks, { concurrent: true }).run(); + } catch { + // Listr already rendered the error; just set exit code + process.exitCode = 1; + } + }, +}); diff --git a/packages/sandbox/src/commands/run.ts b/packages/sandbox/src/commands/run.ts index 4184bc03..74ff4411 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 = { @@ -10,18 +13,52 @@ const args = { long: "rm", description: "Automatically remove the sandbox when the command exits.", }), + stopAfterUse: cmd.flag({ + long: "stop", + description: "Stop the sandbox when the command exits.", + }), } as const; export const run = cmd.command({ name: "run", description: "Create and run a command in a sandbox", args, - async handler({ removeAfterUse, ...rest }) { - const sandbox = await Create.create.handler({ ...rest }); + async handler({ removeAfterUse, stopAfterUse, ...rest }) { + if (removeAfterUse && stopAfterUse) { + throw new Error("--rm and --stop are mutually exclusive."); + } + + 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, nonPersistent: rest.nonPersistent || removeAfterUse }); + } else { + throw error; + } + } + } else { + sandbox = await Create.create.handler({ ...rest, nonPersistent: rest.nonPersistent || removeAfterUse }); + } + try { await Exec.exec.handler({ ...rest, sandbox }); } finally { if (removeAfterUse) { + await sandbox.delete(); + } + if (stopAfterUse) { await sandbox.stop(); } } diff --git a/packages/sandbox/src/commands/sessions.ts b/packages/sandbox/src/commands/sessions.ts new file mode 100644 index 00000000..ce866b7e --- /dev/null +++ b/packages/sandbox/src/commands/sessions.ts @@ -0,0 +1,125 @@ +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, formatBytes, formatRunDuration, formatNextCursorHint } from "../util/output"; +import type { Sandbox } from "@vercel/sandbox"; + +const list = cmd.command({ + name: "list", + aliases: ["ls"], + description: "List sessions from a sandbox", + args: { + all: cmd.flag({ + long: "all", + short: "a", + description: "Show all sessions (default shows just running)", + }), + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to list sessions for", + }), + sortOrder: cmd.option({ + long: "sort-order", + description: "Sort order. Options: asc, desc (default)", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), + limit: cmd.option({ + long: "limit", + description: "Maximum number of sessions per page (default 50).", + type: cmd.optional(cmd.number), + }), + cursor: cmd.option({ + long: "cursor", + description: "Pagination cursor from a previous 'More results' hint.", + type: cmd.optional(cmd.string), + }), + scope, + }, + async handler({ scope: { token, team, project }, all, sandbox: name, sortOrder, limit, cursor }) { + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const { sessions, pagination } = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching sessions...").start(), + (s) => s.stop(), + ); + return sandbox.listSessions({ + limit: limit ?? 50, + ...(cursor && { cursor }), + ...(sortOrder && { sortOrder }), + }); + })(); + + const displayedSessions = all + ? sessions + : sessions.filter((x) => x.status === "running"); + + const memoryFormatter = new Intl.NumberFormat(undefined, { + style: "unit", + unit: "megabyte", + }); + + type SessionRow = (typeof sessions)[number]; + type Column = { value: (s: SessionRow) => string | number; color?: (s: SessionRow) => ChalkInstance }; + + const columns: Record = { + 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) => memoryFormatter.format(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) : "-", + }, + SNAPSHOT: { value: (s) => s.sourceSnapshotId ?? "-" }, + }; + if (all) { + columns.CPU = { value: (s) => s.activeCpuDurationMs ? formatRunDuration(s.activeCpuDurationMs) : "-" }; + columns["NETWORK (OUT/IN)"] = { + value: (s) => (s.networkTransfer?.egress || s.networkTransfer?.ingress) ? + `${formatBytes(s.networkTransfer?.egress ?? 0)} / ${formatBytes(s.networkTransfer?.ingress ?? 0)}` : "- / -", + }; + } + + console.log(table({ rows: displayedSessions, columns })); + + if (pagination.next !== null) { + console.log(formatNextCursorHint(pagination.next)); + } + }, +}); + +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 32d32830..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({ - sandboxId, + name: sandboxName, 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/snapshots.ts b/packages/sandbox/src/commands/snapshots.ts index 41fb3e5d..c6a45ad2 100644 --- a/packages/sandbox/src/commands/snapshots.ts +++ b/packages/sandbox/src/commands/snapshots.ts @@ -1,34 +1,57 @@ 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 { sandboxName } from "../args/sandbox-name"; import { snapshotId } from "../args/snapshot-id"; import { snapshotClient } from "../client"; import { acquireRelease } from "../util/disposables"; -import { formatBytes, table, timeAgo } from "../util/output"; +import { formatBytes, formatNextCursorHint, table, timeAgo } from "../util/output"; const list = cmd.command({ name: "list", aliases: ["ls"], description: "List snapshots for the specified account and project.", args: { - scope, // only arg, position doesn't matter + scope, + name: cmd.option({ + type: cmd.optional(sandboxName), + long: "name", + description: "Filter snapshots by sandbox.", + }), + sortOrder: cmd.option({ + long: "sort-order", + description: "Sort order. Options: asc, desc (default)", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), + limit: cmd.option({ + long: "limit", + description: "Maximum number of snapshots per page (default 50).", + type: cmd.optional(cmd.number), + }), + cursor: cmd.option({ + long: "cursor", + description: "Pagination cursor from a previous 'More results' hint.", + type: cmd.optional(cmd.string), + }), }, - async handler({ scope: { token, team, project } }) { - const snapshots = await (async () => { + async handler({ scope: { token, team, project }, name, sortOrder, limit, cursor }) { + const { snapshots, pagination } = await (async () => { using _spinner = acquireRelease( () => ora("Fetching snapshots...").start(), (s) => s.stop(), ); - const { json } = await snapshotClient.list({ + return snapshotClient.list({ token, teamId: team, projectId: project, - limit: 100, + name, + limit: limit ?? 50, + ...(cursor && { cursor }), + ...(sortOrder && { sortOrder }), }); - return json.snapshots; })(); console.log( @@ -48,10 +71,14 @@ const list = cmd.command({ : timeAgo(s.expiresAt), }, SIZE: { value: (s) => formatBytes(s.sizeBytes) }, - ["SOURCE SANDBOX"]: { value: (s) => s.sourceSandboxId }, + ["SOURCE SESSION"]: { value: (s) => s.sourceSessionId }, }, }), ); + + if (pagination.next !== null) { + console.log(formatNextCursorHint(pagination.next)); + } }, }); @@ -62,7 +89,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 +118,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.sourceSessionId }, }, }), ); @@ -105,7 +132,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, @@ -136,7 +163,12 @@ const remove = cmd.command({ }; }, ); - await new Listr(tasks, { concurrent: true }).run(); + try { + await new Listr(tasks, { concurrent: true }).run(); + } catch { + // Listr already rendered the error; just set exit code + process.exitCode = 1; + } }, }); diff --git a/packages/sandbox/src/commands/stop.ts b/packages/sandbox/src/commands/stop.ts index 6e340ba8..d5669ec5 100644 --- a/packages/sandbox/src/commands/stop.ts +++ b/packages/sandbox/src/commands/stop.ts @@ -1,42 +1,132 @@ import * as cmd from "cmd-ts"; -import { Listr } from "listr2"; -import { sandboxId } from "../args/sandbox-id"; +import chalk from "chalk"; +import ora from "ora"; +import type { Sandbox } from "@vercel/sandbox"; +import { sandboxName } from "../args/sandbox-name"; import { scope } from "../args/scope"; import { sandboxClient } from "../client"; +import { formatBytes, formatRunDuration, timeAgo } from "../util/output"; + +type StopResult = Awaited>; + +/** Label/value pair; null means empty (used for column alignment). */ +type Cell = { label: string; value: string } | null; + +function c(label: string, value: string): Cell { + return { label, value }; +} + +/** Visible width of a cell (label + value, no ANSI). */ +function cellWidth(cell: Cell): number { + return cell ? cell.label.length + cell.value.length : 0; +} + +/** Print rows as a tree with column-aligned label: value pairs. */ +function printTree(rows: Cell[][]) { + // Column widths + const widths: number[] = []; + for (const row of rows) { + for (let i = 0; i < row.length; i++) { + widths[i] = Math.max(widths[i] ?? 0, cellWidth(row[i])); + } + } + + for (let r = 0; r < rows.length; r++) { + const isLast = r === rows.length - 1; + const prefix = isLast ? chalk.dim(" ╰ ") : chalk.dim(" │ "); + const line = rows[r] + .map((cell, i) => { + if (!cell) return " ".repeat(widths[i]); + const pad = widths[i] - cell.label.length - cell.value.length; + return cell.label + chalk.cyan(cell.value) + " ".repeat(Math.max(0, pad)); + }) + .join(" ") + .trimEnd(); + process.stderr.write(prefix + line + "\n"); + } +} + +function printStopResult(name: string, sandbox: Sandbox, sessionSnapshot: StopResult) { + process.stderr.write(chalk.green("✔") + " Sandbox stopped.\n"); + + const snapshot = sessionSnapshot.snapshot; + + const rows: Cell[][] = [ + [ + c("sandbox: ", name), + sandbox.totalActiveCpuDurationMs != null ? c("active cpu: ", formatRunDuration(sandbox.totalActiveCpuDurationMs)) : null, + sandbox.memory != null ? c("mem: ", `${sandbox.memory} MB`) : null, + sandbox.totalDurationMs != null ? c("duration: ", formatRunDuration(sandbox.totalDurationMs)) : null, + sandbox.totalIngressBytes != null ? c("ingress: ", formatBytes(sandbox.totalIngressBytes)) : null, + sandbox.totalEgressBytes != null ? c("egress: ", formatBytes(sandbox.totalEgressBytes)) : null, + ], + [ + c("session: ", sessionSnapshot.id), + sessionSnapshot.activeCpuDurationMs != null ? c("active cpu: ", formatRunDuration(sessionSnapshot.activeCpuDurationMs)) : null, + c("mem: ", `${sessionSnapshot.memory} MB`), + sessionSnapshot.duration != null ? c("duration: ", formatRunDuration(sessionSnapshot.duration)) : null, + sessionSnapshot.networkTransfer ? c("ingress: ", formatBytes(sessionSnapshot.networkTransfer.ingress)) : null, + sessionSnapshot.networkTransfer ? c("egress: ", formatBytes(sessionSnapshot.networkTransfer.egress)) : null, + ], + ...(snapshot + ? [[ + c("snapshot: ", snapshot.id), + c("size: ", formatBytes(snapshot.sizeBytes)), + c("expires: ", snapshot.expiresAt ? timeAgo(snapshot.expiresAt) : "never"), + ]] + : []), + ]; + + printTree(rows); +} 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 }) { - const tasks = Array.from( - new Set([sandboxId, ...sandboxIds]), - (sandboxId) => { - return { - title: `Stopping sandbox ${sandboxId}`, - async task() { - const sandbox = await sandboxClient.get({ - token, - teamId: team, - projectId: project, - sandboxId, - }); - await sandbox.stop(); - }, - }; - }, + async handler({ scope: { token, team, project }, sandboxName, sandboxNames }) { + const names = Array.from(new Set([sandboxName, ...sandboxNames])); + const spinner = ora({ + text: names.length === 1 + ? `Stopping ${names[0]}` + : `Stopping ${names.length} sandboxes`, + stream: process.stderr, + }).start(); + + const results = await Promise.allSettled( + names.map(async (name) => { + const sandbox = await sandboxClient.get({ + token, + teamId: team, + projectId: project, + name, + }); + const sessionSnapshot = await sandbox.stop(); + return { name, sandbox, sessionSnapshot }; + }), ); - await new Listr(tasks, { concurrent: true }).run(); + + spinner.stop(); + + for (const result of results) { + if (result.status === "fulfilled") { + const { name, sandbox, sessionSnapshot } = result.value; + printStopResult(name, sandbox, sessionSnapshot); + } else { + const error = result.reason; + process.stderr.write(chalk.red("✖") + ` ${error.message ?? error}\n`); + process.exitCode = 1; + } + } }, }); diff --git a/packages/sandbox/src/interactive-shell/extend-sandbox-timeout.ts b/packages/sandbox/src/interactive-shell/extend-sandbox-timeout.ts index 5e96a9c7..932c492e 100644 --- a/packages/sandbox/src/interactive-shell/extend-sandbox-timeout.ts +++ b/packages/sandbox/src/interactive-shell/extend-sandbox-timeout.ts @@ -11,18 +11,27 @@ export async function extendSandboxTimeoutPeriodically( sandbox: Sandbox, signal: AbortSignal, ) { - const nextTick = sandbox.createdAt.getTime() + sandbox.timeout; + const session = sandbox.currentSession(); + const timeout = session.timeout; + if (timeout == null) return; + + const nextTick = session.createdAt.getTime() + timeout; debug(`next tick: ${new Date(nextTick).toISOString()}`); while (!signal.aborted) { - const timeout = - sandbox.createdAt.getTime() + sandbox.timeout - Date.now() - BUFFER; - if (timeout > 2000) { - debug(`sleeping for ${timeout}ms until next timeout extension`); - await setTimeout(timeout, null, { signal }); + const currentTimeout = session.timeout; + if (currentTimeout == null) return; + + const sleepMs = + session.createdAt.getTime() + currentTimeout - Date.now() - BUFFER; + if (sleepMs > 2000) { + debug(`sleeping for ${sleepMs}ms until next timeout extension`); + await setTimeout(sleepMs, null, { signal }); } await sandbox.extendTimeout(ms("5 minutes")); - const nextTick = sandbox.createdAt.getTime() + sandbox.timeout; + const updatedTimeout = session.timeout; + if (updatedTimeout == null) return; + const nextTick = session.createdAt.getTime() + updatedTimeout; debug( `extended sandbox timeout by 5 minutes. next tick: ${new Date(nextTick).toISOString()}`, ); diff --git a/packages/sandbox/src/interactive-shell/interactive-shell.ts b/packages/sandbox/src/interactive-shell/interactive-shell.ts index d6c86fa2..79cf852e 100644 --- a/packages/sandbox/src/interactive-shell/interactive-shell.ts +++ b/packages/sandbox/src/interactive-shell/interactive-shell.ts @@ -207,16 +207,22 @@ export async function startInteractiveShell(options: { options.envVars, options.cwd, ); + debug("startServerCommand completed, cmdId=%s, interactivePort=%s", command.cmdId, options.sandbox.interactivePort); using waitForProcess = createAbortController( "Connection established successfully", ); listener.connection.then(() => { + debug("listener.connection resolved"); waitForProcess.abort(); }); - connect(command, listener, waitForProcess.signal).catch( - waitForProcess.ignoreInterruptions, - ); + connect(command, listener, waitForProcess.signal).catch((err) => { + if (waitForProcess.signal.aborted) return; + // If connect() fails before the connection is established, + // propagate the error into the listener stream so attach() sees it + // instead of hanging forever. + listener.stdoutStream.destroy(err instanceof Error ? err : new Error(String(err))); + }); await Promise.all([ throwIfCommandPrematurelyExited(command, waitForProcess.signal), @@ -319,7 +325,7 @@ async function attach({ client.close(); console.error( - chalk.dim(`\n╰▶ connection to ▲ ${sandbox.sandboxId} closed.`), + chalk.dim(`\n╰▶ connection to ▲ ${sandbox.name} closed.`), ); } @@ -378,6 +384,7 @@ async function connect( stderrStream.write(chunk.data); } } + listener.stdoutStream.end(); } function getStderrStream() { diff --git a/packages/sandbox/src/types/snapshot-expiration.ts b/packages/sandbox/src/types/snapshot-expiration.ts new file mode 100644 index 00000000..04e28797 --- /dev/null +++ b/packages/sandbox/src/types/snapshot-expiration.ts @@ -0,0 +1,14 @@ +import { extendType, string } from "cmd-ts"; +import { Duration } from "./duration"; +import type { StringValue } from "ms"; + +export const SnapshotExpiration = extendType(string, { + displayName: "DURATION|none", + description: 'A duration (e.g. 7d, 30d) or "none" for no expiration', + async from(value): Promise { + if (value === "none") { + return "0"; + } + return Duration.from(value); + }, +}); diff --git a/packages/sandbox/src/util/output.test.ts b/packages/sandbox/src/util/output.test.ts new file mode 100644 index 00000000..c542cc0e --- /dev/null +++ b/packages/sandbox/src/util/output.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { formatNextCursorHint } from "./output"; + +describe("formatNextCursorHint", () => { + const originalArgv = process.argv; + + beforeEach(() => { + process.argv = [...originalArgv]; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + function setArgs(args: string[]) { + process.argv = ["/usr/local/bin/node", "/path/to/sandbox.mjs", ...args]; + } + + it("appends --cursor when no cursor was passed", () => { + setArgs(["list", "--limit", "2"]); + expect(formatNextCursorHint("cur1")).toBe( + "\nMore results: sandbox list --limit 2 --cursor cur1", + ); + }); + + it("preserves boolean flags", () => { + setArgs(["list", "--all"]); + expect(formatNextCursorHint("cur1")).toBe( + "\nMore results: sandbox list --all --cursor cur1", + ); + }); + + it("preserves positional arguments", () => { + setArgs(["sessions", "list", "my-sandbox", "--limit", "5"]); + expect(formatNextCursorHint("abc")).toBe( + "\nMore results: sandbox sessions list my-sandbox --limit 5 --cursor abc", + ); + }); + + it("strips an existing --cursor X form", () => { + setArgs(["list", "--limit", "2", "--cursor", "old"]); + expect(formatNextCursorHint("new")).toBe( + "\nMore results: sandbox list --limit 2 --cursor new", + ); + }); + + it("strips an existing --cursor=X form", () => { + setArgs(["list", "--limit", "2", "--cursor=old"]); + expect(formatNextCursorHint("new")).toBe( + "\nMore results: sandbox list --limit 2 --cursor new", + ); + }); + + it("works with no arguments", () => { + setArgs(["list"]); + expect(formatNextCursorHint("cur")).toBe( + "\nMore results: sandbox list --cursor cur", + ); + }); +}); diff --git a/packages/sandbox/src/util/output.ts b/packages/sandbox/src/util/output.ts index d5ce35de..d30738cd 100644 --- a/packages/sandbox/src/util/output.ts +++ b/packages/sandbox/src/util/output.ts @@ -77,4 +77,23 @@ export function formatRunDuration(d: number): string { return `${d}ms`; } return `${d/1000}s` +} + +export function formatNextCursorHint(cursor: string): string { + const args = process.argv.slice(2); + const filtered: string[] = []; + + for (let i = 0; i < args.length; i++) { + // Ignore `--cursor` parameter. We will overwrite it. + if (args[i] === "--cursor") { + i++; + continue; + } + if (args[i].startsWith("--cursor=")) { + continue; + } + + filtered.push(args[i]); + } + return `\nMore results: sandbox ${filtered.join(" ")} --cursor ${cursor}`; } \ No newline at end of file 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 de3a5187..32aefb04 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -1,5 +1,131 @@ # @vercel/sandbox +## 2.0.0-beta.19 + +### Patch Changes + +- Add Node 26 support. ([#181](https://github.com/vercel/sandbox/pull/181)) + +## 2.0.0-beta.18 + +### Minor Changes + +- Add L7 request matchers and forward URLs support to network policy rules. ([#173](https://github.com/vercel/sandbox/pull/173)) + +## 2.0.0-beta.17 + +### Minor Changes + +- Support pagination (CLI and SDK) when listing sandboxes, snapshots, sessions + +## 2.0.0-beta.16 + +### Minor Changes + +- Support a new method: Sandbox.getOrCreate() + +## 2.0.0-beta.15 + +### Minor Changes + +- Remove support for blocking parameter in .stop() and default to always blocking. Improve CLI output when stopping a sandbox. + +## 2.0.0-beta.14 + +### Patch Changes + +- Support updading current-snapshot-id of an existing sandbox + +## 2.0.0-beta.13 + +### Minor Changes + +- Support new onResume parameter in Sandbox.create and Sandbox.get + +## 2.0.0-beta.12 + +### Minor Changes + +- Rebase from main + +## 2.0.0-beta.11 + +### Patch Changes + +- Fix an 422 error when trying to resume a sandbox after snapshotting + +## 2.0.0-beta.10 + +### Minor Changes + +- Support default snapshot expiration for persistent sandboxes + +## 2.0.0-beta.9 + +### Minor Changes + +- Move to cursor pagination. Support new sortyBy parameter for lists. Support new statusUpdatedAt filter + +## 2.0.0-beta.8 + +### Patch Changes + +- Fix an error with resuming while reading a file + +## 2.0.0-beta.7 + +### Patch Changes + +- Fix bug where the first ssh connection hang ([#98](https://github.com/vercel/sandbox/pull/98)) + +- Fix JsDocs, messages and double-error message bug ([#94](https://github.com/vercel/sandbox/pull/94)) + +## 2.0.0-beta.6 + +### Minor Changes + +- Lists now unwrap the json and return the items and pagination fields directly ([#92](https://github.com/vercel/sandbox/pull/92)) + +### Patch Changes + +- Add support for tags + +## 2.0.0-beta.5 + +### Minor Changes + +- Rename sandbox to session, namedSandbox to sandbox + +## 2.0.0-beta.4 + +### Patch Changes + +- Add support for patch + delete v2 endpoints for named sandboxes. ([#85](https://github.com/vercel/sandbox/pull/85)) + +## 2.0.0-beta.3 + +### Minor Changes + +- Automatically scale memory to vcpu when updating + +## 2.0.0-beta.2 + +### Minor Changes + +- Refactor the sandbox update and deprecate old network-policy update + +## 2.0.0-beta.1 + +### Minor Changes + +- Rename snapshotOnShutdown to persistent + +## 2.0.0-beta.0 + +### Major Changes + +- Introduce named and long-lived sandboxes ([`7407ec9ec419144ae49b0eb2704cb5cf2267b7f3`](https://github.com/vercel/sandbox/commit/7407ec9ec419144ae49b0eb2704cb5cf2267b7f3)) + ## 1.10.2 ### Patch Changes diff --git a/packages/vercel-sandbox/README.md b/packages/vercel-sandbox/README.md index 416bc10d..cc4563ec 100644 --- a/packages/vercel-sandbox/README.md +++ b/packages/vercel-sandbox/README.md @@ -52,7 +52,9 @@ async function main() { resources: { vcpus: 4 }, ports: [3000], runtime: "node24", + name: "vercel-sandbox-example", }); + console.log(`Sandbox ${sandbox.name} created`); console.log(`Installing dependencies...`); const install = await sandbox.runCommand({ @@ -62,7 +64,7 @@ async function main() { stdout: process.stdout, }); - if (install.exitCode != 0) { + if (install.exitCode !== 0) { console.log("installing packages failed"); process.exit(1); } @@ -98,6 +100,43 @@ This will: All while streaming logs to your local terminal. +Sandboxes are persistent by default. To resume a sandbox with its previous state: + +Create a `resume.mts` file: + +```ts +import { Sandbox } from "@vercel/sandbox"; +import { setTimeout } from "timers/promises"; +import { spawn } from "child_process"; + +async function main() { + const sandbox = await Sandbox.get({ + name: "vercel-sandbox-example", + }); + console.log(`Sandbox ${sandbox.name} resumed`); + + console.log(`Starting the development server...`); + await sandbox.runCommand({ + cmd: "npm", + args: ["run", "dev"], + stderr: process.stderr, + stdout: process.stdout, + detached: true, + }); + + await setTimeout(500); + spawn("open", [sandbox.domain(3000)]); +} + +main().catch(console.error); +``` + +Run it: + +```sh +node --experimental-strip-types --env-file .env.local resume.mts +``` + ## Authentication ### Vercel OIDC token diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 31b253a0..01d34253 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/sandbox", - "version": "1.10.2", + "version": "2.0.0-beta.19", "description": "Software Development Kit for Vercel Sandbox", "type": "module", "main": "dist/index.cjs", 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 5d08d9ec..84f954eb 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.test.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.test.ts @@ -30,7 +30,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); const results: Array<{ stream: string; data: string }> = []; for await (const log of logs) { @@ -51,7 +51,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); const results: Array<{ stream: string; data: string }> = []; for await (const log of logs) { @@ -72,7 +72,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); await expect(async () => { for await (const _ of logs) { @@ -88,7 +88,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); try { for await (const _ of logs) { @@ -110,7 +110,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); await expect(async () => { for await (const _ of logs) { @@ -136,7 +136,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); const results: Array<{ stream: string; data: string }> = []; await expect(async () => { @@ -149,14 +149,14 @@ describe("APIClient", () => { expect(results[0]).toEqual({ stream: "stdout", data: "some logs" }); }); - it("includes sandboxId in APIError", async () => { + it("includes sessionId in APIError", async () => { mockFetch.mockResolvedValue( new Response(null, { headers: { "content-type": "application/json" }, }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); try { for await (const _ of logs) { @@ -164,11 +164,11 @@ describe("APIClient", () => { expect.fail("Expected APIError to be thrown"); } catch (err) { expect(err).toBeInstanceOf(APIError); - expect((err as APIError).sandboxId).toBe("sbx_123"); + expect((err as APIError).sessionId).toBe("sbx_123"); } }); - it("includes sandboxId in StreamError", async () => { + it("includes sessionId in StreamError", async () => { const logLines = [ { stream: "error", @@ -185,7 +185,7 @@ describe("APIClient", () => { }), ); - const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" }); + const logs = client.getLogs({ sessionId: "sbx_123", cmdId: "cmd_456" }); try { for await (const _ of logs) { @@ -193,7 +193,7 @@ describe("APIClient", () => { expect.fail("Expected StreamError to be thrown"); } catch (err) { expect(err).toBeInstanceOf(StreamError); - expect((err as StreamError).sandboxId).toBe("sbx_123"); + expect((err as StreamError).sessionId).toBe("sbx_123"); } }); }); @@ -218,7 +218,7 @@ describe("APIClient", () => { name: "echo", args: ["hello"], cwd: "/", - sandboxId: "sbx_123", + sessionId: "sbx_123", exitCode: null, startedAt: 1, }, @@ -237,7 +237,7 @@ describe("APIClient", () => { ); const result = await client.runCommand({ - sandboxId: "sbx_123", + sessionId: "sbx_123", command: "echo", args: ["hello"], env: {}, @@ -259,7 +259,7 @@ describe("APIClient", () => { try { await client.runCommand({ - sandboxId: "sbx_123", + sessionId: "sbx_123", command: "echo", args: ["hello"], env: {}, @@ -283,7 +283,7 @@ describe("APIClient", () => { name: "python3", args: ["script.py"], cwd: "/", - sandboxId: "sbx_123", + sessionId: "sbx_123", exitCode: null, startedAt: 1, }, @@ -306,7 +306,7 @@ describe("APIClient", () => { const controller = new AbortController(); const result = await client.runCommand({ - sandboxId: "sbx_123", + sessionId: "sbx_123", command: "python3", args: ["script.py"], env: {}, @@ -330,7 +330,7 @@ describe("APIClient", () => { name: "python3", args: ["script.py"], cwd: "/", - sandboxId: "sbx_123", + sessionId: "sbx_123", exitCode: null, startedAt: 1, }, @@ -353,7 +353,7 @@ describe("APIClient", () => { ); const result = await client.runCommand({ - sandboxId: "sbx_123", + sessionId: "sbx_123", command: "python3", args: ["script.py"], env: {}, @@ -386,7 +386,7 @@ describe("APIClient", () => { await expect( client.runCommand({ - sandboxId: "sbx_123", + sessionId: "sbx_123", command: "python3", args: ["script.py"], env: {}, @@ -398,11 +398,11 @@ describe("APIClient", () => { }); }); - describe("stopSandbox", () => { + describe("stopSession", () => { let client: APIClient; let mockFetch: ReturnType; - const makeSandbox = (status: string) => ({ + const makeSession = (status: string) => ({ id: "sbx_123", memory: 2048, vcpus: 1, @@ -416,8 +416,26 @@ describe("APIClient", () => { updatedAt: Date.now(), }); + const makeSandbox = () => ({ + name: "my-sandbox", + persistent: true, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + totalActiveCpuDurationMs: 1200, + totalIngressBytes: 1200000, + totalEgressBytes: 3400000, + totalDurationMs: 45000, + createdAt: Date.now(), + updatedAt: Date.now(), + currentSessionId: "sbx_123", + currentSnapshotId: "snap_456", + status: "stopped" as const, + }); + beforeEach(() => { - vi.useFakeTimers(); mockFetch = vi.fn(); client = new APIClient({ teamId: "team_123", @@ -426,107 +444,471 @@ describe("APIClient", () => { }); }); - afterEach(() => { - vi.useRealTimers(); + it("returns session from stop response", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ session: makeSession("stopped") }), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.stopSession({ sessionId: "sbx_123" }); + + expect(result.json.session.status).toBe("stopped"); + expect(result.json.sandbox).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(1); }); - it("returns immediately when blocking is not set", async () => { + it("parses sandbox metadata from stop response", async () => { + const sandbox = makeSandbox(); mockFetch.mockResolvedValue( - new Response(JSON.stringify({ sandbox: makeSandbox("stopping") }), { + new Response( + JSON.stringify({ session: makeSession("stopped"), sandbox }), + { headers: { "content-type": "application/json" } }, + ), + ); + + const result = await client.stopSession({ sessionId: "sbx_123" }); + + expect(result.json.session.status).toBe("stopped"); + expect(result.json.sandbox).toBeDefined(); + expect(result.json.sandbox?.name).toBe("my-sandbox"); + expect(result.json.sandbox?.totalActiveCpuDurationMs).toBe(1200); + expect(result.json.sandbox?.totalIngressBytes).toBe(1200000); + expect(result.json.sandbox?.totalEgressBytes).toBe(3400000); + expect(result.json.sandbox?.totalDurationMs).toBe(45000); + expect(result.json.sandbox?.currentSnapshotId).toBe("snap_456"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("getSandbox", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSandboxMetadata = () => ({ + name: "my-sandbox", + persistent: true, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSessionId: "sbx_123", + }); + + const makeSession = () => ({ + 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 sandbox by name and projectId", async () => { + const body = { + sandbox: makeSandboxMetadata(), + session: makeSession(), + 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.stopSandbox({ sandboxId: "sbx_123" }); + const result = await client.getSandbox({ + name: "my-sandbox", + projectId: "proj_123", + }); - expect(result.json.sandbox.status).toBe("stopping"); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.json.sandbox.name).toBe("my-sandbox"); + expect(result.json.session.id).toBe("sbx_123"); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/sandboxes/my-sandbox"); + expect(url).toContain("projectId=proj_123"); }); - it("polls until stopped when blocking is true", async () => { - mockFetch - .mockResolvedValueOnce( - new Response(JSON.stringify({ sandbox: makeSandbox("stopping") }), { - headers: { "content-type": "application/json" }, - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ sandbox: makeSandbox("stopping"), routes: [] }), - { headers: { "content-type": "application/json" } }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ sandbox: makeSandbox("stopped"), routes: [] }), - { headers: { "content-type": "application/json" } }, - ), - ); - - const promise = client.stopSandbox({ - sandboxId: "sbx_123", - blocking: true, + it("passes resume query param when provided", async () => { + const body = { + sandbox: makeSandboxMetadata(), + session: makeSession(), + routes: [], + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.getSandbox({ + name: "my-sandbox", + projectId: "proj_123", + resume: true, }); - // Advance past the two polling delays - await vi.advanceTimersByTimeAsync(500); - await vi.advanceTimersByTimeAsync(500); + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("resume=true"); + }); + }); - const result = await promise; - expect(result.json.sandbox.status).toBe("stopped"); - expect(mockFetch).toHaveBeenCalledTimes(3); + describe("listSandboxes", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSandboxMetadata = (name: string) => ({ + name, + persistent: false, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSessionId: "sbx_123", }); - it("stops polling on failed status", async () => { - mockFetch - .mockResolvedValueOnce( - new Response(JSON.stringify({ sandbox: makeSandbox("stopping") }), { - headers: { "content-type": "application/json" }, - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ sandbox: makeSandbox("failed"), routes: [] }), - { headers: { "content-type": "application/json" } }, - ), - ); - - const promise = client.stopSandbox({ - sandboxId: "sbx_123", - blocking: true, + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, }); + }); + + it("lists sandboxes with pagination", async () => { + const body = { + sandboxes: [makeSandboxMetadata("sb-1"), makeSandboxMetadata("sb-2")], + pagination: { count: 2, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); - await vi.advanceTimersByTimeAsync(500); + const result = await client.listSandboxes({ + projectId: "proj_123", + }); - const result = await promise; - expect(result.json.sandbox.status).toBe("failed"); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.json.sandboxes).toHaveLength(2); + expect(result.json.sandboxes[0].name).toBe("sb-1"); + expect(result.json.pagination.count).toBe(2); }); - it("stops polling on aborted status", async () => { - mockFetch - .mockResolvedValueOnce( - new Response(JSON.stringify({ sandbox: makeSandbox("stopping") }), { - headers: { "content-type": "application/json" }, - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ sandbox: makeSandbox("aborted"), routes: [] }), - { headers: { "content-type": "application/json" } }, - ), - ); - - const promise = client.stopSandbox({ - sandboxId: "sbx_123", - blocking: true, + it("passes all query params", async () => { + const body = { + sandboxes: [], + pagination: { count: 0, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.listSandboxes({ + 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"); + }); + + it("passes sortOrder and sortBy statusUpdatedAt", async () => { + const body = { + sandboxes: [makeSandboxMetadata("sb-1")], + pagination: { count: 1, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.listSandboxes({ + projectId: "proj_123", + sortBy: "statusUpdatedAt", + sortOrder: "desc", + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("sortBy=statusUpdatedAt"); + expect(url).toContain("sortOrder=desc"); + }); + }); + + describe("listSessions", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSession = () => ({ + 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("lists sessions with cursor pagination", async () => { + const body = { + sessions: [makeSession()], + pagination: { count: 1, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.listSessions({ + projectId: "proj_123", + }); + + expect(result.json.sessions).toHaveLength(1); + expect(result.json.pagination.count).toBe(1); + expect(result.json.pagination.next).toBeNull(); + }); + + it("passes cursor and sortOrder params", async () => { + const body = { + sessions: [], + pagination: { count: 0, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.listSessions({ + projectId: "proj_123", + cursor: "cursor_abc", + sortOrder: "asc", + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("cursor=cursor_abc"); + expect(url).toContain("sortOrder=asc"); + }); + }); + + describe("listSnapshots", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSnapshot = () => ({ + id: "snap_123", + sourceSessionId: "sbx_123", + region: "iad1", + status: "created", + sizeBytes: 1024, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("lists snapshots with cursor pagination", async () => { + const body = { + snapshots: [makeSnapshot()], + pagination: { count: 1, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.listSnapshots({ + projectId: "proj_123", + }); + + expect(result.json.snapshots).toHaveLength(1); + expect(result.json.pagination.count).toBe(1); + expect(result.json.pagination.next).toBeNull(); + }); + + it("passes cursor and sortOrder params", async () => { + const body = { + snapshots: [], + pagination: { count: 0, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.listSnapshots({ + projectId: "proj_123", + cursor: "cursor_xyz", + sortOrder: "desc", + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("cursor=cursor_xyz"); + expect(url).toContain("sortOrder=desc"); + }); + }); + + describe("updateSandbox", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSandboxMetadata = () => ({ + name: "my-sandbox", + persistent: true, + region: "iad1", + vcpus: 2, + memory: 4096, + runtime: "node24", + timeout: 600000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSessionId: "sbx_123", + snapshotExpiration: 604800000, + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("sends PATCH with update fields", async () => { + const body = { sandbox: makeSandboxMetadata() }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.updateSandbox({ + name: "my-sandbox", + projectId: "proj_123", + persistent: true, + timeout: 600000, + snapshotExpiration: 604800000, + currentSnapshotId: "snap_abc123", + }); + + expect(result.json.sandbox.name).toBe("my-sandbox"); + + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/sandboxes/my-sandbox"); + expect(url).toContain("projectId=proj_123"); + expect(opts.method).toBe("PATCH"); + + const parsedBody = JSON.parse(opts.body); + expect(parsedBody.persistent).toBe(true); + expect(parsedBody.timeout).toBe(600000); + expect(parsedBody.snapshotExpiration).toBe(604800000); + expect(parsedBody.currentSnapshotId).toBe("snap_abc123"); + }); + }); + + describe("deleteSandbox", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSandboxMetadata = () => ({ + name: "my-sandbox", + persistent: false, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSessionId: "sbx_123", + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("sends DELETE with projectId", async () => { + const body = { sandbox: makeSandboxMetadata() }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.deleteSandbox({ + name: "my-sandbox", + projectId: "proj_123", }); - await vi.advanceTimersByTimeAsync(500); + expect(result.json.sandbox.name).toBe("my-sandbox"); - const result = await promise; - expect(result.json.sandbox.status).toBe("aborted"); - expect(mockFetch).toHaveBeenCalledTimes(2); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/sandboxes/my-sandbox"); + expect(url).toContain("projectId=proj_123"); + expect(opts.method).toBe("DELETE"); }); }); @@ -547,7 +929,7 @@ describe("APIClient", () => { mockFetch.mockResolvedValue( new Response( JSON.stringify({ - sandbox: { + session: { id: "sbx_123", memory: 2048, vcpus: 1, @@ -562,7 +944,7 @@ describe("APIClient", () => { }, snapshot: { id: "snap_123", - sourceSandboxId: "sbx_123", + sourceSessionId: "sbx_123", region: "iad1", status: "created", sizeBytes: 1024, @@ -575,7 +957,7 @@ describe("APIClient", () => { ); await client.createSnapshot({ - sandboxId: "sbx_123", + sessionId: "sbx_123", expiration: 0, }); diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index d1b0a4e5..a1d325a9 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -5,21 +5,23 @@ import { type RequestParams, } from "./base-client.js"; import { - CommandFinishedData, - SandboxAndRoutesResponse, - SandboxResponse, + type CommandFinishedData, + SessionAndRoutesResponse, + SessionResponse, + StopSessionResponse, + SessionsResponse, CommandResponse, CommandFinishedResponse, EmptyResponse, LogLine, - LogLineStdout, - LogLineStderr, - SandboxesResponse, + type LogLineStdout, + type LogLineStderr, SnapshotsResponse, - ExtendTimeoutResponse, - UpdateNetworkPolicyResponse, SnapshotResponse, CreateSnapshotResponse, + SandboxAndSessionResponse, + SandboxesPaginationResponse, + UpdateSandboxResponse, type CommandData, } from "./validators.js"; import { APIError, StreamError } from "./api-error.js"; @@ -39,7 +41,6 @@ import { } from "../utils/network-policy.js"; import { getPrivateParams, WithPrivate } from "../utils/types.js"; import { RUNTIMES } from "../constants.js"; -import { setTimeout } from "node:timers/promises"; interface Claims { owner_id: string; @@ -137,15 +138,15 @@ export class APIClient extends BaseClient { }); } - async getSandbox( - params: WithPrivate<{ sandboxId: string; signal?: AbortSignal }>, + async getSession( + params: WithPrivate<{ sessionId: string; signal?: AbortSignal }>, ) { const privateParams = getPrivateParams(params); let querystring = new URLSearchParams(privateParams).toString(); querystring = querystring ? `?${querystring}` : ""; return parseOrThrow( - SandboxAndRoutesResponse, - await this.request(`/v1/sandboxes/${params.sandboxId}${querystring}`, { + SessionAndRoutesResponse, + await this.request(`/v2/sandboxes/sessions/${params.sessionId}${querystring}`, { signal: params.signal, }), ); @@ -153,6 +154,7 @@ export class APIClient extends BaseClient { async createSandbox( params: WithPrivate<{ + name?: string; ports?: number[]; projectId: string; source?: @@ -168,16 +170,19 @@ export class APIClient extends BaseClient { | { type: "snapshot"; snapshotId: string }; timeout?: number; resources?: { vcpus: number }; + persistent?: boolean; runtime?: RUNTIMES | (string & {}); networkPolicy?: NetworkPolicy; env?: Record; + tags?: Record; + snapshotExpiration?: number; signal?: AbortSignal; }>, ) { const privateParams = getPrivateParams(params); return parseOrThrow( - SandboxAndRoutesResponse, - await this.request("/v1/sandboxes", { + SandboxAndSessionResponse, + await this.request("/v2/sandboxes", { method: "POST", body: JSON.stringify({ projectId: params.projectId, @@ -186,10 +191,14 @@ export class APIClient extends BaseClient { timeout: params.timeout, resources: params.resources, runtime: params.runtime, + name: params.name, + persistent: params.persistent, networkPolicy: params.networkPolicy ? toAPINetworkPolicy(params.networkPolicy) : undefined, env: params.env, + tags: params.tags, + snapshotExpiration: params.snapshotExpiration, ...privateParams, }), signal: params.signal, @@ -198,7 +207,7 @@ export class APIClient extends BaseClient { } async runCommand(params: { - sandboxId: string; + sessionId: string; cwd?: string; command: string; args: string[]; @@ -208,7 +217,7 @@ export class APIClient extends BaseClient { signal?: AbortSignal; }): Promise<{ command: CommandData; finished: Promise }>; async runCommand(params: { - sandboxId: string; + sessionId: string; cwd?: string; command: string; args: string[]; @@ -218,7 +227,7 @@ export class APIClient extends BaseClient { signal?: AbortSignal; }): Promise>>; async runCommand(params: { - sandboxId: string; + sessionId: string; cwd?: string; command: string; args: string[]; @@ -229,7 +238,7 @@ export class APIClient extends BaseClient { }) { if (params.wait) { const response = await this.request( - `/v1/sandboxes/${params.sandboxId}/cmd`, + `/v2/sandboxes/sessions/${params.sessionId}/cmd`, { method: "POST", body: JSON.stringify({ @@ -251,14 +260,14 @@ export class APIClient extends BaseClient { if (response.headers.get("content-type") !== "application/x-ndjson") { throw new APIError(response, { message: "Expected a stream of command data", - sandboxId: params.sandboxId, + sessionId: params.sessionId, }); } if (response.body === null) { throw new APIError(response, { message: "No response body", - sandboxId: params.sandboxId, + sessionId: params.sessionId, }); } @@ -275,7 +284,7 @@ export class APIClient extends BaseClient { throw new StreamError( "stream_ended_early", "Stream ended before command data was received", - params.sandboxId, + params.sessionId, ); } const { command } = CommandResponse.parse(commandChunk.value); @@ -286,7 +295,7 @@ export class APIClient extends BaseClient { throw new StreamError( "stream_ended_early", "Stream ended before command finished", - params.sandboxId, + params.sessionId, ); } const { command } = CommandFinishedResponse.parse(finishedChunk.value); @@ -298,7 +307,7 @@ export class APIClient extends BaseClient { return parseOrThrow( CommandResponse, - await this.request(`/v1/sandboxes/${params.sandboxId}/cmd`, { + await this.request(`/v2/sandboxes/sessions/${params.sessionId}/cmd`, { method: "POST", body: JSON.stringify({ command: params.command, @@ -313,19 +322,19 @@ export class APIClient extends BaseClient { } async getCommand(params: { - sandboxId: string; + sessionId: string; cmdId: string; wait: true; signal?: AbortSignal; }): Promise>>; async getCommand(params: { - sandboxId: string; + sessionId: string; cmdId: string; wait?: boolean; signal?: AbortSignal; }): Promise>>; async getCommand(params: { - sandboxId: string; + sessionId: string; cmdId: string; wait?: boolean; signal?: AbortSignal; @@ -334,28 +343,28 @@ export class APIClient extends BaseClient { ? parseOrThrow( CommandFinishedResponse, await this.request( - `/v1/sandboxes/${params.sandboxId}/cmd/${params.cmdId}`, + `/v2/sandboxes/sessions/${params.sessionId}/cmd/${params.cmdId}`, { signal: params.signal, query: { wait: "true" } }, ), ) : parseOrThrow( CommandResponse, await this.request( - `/v1/sandboxes/${params.sandboxId}/cmd/${params.cmdId}`, + `/v2/sandboxes/sessions/${params.sessionId}/cmd/${params.cmdId}`, { signal: params.signal }, ), ); } async mkDir(params: { - sandboxId: string; + sessionId: string; path: string; cwd?: string; signal?: AbortSignal; }) { return parseOrThrow( EmptyResponse, - await this.request(`/v1/sandboxes/${params.sandboxId}/fs/mkdir`, { + await this.request(`/v2/sandboxes/sessions/${params.sessionId}/fs/mkdir`, { method: "POST", body: JSON.stringify({ path: params.path, cwd: params.cwd }), signal: params.signal, @@ -364,14 +373,14 @@ export class APIClient extends BaseClient { } getFileWriter(params: { - sandboxId: string; + sessionId: string; extractDir: string; signal?: AbortSignal; }) { const writer = new FileWriter(); return { response: (async () => { - return this.request(`/v1/sandboxes/${params.sandboxId}/fs/write`, { + return this.request(`/v2/sandboxes/sessions/${params.sessionId}/fs/write`, { method: "POST", headers: { "content-type": "application/gzip", @@ -385,43 +394,40 @@ export class APIClient extends BaseClient { }; } - async listSandboxes(params: { + async listSessions(params: { /** - * The ID or name of the project to which the sandboxes belong. + * The ID or name of the project to which the sessions belong. * @example "my-project" */ projectId: string; /** - * Maximum number of sandboxes to list from a request. + * Filter sessions by sandbox name. + */ + name?: string; + /** + * Maximum number of sessions to list from a request. * @example 10 */ limit?: number; /** - * Get sandboxes created after this JavaScript timestamp. - * @example 1540095775941 + * Cursor for pagination. */ - since?: number | Date; + cursor?: string; /** - * Get sandboxes created before this JavaScript timestamp. - * @example 1540095775951 + * Sort order for results. */ - until?: number | Date; + sortOrder?: "asc" | "desc"; signal?: AbortSignal; }) { return parseOrThrow( - SandboxesResponse, - await this.request(`/v1/sandboxes`, { + SessionsResponse, + await this.request(`/v2/sandboxes/sessions`, { query: { project: params.projectId, + name: params.name, limit: params.limit, - since: - typeof params.since === "number" - ? params.since - : params.since?.getTime(), - until: - typeof params.until === "number" - ? params.until - : params.until?.getTime(), + cursor: params.cursor, + sortOrder: params.sortOrder, }, method: "GET", signal: params.signal, @@ -435,37 +441,34 @@ export class APIClient extends BaseClient { * @example "my-project" */ projectId: string; + /** + * Filter snapshots by sandbox name. + */ + name?: string; /** * Maximum number of snapshots to list from a request. * @example 10 */ limit?: number; /** - * Get snapshots created after this JavaScript timestamp. - * @example 1540095775941 + * Cursor for pagination. */ - since?: number | Date; + cursor?: string; /** - * Get snapshots created before this JavaScript timestamp. - * @example 1540095775951 + * Sort order for results. */ - until?: number | Date; + sortOrder?: "asc" | "desc"; signal?: AbortSignal; }) { return parseOrThrow( SnapshotsResponse, - await this.request(`/v1/sandboxes/snapshots`, { + await this.request(`/v2/sandboxes/snapshots`, { query: { project: params.projectId, + name: params.name, limit: params.limit, - since: - typeof params.since === "number" - ? params.since - : params.since?.getTime(), - until: - typeof params.until === "number" - ? params.until - : params.until?.getTime(), + cursor: params.cursor, + sortOrder: params.sortOrder, }, method: "GET", signal: params.signal, @@ -474,7 +477,7 @@ export class APIClient extends BaseClient { } async writeFiles(params: { - sandboxId: string; + sessionId: string; cwd: string; files: { path: string; @@ -485,7 +488,7 @@ export class APIClient extends BaseClient { signal?: AbortSignal; }) { const { writer, response } = this.getFileWriter({ - sandboxId: params.sandboxId, + sessionId: params.sessionId, extractDir: params.extractDir, signal: params.signal, }); @@ -507,13 +510,13 @@ export class APIClient extends BaseClient { } async readFile(params: { - sandboxId: string; + sessionId: string; path: string; cwd?: string; signal?: AbortSignal; }): Promise { const response = await this.request( - `/v1/sandboxes/${params.sandboxId}/fs/read`, + `/v2/sandboxes/sessions/${params.sessionId}/fs/read`, { method: "POST", body: JSON.stringify({ path: params.path, cwd: params.cwd }), @@ -525,6 +528,10 @@ export class APIClient extends BaseClient { return null; } + if (!response.ok) { + await parseOrThrow(z.any(), response); + } + if (response.body === null) { return null; } @@ -533,7 +540,7 @@ export class APIClient extends BaseClient { } async killCommand(params: { - sandboxId: string; + sessionId: string; commandId: string; signal: number; abortSignal?: AbortSignal; @@ -541,7 +548,7 @@ export class APIClient extends BaseClient { return parseOrThrow( CommandResponse, await this.request( - `/v1/sandboxes/${params.sandboxId}/${params.commandId}/kill`, + `/v2/sandboxes/sessions/${params.sessionId}/cmd/${params.commandId}/kill`, { method: "POST", body: JSON.stringify({ signal: params.signal }), @@ -552,7 +559,7 @@ export class APIClient extends BaseClient { } getLogs(params: { - sandboxId: string; + sessionId: string; cmdId: string; signal?: AbortSignal; }): AsyncGenerator< @@ -568,7 +575,7 @@ export class APIClient extends BaseClient { : mergeSignals(params.signal, disposer.signal); const generator = (async function* () { - const url = `/v1/sandboxes/${params.sandboxId}/cmd/${params.cmdId}/logs`; + const url = `/v2/sandboxes/sessions/${params.sessionId}/cmd/${params.cmdId}/logs`; const response = await self.request(url, { method: "GET", signal, @@ -581,14 +588,14 @@ export class APIClient extends BaseClient { if (response.headers.get("content-type") !== "application/x-ndjson") { throw new APIError(response, { message: "Expected a stream of logs", - sandboxId: params.sandboxId, + sessionId: params.sessionId, }); } if (response.body === null) { throw new APIError(response, { message: "No response body", - sandboxId: params.sandboxId, + sessionId: params.sessionId, }); } @@ -603,7 +610,7 @@ export class APIClient extends BaseClient { throw new StreamError( parsed.data.code, parsed.data.message, - params.sandboxId, + params.sessionId, ); } yield parsed; @@ -618,45 +625,25 @@ export class APIClient extends BaseClient { }); } - async stopSandbox(params: { - sandboxId: string; + async stopSession(params: { + sessionId: string; signal?: AbortSignal; - blocking?: boolean; - }): Promise>> { - const url = `/v1/sandboxes/${params.sandboxId}/stop`; - const response = await parseOrThrow( - SandboxResponse, + }): Promise>> { + const url = `/v2/sandboxes/sessions/${params.sessionId}/stop`; + return parseOrThrow( + StopSessionResponse, await this.request(url, { method: "POST", signal: params.signal }), ); - - if (params.blocking) { - let sandbox = response.json.sandbox; - while ( - sandbox.status !== "stopped" && - sandbox.status !== "failed" && - sandbox.status !== "aborted" - ) { - await setTimeout(500, undefined, { signal: params.signal }); - const poll = await this.getSandbox({ - sandboxId: params.sandboxId, - signal: params.signal, - }); - sandbox = poll.json.sandbox; - response.json.sandbox = sandbox; - } - } - - return response; } async updateNetworkPolicy(params: { - sandboxId: string; + sessionId: string; networkPolicy: NetworkPolicy; signal?: AbortSignal; - }): Promise>> { - const url = `/v1/sandboxes/${params.sandboxId}/network-policy`; + }): Promise>> { + const url = `/v2/sandboxes/sessions/${params.sessionId}/network-policy`; return parseOrThrow( - UpdateNetworkPolicyResponse, + SessionResponse, await this.request(url, { method: "POST", body: JSON.stringify(toAPINetworkPolicy(params.networkPolicy)), @@ -666,13 +653,13 @@ export class APIClient extends BaseClient { } async extendTimeout(params: { - sandboxId: string; + sessionId: string; duration: number; signal?: AbortSignal; - }): Promise>> { - const url = `/v1/sandboxes/${params.sandboxId}/extend-timeout`; + }): Promise>> { + const url = `/v2/sandboxes/sessions/${params.sessionId}/extend-timeout`; return parseOrThrow( - ExtendTimeoutResponse, + SessionResponse, await this.request(url, { method: "POST", body: JSON.stringify({ duration: params.duration }), @@ -682,11 +669,11 @@ export class APIClient extends BaseClient { } async createSnapshot(params: { - sandboxId: string; + sessionId: string; expiration?: number; signal?: AbortSignal; }): Promise>> { - const url = `/v1/sandboxes/${params.sandboxId}/snapshot`; + const url = `/v2/sandboxes/sessions/${params.sessionId}/snapshot`; const body = params.expiration === undefined ? undefined @@ -705,7 +692,7 @@ export class APIClient extends BaseClient { snapshotId: string; signal?: AbortSignal; }): Promise>> { - const url = `/v1/sandboxes/snapshots/${params.snapshotId}`; + const url = `/v2/sandboxes/snapshots/${params.snapshotId}`; return parseOrThrow( SnapshotResponse, await this.request(url, { method: "DELETE", signal: params.signal }), @@ -716,12 +703,117 @@ export class APIClient extends BaseClient { snapshotId: string; signal?: AbortSignal; }): Promise>> { - const url = `/v1/sandboxes/snapshots/${params.snapshotId}`; + const url = `/v2/sandboxes/snapshots/${params.snapshotId}`; return parseOrThrow( SnapshotResponse, await this.request(url, { signal: params.signal }), ); } + + async getSandbox(params: WithPrivate<{ + name: string; + projectId: string; + resume?: boolean; + signal?: AbortSignal; + }>) { + const privateParams = getPrivateParams(params); + const query: Record = { + projectId: params.projectId, + ...privateParams, + }; + if (params.resume !== undefined) { + query.resume = String(params.resume); + } + return parseOrThrow( + SandboxAndSessionResponse, + await this.request(`/v2/sandboxes/${encodeURIComponent(params.name)}`, { + query, + signal: params.signal, + }), + ); + } + + async listSandboxes(params: { + projectId: string; + limit?: number; + sortBy?: "createdAt" | "name" | "statusUpdatedAt"; + sortOrder?: "asc" | "desc"; + namePrefix?: string; + cursor?: string; + tags?: Record; + signal?: AbortSignal; + }) { + return parseOrThrow( + SandboxesPaginationResponse, + await this.request(`/v2/sandboxes`, { + query: { + project: params.projectId, + limit: params.limit, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + namePrefix: params.namePrefix, + cursor: params.cursor, + tags: toTagsFilter(params.tags), + }, + method: "GET", + signal: params.signal, + }), + ); + } + + async updateSandbox(params: { + name: string; + projectId: string; + persistent?: boolean; + resources?: { vcpus?: number; memory?: number }; + runtime?: RUNTIMES | (string & {}); + timeout?: number; + networkPolicy?: NetworkPolicy; + tags?: Record; + snapshotExpiration?: number; + currentSnapshotId?: string; + signal?: AbortSignal; + }) { + return parseOrThrow( + UpdateSandboxResponse, + await this.request(`/v2/sandboxes/${encodeURIComponent(params.name)}`, { + method: "PATCH", + query: { + projectId: params.projectId, + }, + body: JSON.stringify({ + persistent: params.persistent, + resources: params.resources, + runtime: params.runtime, + timeout: params.timeout, + networkPolicy: params.networkPolicy + ? toAPINetworkPolicy(params.networkPolicy) + : undefined, + tags: params.tags, + snapshotExpiration: params.snapshotExpiration, + currentSnapshotId: params.currentSnapshotId, + }), + signal: params.signal, + }), + ); + } + + async deleteSandbox(params: { + name: string; + projectId: string; + signal?: AbortSignal; + }) { + return parseOrThrow( + UpdateSandboxResponse, + await this.request(`/v2/sandboxes/${encodeURIComponent(params.name)}`, { + method: "DELETE", + query: { + projectId: params.projectId, + }, + signal: params.signal, + }), + ); + } } async function pipe( @@ -798,3 +890,12 @@ function mergeSignals(...signals: [AbortSignal, ...AbortSignal[]]) { } return controller.signal; } + +function toTagsFilter( + tags: Record | undefined, +): string[] | undefined { + if (tags === undefined) return undefined; + const entries = Object.entries(tags); + if (entries.length === 0) return undefined; + return entries.map(([key, value]) => `${key}:${value}`); +} diff --git a/packages/vercel-sandbox/src/api-client/api-error.ts b/packages/vercel-sandbox/src/api-client/api-error.ts index 64c164ab..e65490a9 100644 --- a/packages/vercel-sandbox/src/api-client/api-error.ts +++ b/packages/vercel-sandbox/src/api-client/api-error.ts @@ -2,7 +2,8 @@ interface Options { message?: string; json?: ErrorData; text?: string; - sandboxId?: string; + sandboxName?: string; + sessionId?: string; } export class APIError extends Error { @@ -10,7 +11,8 @@ export class APIError extends Error { public message: string; public json?: ErrorData; public text?: string; - public sandboxId?: string; + public sandboxName?: string; + public sessionId?: string constructor(response: Response, options?: Options) { super(response.statusText); @@ -22,7 +24,8 @@ export class APIError extends Error { this.message = options?.message ?? ""; this.json = options?.json; this.text = options?.text; - this.sandboxId = options?.sandboxId; + this.sandboxName = options?.sandboxName; + this.sessionId = options?.sessionId; } } @@ -32,13 +35,13 @@ export class APIError extends Error { */ export class StreamError extends Error { public code: string; - public sandboxId: string; + public sessionId: string; - constructor(code: string, message: string, sandboxId: string) { + constructor(code: string, message: string, sessionId: string) { super(message); this.name = "StreamError"; this.code = code; - this.sandboxId = sandboxId; + this.sessionId = sessionId; if (Error.captureStackTrace) { Error.captureStackTrace(this, StreamError); } diff --git a/packages/vercel-sandbox/src/api-client/base-client.ts b/packages/vercel-sandbox/src/api-client/base-client.ts index 42f83424..93a783a8 100644 --- a/packages/vercel-sandbox/src/api-client/base-client.ts +++ b/packages/vercel-sandbox/src/api-client/base-client.ts @@ -90,11 +90,19 @@ export interface Parsed { } /** - * Extract sandboxId from a sandbox API URL. - * URLs follow the pattern: /v1/sandboxes/{sandboxId}/... + * Extract sessionId from a sandbox API URL. */ -function extractSandboxId(url: string): string | undefined { - const match = url.match(/\/v1\/sandboxes\/([^/?]+)/); +function extractSessionId(url: string): string | undefined { + const match = url.match(/\/v2\/sandboxes\/sessions\/([^/?]+)/); + return match?.[1]; +} + +/** + * Extract sandbox name from a sandbox API url. + * Excludes known sub-paths like /sessions/ and /snapshots/. + */ +function extractSandboxName(url: string): string | undefined { + const match = url.match(/\/v2\/sandboxes\/(?!sessions(?:\/|$|\?))(?!snapshots(?:\/|$|\?))([^/?]+)/); return match?.[1]; } @@ -109,12 +117,17 @@ export async function parse( validator: ZodType, response: Response, ): Promise | APIError> { - const sandboxId = extractSandboxId(response.url); + const sessionId = extractSessionId(response.url); + let sandboxName: string | undefined; + if (!sessionId) { + sandboxName = extractSandboxName(response.url); + } const text = await response.text().catch((err) => { return new APIError(response, { message: `Can't read response text: ${String(err)}`, - sandboxId, + sessionId, + sandboxName }); }); @@ -130,7 +143,8 @@ export async function parse( return new APIError(response, { message: `Can't parse JSON: ${String(error)}`, text, - sandboxId, + sessionId, + sandboxName }); } @@ -139,7 +153,8 @@ export async function parse( message: `Status code ${response.status} is not ok`, json: json as ErrorData, text, - sandboxId, + sessionId, + sandboxName }); } @@ -149,7 +164,8 @@ export async function parse( message: `Response JSON is not valid: ${validated.error}`, json: json as ErrorData, text, - sandboxId, + sessionId, + sandboxName }); } diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index ec4173b4..f084fbe9 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -1,6 +1,24 @@ import { z } from "zod"; -export type SandboxMetaData = z.infer; +export type SessionMetaData = z.infer; + +const RuleMatcherValidator = z.object({ + exact: z.string().optional(), + startsWith: z.string().optional(), + regex: z.string().optional(), +}); + +const KeyValueMatcherValidator = z.object({ + key: RuleMatcherValidator.optional(), + value: RuleMatcherValidator.optional(), +}); + +const RuleMatchValidator = z.object({ + path: RuleMatcherValidator.optional(), + method: z.array(z.string()).optional(), + queryString: z.array(KeyValueMatcherValidator).optional(), + headers: z.array(KeyValueMatcherValidator).optional(), +}); export const InjectionRuleValidator = z.object({ domain: z.string(), @@ -8,23 +26,67 @@ export const InjectionRuleValidator = z.object({ headers: z.record(z.string()).optional(), // headerNames are returned in responses headerNames: z.array(z.string()).optional(), + match: RuleMatchValidator.optional(), }); -export const NetworkPolicyValidator = z.union([ - z.object({ mode: z.literal("allow-all") }).passthrough(), - z.object({ mode: z.literal("deny-all") }).passthrough(), - z +export const ForwardRuleValidator = z.object({ + domain: z.string(), + forwardURL: z.string(), + match: RuleMatchValidator.optional(), +}); + +export const NetworkPolicyTransformValidator = z.object({ + headers: z.record(z.string()).optional(), +}); + +export const NetworkPolicyRuleValidator = z.object({ + match: RuleMatchValidator.optional(), + transform: z.array(NetworkPolicyTransformValidator).optional(), + forwardURL: z.string().optional(), +}); + +export const V2NetworkPolicyObjectValidator = z.object({ + allow: z + .union([ + z.array(z.string()), + z.record(z.array(NetworkPolicyRuleValidator)), + ]) + .optional(), + subnets: z .object({ - mode: z.literal("custom"), - allowedDomains: z.array(z.string()).optional(), - allowedCIDRs: z.array(z.string()).optional(), - deniedCIDRs: z.array(z.string()).optional(), - injectionRules: z.array(InjectionRuleValidator).optional(), + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), }) - .passthrough(), + .optional(), +}); + +const NetworkPolicyModeValidator = z.union([ + z.object({ mode: z.literal("allow-all") }).passthrough(), + z.object({ mode: z.literal("deny-all") }).passthrough(), ]); -export const Sandbox = z.object({ +const LegacyCustomNetworkPolicyValidator = z + .object({ + mode: z.literal("custom"), + allowedDomains: z.array(z.string()).optional(), + allowedCIDRs: z.array(z.string()).optional(), + deniedCIDRs: z.array(z.string()).optional(), + injectionRules: z.array(InjectionRuleValidator).optional(), + forwardRules: z.array(ForwardRuleValidator).optional(), + }) + .passthrough(); + +export const NetworkPolicyRequestValidator = z.union([ + NetworkPolicyModeValidator, + V2NetworkPolicyObjectValidator.passthrough(), +]); + +export const NetworkPolicyResponseValidator = z.union([ + NetworkPolicyModeValidator, + LegacyCustomNetworkPolicyValidator, +]); + +export const Session = z.object({ id: z.string(), memory: z.number(), vcpus: z.number(), @@ -52,7 +114,7 @@ export const Sandbox = z.object({ cwd: z.string(), updatedAt: z.number(), interactivePort: z.number().optional(), - networkPolicy: NetworkPolicyValidator.optional(), + networkPolicy: NetworkPolicyResponseValidator.optional(), activeCpuDurationMs: z.number().optional(), networkTransfer: z.object({ ingress: z.number(), @@ -72,7 +134,7 @@ export type SnapshotMetadata = z.infer; export const Snapshot = z.object({ id: z.string(), - sourceSandboxId: z.string(), + sourceSessionId: z.string(), region: z.string(), status: z.enum(["created", "deleted", "failed"]), sizeBytes: z.number(), @@ -81,22 +143,9 @@ export const Snapshot = z.object({ updatedAt: z.number(), }); -export const Pagination = z.object({ - /** - * Amount of items in the current page. - * @example 20 - */ +export const CursorPagination = z.object({ count: z.number(), - /** - * Timestamp that must be used to request the next page. - * @example 1540095775951 - */ - next: z.number().nullable(), - /** - * Timestamp that must be used to request the previous page. - * @example 1540095775951 - */ - prev: z.number().nullable(), + next: z.string().nullable(), }); export type CommandData = z.infer; @@ -106,7 +155,7 @@ export const Command = z.object({ name: z.string(), args: z.array(z.string()), cwd: z.string(), - sandboxId: z.string(), + sessionId: z.string(), exitCode: z.number().nullable(), startedAt: z.number(), }); @@ -115,14 +164,19 @@ const CommandFinished = Command.extend({ exitCode: z.number(), }); -export const SandboxResponse = z.object({ - sandbox: Sandbox, +export const SessionResponse = z.object({ + session: Session.passthrough(), }); -export const SandboxAndRoutesResponse = SandboxResponse.extend({ +export const SessionAndRoutesResponse = SessionResponse.extend({ routes: z.array(SandboxRoute), }); +export const SessionsResponse = z.object({ + sessions: z.array(Session.passthrough()), + pagination: CursorPagination, +}); + export const CommandResponse = z.object({ command: Command, }); @@ -157,29 +211,64 @@ export const LogLine = z.discriminatedUnion("stream", [ LogError, ]); -export const SandboxesResponse = z.object({ - sandboxes: z.array(Sandbox), - pagination: Pagination, -}); - export const SnapshotsResponse = z.object({ snapshots: z.array(Snapshot), - pagination: Pagination, + pagination: CursorPagination, }); -export const ExtendTimeoutResponse = z.object({ - sandbox: Sandbox, +export const CreateSnapshotResponse = z.object({ + snapshot: Snapshot, + session: Session.passthrough(), }); -export const UpdateNetworkPolicyResponse = z.object({ - sandbox: Sandbox, +export const SnapshotResponse = z.object({ + snapshot: Snapshot, }); -export const CreateSnapshotResponse = z.object({ - snapshot: Snapshot, +export const Sandbox = z.object({ + name: z.string(), + persistent: z.boolean(), + region: z.string().optional(), + vcpus: z.number().optional(), + memory: z.number().optional(), + runtime: z.string().optional(), + timeout: z.number().optional(), + networkPolicy: NetworkPolicyResponseValidator.optional(), + totalEgressBytes: z.number().optional(), + totalIngressBytes: z.number().optional(), + totalActiveCpuDurationMs: z.number().optional(), + totalDurationMs: z.number().optional(), + createdAt: z.number(), + updatedAt: z.number(), + currentSessionId: z.string(), + currentSnapshotId: z.string().optional(), + status: Session.shape.status, + statusUpdatedAt: z.number().optional(), + cwd: z.string().optional(), + tags: z.record(z.string()).optional(), + snapshotExpiration: z.number().optional(), +}); + +export type SandboxMetaData = z.infer; + +export const StopSessionResponse = z.object({ + session: Session.passthrough(), + sandbox: Sandbox.optional(), + snapshot: Snapshot.optional(), +}); + +export const SandboxAndSessionResponse = z.object({ sandbox: Sandbox, + session: Session.passthrough(), + routes: z.array(SandboxRoute), + resumed: z.boolean().optional(), }); -export const SnapshotResponse = z.object({ - snapshot: Snapshot, +export const SandboxesPaginationResponse = z.object({ + sandboxes: z.array(Sandbox), + pagination: CursorPagination, +}); + +export const UpdateSandboxResponse = z.object({ + sandbox: Sandbox, }); diff --git a/packages/vercel-sandbox/src/command.serialize.test.ts b/packages/vercel-sandbox/src/command.serialize.test.ts index 92770225..97e8d743 100644 --- a/packages/vercel-sandbox/src/command.serialize.test.ts +++ b/packages/vercel-sandbox/src/command.serialize.test.ts @@ -46,7 +46,7 @@ describe("CommandFinished serialization", () => { return new CommandFinished({ client, - sandboxId, + sessionId: sandboxId, cmd, exitCode, output, @@ -568,7 +568,7 @@ describe("Command serialization", () => { return new Command({ client, - sandboxId, + sessionId: sandboxId, cmd, output, }); diff --git a/packages/vercel-sandbox/src/command.test.ts b/packages/vercel-sandbox/src/command.test.ts index f2e6d21e..52221bdd 100644 --- a/packages/vercel-sandbox/src/command.test.ts +++ b/packages/vercel-sandbox/src/command.test.ts @@ -1,16 +1,20 @@ import { expect, it, vi, beforeEach, afterEach, describe } from "vitest"; +import ms from "ms"; import { Sandbox } from "./sandbox.js"; describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Command", () => { let sandbox: Sandbox; beforeEach(async () => { - sandbox = await Sandbox.create(); + sandbox = await Sandbox.create({ + persistent: false, + snapshotExpiration: ms("1d"), + }); }); afterEach(async () => { - await sandbox.stop(); - }); + await sandbox.delete(); + }, 30_000); it("supports more than one logs consumer", async () => { const stdoutSpy = vi @@ -48,7 +52,7 @@ describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Command", () => { await cmd.kill("SIGINT"); const result = await cmd.wait(); - expect(result.exitCode).toBe(130); // 128 + 2 + expect(result.exitCode).toBe(255); }); it("Kills a command with a SIGTERM", async () => { @@ -61,7 +65,7 @@ describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Command", () => { await cmd.kill("SIGTERM"); const result = await cmd.wait(); - expect(result.exitCode).toBe(143); // 128 + 15 + expect(result.exitCode).toBe(255); }); it("can execute commands with sudo", async () => { diff --git a/packages/vercel-sandbox/src/command.ts b/packages/vercel-sandbox/src/command.ts index 37a82087..6ac12fce 100644 --- a/packages/vercel-sandbox/src/command.ts +++ b/packages/vercel-sandbox/src/command.ts @@ -64,9 +64,9 @@ export class Command { } /** - * ID of the sandbox this command is running in. + * ID of the session this command is running in. */ - protected sandboxId: string; + protected sessionId: string; /** * Data for the command execution. @@ -106,23 +106,23 @@ export class Command { /** * @param params - Object containing the client, sandbox ID, and command data. * @param params.client - Optional API client. If not provided, will be lazily created using global credentials. - * @param params.sandboxId - The ID of the sandbox where the command is running. + * @param params.sessionId - The ID of the session where the command is running. * @param params.cmd - The command data. * @param params.output - Optional cached output to restore (used during deserialization). */ constructor({ client, - sandboxId, + sessionId, cmd, output, }: { client?: APIClient; - sandboxId: string; + sessionId: string; cmd: CommandData; output?: CommandOutput; }) { this._client = client ?? null; - this.sandboxId = sandboxId; + this.sessionId = sessionId; this.cmd = cmd; this.exitCode = cmd.exitCode ?? null; if (output) { @@ -145,7 +145,7 @@ export class Command { */ static [WORKFLOW_SERIALIZE](instance: Command): SerializedCommand { const serialized: SerializedCommand = { - sandboxId: instance.sandboxId, + sandboxId: instance.sessionId, cmd: instance.cmd, }; if (instance._resolvedOutput) { @@ -165,7 +165,7 @@ export class Command { */ static [WORKFLOW_DESERIALIZE](data: SerializedCommand): Command { return new Command({ - sandboxId: data.sandboxId, + sessionId: data.sandboxId, cmd: data.cmd, output: data.output, }); @@ -198,7 +198,7 @@ export class Command { ); } return this._client.getLogs({ - sandboxId: this.sandboxId, + sessionId: this.sessionId, cmdId: this.cmd.id, signal: opts?.signal, }); @@ -229,7 +229,7 @@ export class Command { params?.signal?.throwIfAborted(); const command = await client.getCommand({ - sandboxId: this.sandboxId, + sessionId: this.sessionId, cmdId: this.cmd.id, wait: true, signal: params?.signal, @@ -237,7 +237,7 @@ export class Command { return new CommandFinished({ client, - sandboxId: this.sandboxId, + sessionId: this.sessionId, cmd: command.json.command, exitCode: command.json.command.exitCode, }); @@ -346,7 +346,7 @@ export class Command { "use step"; const client = await this.ensureClient(); await client.killCommand({ - sandboxId: this.sandboxId, + sessionId: this.sessionId, commandId: this.cmd.id, signal: resolveSignal(signal ?? "SIGTERM"), abortSignal: opts?.abortSignal, @@ -373,14 +373,14 @@ export class CommandFinished extends Command { /** * @param params - Object containing client, sandbox ID, command data, and exit code. * @param params.client - Optional API client. If not provided, will be lazily created using global credentials. - * @param params.sandboxId - The ID of the sandbox where the command ran. + * @param params.sessionId - The ID of the session where the command ran. * @param params.cmd - The command data. * @param params.exitCode - The exit code of the completed command. * @param params.output - Optional cached output to restore (used during deserialization). */ constructor(params: { client?: APIClient; - sandboxId: string; + sessionId: string; cmd: CommandData; exitCode: number; output?: CommandOutput; @@ -417,7 +417,7 @@ export class CommandFinished extends Command { data: SerializedCommandFinished, ): CommandFinished { return new CommandFinished({ - sandboxId: data.sandboxId, + sessionId: data.sandboxId, cmd: data.cmd, exitCode: data.exitCode, output: data.output, diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 35daeb35..682bf74c 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -1,9 +1,15 @@ export { - Sandbox, type NetworkPolicy, + type NetworkPolicyKeyValueMatcher, + type NetworkPolicyMatch, + type NetworkPolicyMatcher, + Sandbox, +} from "./sandbox.js"; +export { + Session, type NetworkPolicyRule, type NetworkTransformer, -} from "./sandbox.js"; +} from "./session.js"; export type { SerializedSandbox } from "./sandbox.js"; export { Snapshot } from "./snapshot.js"; export type { SerializedSnapshot } from "./snapshot.js"; diff --git a/packages/vercel-sandbox/src/network-policy.ts b/packages/vercel-sandbox/src/network-policy.ts index 131e098a..b702ed31 100644 --- a/packages/vercel-sandbox/src/network-policy.ts +++ b/packages/vercel-sandbox/src/network-policy.ts @@ -11,12 +11,64 @@ export type NetworkTransformer = { headers?: Record; }; +/** + * Defines how a request value is matched. + */ +export type NetworkPolicyMatcher = { + /** Match the value exactly. */ + exact?: string; +} | { + /** Match values that start with the provided prefix. */ + startsWith?: string; +} | { + /** Match values against an RE2 regular expression. */ + regex?: string; +}; + +/** + * Matcher for key/value request entries such as headers and query parameters. + */ +export type NetworkPolicyKeyValueMatcher = { + /** Matcher for the entry key. */ + key?: NetworkPolicyMatcher; + /** Matcher for the entry value. */ + value?: NetworkPolicyMatcher; +}; + +/** + * Request matcher for a network policy rule. + * + * All specified dimensions must match. Multiple methods are ORed; multiple + * header and query-string matchers are ANDed. + */ +export type NetworkPolicyMatch = { + /** Match on the request path. */ + path?: NetworkPolicyMatcher; + /** Match on the HTTP method. */ + method?: string[]; + /** Match on query-string entries. */ + queryString?: NetworkPolicyKeyValueMatcher[]; + /** Match on request headers. */ + headers?: NetworkPolicyKeyValueMatcher[]; +}; + /** * A rule applied to requests matching a domain in the network policy. */ export type NetworkPolicyRule = { - /** Transforms to apply to matching requests. */ + /** + * Optional request matcher. When provided, transforms only apply to requests + * that match every specified dimension. + */ + match?: NetworkPolicyMatch; + /** + * Transforms to apply to matching requests. + */ transform?: NetworkTransformer[]; + /** + * HTTPS proxy URL to forward matching requests to. Must not include query string or fragment. + */ + forwardURL?: string; }; /** @@ -60,6 +112,13 @@ export type NetworkPolicyRule = { * allow: { * "ai-gateway.vercel.sh": [ * { + * match: { + * method: ["POST"], + * path: { startsWith: "/v1/" }, + * headers: [ + * { key: { exact: "x-api-key" }, value: { exact: "placeholder" } } + * ] + * }, * transform: [{ * headers: { authorization: "Bearer ..." } * }] diff --git a/packages/vercel-sandbox/src/sandbox.serialize.test.ts b/packages/vercel-sandbox/src/sandbox.serialize.test.ts index 07fdaa0b..689a3b36 100644 --- a/packages/vercel-sandbox/src/sandbox.serialize.test.ts +++ b/packages/vercel-sandbox/src/sandbox.serialize.test.ts @@ -5,14 +5,30 @@ import { } from "@workflow/core/serialization"; import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SandboxMetaData, SandboxRouteData } from "./api-client"; +import type { + SandboxMetaData, + SandboxRouteData, + SessionMetaData, +} from "./api-client"; import { APIClient } from "./api-client"; import { Sandbox, type SerializedSandbox } from "./sandbox"; -import { toSandboxSnapshot } from "./utils/sandbox-snapshot"; describe("Sandbox serialization", () => { - const mockMetadata: SandboxMetaData = { - id: "sbx_test123", + const mockSandboxMetadata: SandboxMetaData = { + name: "test-sandbox", + persistent: false, + currentSessionId: "sess_test123", + region: "us-east-1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: "2023-11-14T22:13:20.000Z", + updatedAt: "2023-11-14T22:13:22.000Z", + } as SandboxMetaData; + + const mockSessionMetadata: SessionMetaData = { + id: "sess_test123", memory: 2048, vcpus: 1, region: "us-east-1", @@ -25,17 +41,14 @@ describe("Sandbox serialization", () => { cwd: "/vercel/sandbox", updatedAt: 1700000002000, networkPolicy: { mode: "allow-all" }, - }; + } as SessionMetaData; const mockRoutes: SandboxRouteData[] = [ { url: "https://test-3000.vercel.run", subdomain: "test-3000", port: 3000 }, { url: "https://test-4000.vercel.run", subdomain: "test-4000", port: 4000 }, ]; - const createMockSandbox = ( - metadata: SandboxMetaData = mockMetadata, - routes: SandboxRouteData[] = mockRoutes, - ): Sandbox => { + const createMockSandbox = (): Sandbox => { const client = new APIClient({ teamId: "team_test", token: "test_token", @@ -43,8 +56,10 @@ describe("Sandbox serialization", () => { return new Sandbox({ client, - sandbox: toSandboxSnapshot(metadata), - routes, + sandbox: mockSandboxMetadata, + session: mockSessionMetadata, + routes: mockRoutes, + projectId: "proj_test", }); }; @@ -65,9 +80,13 @@ describe("Sandbox serialization", () => { const sandbox = createMockSandbox(); const serialized = serializeSandbox(sandbox); - expect(serialized.metadata.id).toBe("sbx_test123"); - expect(serialized.routes).toEqual(mockRoutes); + expect(serialized.metadata.id).toBe("sess_test123"); expect(serialized.metadata.networkPolicy).toBe("allow-all"); + expect(serialized.metadata.status).toBe("running"); + expect(serialized.metadata.memory).toBe(2048); + expect(serialized.routes).toEqual(mockRoutes); + expect(serialized.sandboxMetadata).toEqual(mockSandboxMetadata); + expect(serialized.projectId).toBe("proj_test"); }); it("returns plain JSON-serializable data", () => { @@ -77,7 +96,7 @@ describe("Sandbox serialization", () => { const jsonString = JSON.stringify(serialized); const parsed = JSON.parse(jsonString); - expect(parsed.metadata.id).toBe("sbx_test123"); + expect(parsed.metadata.id).toBe("sess_test123"); expect(parsed.routes).toEqual(mockRoutes); }); @@ -107,10 +126,18 @@ describe("Sandbox serialization", () => { const result = deserializeSandbox(serialized); - expect(result.sandboxId).toBe("sbx_test123"); - expect(result.status).toBe("running"); + // Sandbox-level metadata + expect(result.name).toBe("test-sandbox"); + expect(result.persistent).toBe(false); + expect((result as any).projectId).toBe("proj_test"); + + // Session is restored from the serialized snapshot + const session = result.currentSession(); + expect(session.sessionId).toBe("sess_test123"); + expect(session.status).toBe("running"); + expect(session.memory).toBe(2048); + expect(session.networkPolicy).toBe("allow-all"); expect(result.routes).toEqual(mockRoutes); - expect(result.networkPolicy).toBe("allow-all"); expect(result.domain(3000)).toBe("https://test-3000.vercel.run"); }); @@ -120,7 +147,7 @@ describe("Sandbox serialization", () => { const serializedData: SerializedSandbox = { metadata: { - id: "sbx_test123", + id: "sess_test123", memory: 2048, vcpus: 1, region: "us-east-1", @@ -135,13 +162,15 @@ describe("Sandbox serialization", () => { networkPolicy: "allow-all", }, routes: mockRoutes, + sandboxMetadata: mockSandboxMetadata, + projectId: "proj_test", }; const deserialized = FreshSandbox[WORKFLOW_DESERIALIZE]( serializedData, ) as Sandbox; - expect(deserialized.sandboxId).toBe("sbx_test123"); + expect(deserialized.name).toBe("test-sandbox"); expect(deserialized.status).toBe("running"); expect(deserialized.routes).toEqual(mockRoutes); }); @@ -152,7 +181,7 @@ describe("Sandbox serialization", () => { const serializedData: SerializedSandbox = { metadata: { - id: "sbx_test123", + id: "sess_test123", memory: 2048, vcpus: 1, region: "us-east-1", @@ -167,6 +196,8 @@ describe("Sandbox serialization", () => { networkPolicy: "allow-all", }, routes: mockRoutes, + sandboxMetadata: mockSandboxMetadata, + projectId: "proj_test", }; const deserialized = FreshSandbox[WORKFLOW_DESERIALIZE]( @@ -197,8 +228,7 @@ describe("Sandbox serialization", () => { ); expect(rehydrated).toBeInstanceOf(Sandbox); - expect(rehydrated.sandboxId).toBe("sbx_test123"); - expect(rehydrated.routes).toEqual(mockRoutes); + expect(rehydrated.name).toBe("test-sandbox"); }); it("preserves converted metadata through runtime pipeline", async () => { @@ -217,8 +247,7 @@ describe("Sandbox serialization", () => { undefined, ); - expect(rehydrated.status).toBe("running"); - expect(rehydrated.networkPolicy).toBe("allow-all"); + expect(rehydrated.name).toBe("test-sandbox"); }); }); }); diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 16807cd1..40cec2a2 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -2,6 +2,7 @@ import { it, beforeEach, afterEach, expect, describe, vi } from "vitest"; import { PassThrough } from "stream"; import { consumeReadable } from "./utils/consume-readable.js"; import { Sandbox } from "./sandbox.js"; +import { Snapshot } from "./snapshot.js"; import { APIError } from "./api-client/api-error.js"; import type { APIClient, @@ -18,7 +19,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + sandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile(undefined as any, { path: "/tmp/out" }), @@ -29,7 +32,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + sandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile({ path: "" }, { path: "/tmp/out" }), @@ -40,7 +45,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + sandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile({ path: "file.txt" }, undefined as any), @@ -51,7 +58,9 @@ describe("downloadFile validation", () => { const sandbox = new Sandbox({ client: {} as any, routes: [], - sandbox: { id: "test" } as any, + session: { id: "test" } as any, + sandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( sandbox.downloadFile({ path: "file.txt" }, { path: "" }), @@ -60,17 +69,19 @@ describe("downloadFile validation", () => { }); const makeSandboxMetadata = (): SandboxMetaData => ({ - id: "sbx_123", + name: "test-name", + currentSessionId: "sbx_123", + persistent: true, + status: "running", memory: 2048, vcpus: 1, region: "iad1", runtime: "node24", timeout: 300_000, - status: "running", - requestedAt: 1, - createdAt: 1, cwd: "/", updatedAt: 1, + createdAt: 1, + snapshotExpiration: 604800000, }); const makeCommand = (): CommandData => ({ @@ -78,7 +89,7 @@ const makeCommand = (): CommandData => ({ name: "echo", args: ["hello"], cwd: "/", - sandboxId: "sbx_123", + sessionId: "sbx_123", exitCode: null, startedAt: 1, }); @@ -88,7 +99,7 @@ describe("_runCommand error handling", () => { const command = makeCommand(); const logsError = new APIError(new Response("failed", { status: 500 }), { message: "Failed to stream logs", - sandboxId: "sbx_123", + sessionId: "sbx_123", }); const runCommandMock = vi.fn(async ({ wait }: { wait?: boolean }) => { @@ -115,6 +126,8 @@ describe("_runCommand error handling", () => { } as unknown as APIClient, routes: [], sandbox: makeSandboxMetadata(), + session: {} as any, + projectId: "test-project", }); await expect( @@ -130,7 +143,7 @@ describe("_runCommand error handling", () => { const command = makeCommand(); const logsError = new APIError(new Response("failed", { status: 500 }), { message: "Failed to stream logs", - sandboxId: "sbx_123", + sessionId: "sbx_123", }); const runCommandMock = vi.fn(async ({ wait }: { wait?: boolean }) => { @@ -157,6 +170,8 @@ describe("_runCommand error handling", () => { } as unknown as APIClient, routes: [], sandbox: makeSandboxMetadata(), + session: {} as any, + projectId: "test-project", }); const stdout = new PassThrough(); @@ -183,17 +198,100 @@ describe("_runCommand error handling", () => { }); }); +describe("Sandbox.getOrCreate", () => { + const CREDENTIALS = { + token: "test-token", + teamId: "team_123", + projectId: "proj_123", + }; + + const jsonResponse = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + + it("propagates non-404 errors from Sandbox.get without attempting create", async () => { + // 403 is non-retryable (<500 and !=429), so it surfaces immediately. + const mockFetch = vi.fn(async () => + jsonResponse(403, { error: { code: "forbidden", message: "nope" } }), + ); + const onCreate = vi.fn<(sandbox: Sandbox) => Promise>(); + + await expect( + Sandbox.getOrCreate({ + ...CREDENTIALS, + name: "my-sandbox", + fetch: mockFetch as unknown as typeof fetch, + onCreate, + }), + ).rejects.toBeInstanceOf(APIError); + + // Only the get call should have been made — no create attempt. + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/v2/sandboxes/my-sandbox"); + expect(init?.method ?? "GET").toBe("GET"); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it("propagates create errors when a name race occurs after a 404 from get", async () => { + const mockFetch = vi.fn(async (input, init) => { + const method = init?.method ?? "GET"; + if (method === "GET") { + return jsonResponse(404, { + error: { code: "not_found", message: "not found" }, + }); + } + if (method === "POST") { + return jsonResponse(400, { + error: { + code: "bad_request", + message: + "A sandbox with the name 'my-sandbox' already exists for this project.", + }, + }); + } + throw new Error(`Unexpected method ${method} to ${String(input)}`); + }); + const onCreate = vi.fn<(sandbox: Sandbox) => Promise>(); + + const promise = Sandbox.getOrCreate({ + ...CREDENTIALS, + name: "my-sandbox", + fetch: mockFetch as unknown as typeof fetch, + onCreate, + }); + + await expect(promise).rejects.toBeInstanceOf(APIError); + await expect(promise).rejects.toMatchObject({ + response: { status: 400 }, + json: { error: { code: "bad_request" } }, + }); + + // One GET (get-sandbox 404) + one POST (create 400). onCreate must not fire. + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(onCreate).not.toHaveBeenCalled(); + }); +}); + describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Sandbox", () => { const PORTS = [3000, 4000]; + const SNAPSHOT_EXPIRATION = ms("1d"); + let sandbox: Sandbox; beforeEach(async () => { - sandbox = await Sandbox.create({ ports: PORTS }); + sandbox = await Sandbox.create({ + ports: PORTS, + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); }); afterEach(async () => { - await sandbox.stop(); - }); + await sandbox.delete(); + }, 30_000); it("allows to write files and then read them as a stream", async () => { await sandbox.writeFiles([ @@ -323,11 +421,46 @@ 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 () => { + const sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + try { + await sbx.stop(); + const result = await sbx.runCommand("echo", ["resumed!"]); + expect(result.exitCode).toBe(0); + expect(await result.stdout()).toContain("resumed!"); + } finally { + await sbx.delete(); + } + }); + + it("auto-resumes a stopped session when reading a file", async () => { + const sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + await sbx.writeFiles([ + { path: "persist.txt", content: Buffer.from("persisted content") }, + ]); + await sbx.stop(); + + const content = await sbx.readFileToBuffer({ path: "persist.txt" }); + expect(content?.toString()).toBe("persisted content"); + } finally { + await sbx.delete(); + } }); it("raises an error when the timeout cannot be updated", async () => { @@ -344,4 +477,349 @@ for (const port of ports) { }); } }); + + it("returns not found when getting a deleted sandbox", async () => { + const sbx = await Sandbox.create({ + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + const name = sbx.name; + await sbx.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 sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + await sbx.stop(); + + const resumed = await Sandbox.get({ name: sbx.name, resume: true }); + const { sessions } = await resumed.listSessions(); + + expect(sessions).toHaveLength(2); + + const currentSessionId = resumed.currentSession().sessionId; + const match = sessions.find((s) => s.id === currentSessionId); + expect(match).toBeDefined(); + } finally { + await sbx.delete(); + } + }); + + it("lists one snapshot after creating one", async () => { + await sandbox.snapshot(); + + const { snapshots } = await sandbox.listSnapshots(); + expect(snapshots).toHaveLength(1); + }); + + it("reflects updated resources after update", async () => { + const sbx = await Sandbox.create({ + timeout: 60_000, + persistent: true, + snapshotExpiration: 7 * 86400000, + }); + + try { + expect(sbx.snapshotExpiration).toBe(7 * 86400000); + await sbx.stop(); + + const { snapshotId } = await sbx.snapshot(); + + await sbx.update({ + resources: { vcpus: 4 }, + timeout: 30_000, + persistent: false, + snapshotExpiration: 2 * 86400000, + currentSnapshotId: snapshotId, + }); + + const updated = await Sandbox.get({ + name: sbx.name, + resume: false, + }); + expect(updated.vcpus).toBe(4); + expect(updated.memory).toBe(8192); + expect(updated.timeout).toBe(30_000); + expect(updated.persistent).toBe(false); + expect(updated.snapshotExpiration).toBe(2 * 86400000); + expect(updated.currentSnapshotId).toBe(snapshotId); + } finally { + await sbx.delete(); + } + }); + + it("appears in the sandbox list after creation", async () => { + await sandbox.stop(); + const { sandboxes } = await Sandbox.list({ limit: 1 }); + expect(sandboxes).toHaveLength(1); + expect(sandboxes[0].name).toBe(sandbox.name); + }); + + it("calls onResume when Sandbox.get resumes a stopped sandbox", async () => { + const sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + await sbx.stop(); + + let resumedSandbox: Sandbox | null = null; + const retrieved = await Sandbox.get({ + name: sbx.name, + resume: true, + onResume: async (s) => { + resumedSandbox = s; + }, + }); + + expect(resumedSandbox).toBe(retrieved); + } finally { + await sbx.delete(); + } + }); + + it("calls onResume on auto-resume after a stopped session", async () => { + let resumeCount = 0; + const sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + onResume: async () => { + resumeCount++; + }, + }); + + try { + await sbx.stop(); + await sbx.runCommand("echo", ["hello"]); + + expect(resumeCount).toBe(1); + } finally { + await sbx.delete(); + } + }); + + it("updates status and currentSnapshotId after stopping a persistent sandbox", async () => { + const sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + expect(sbx.status).toBe("running"); + + await sbx.stop(); + + expect(sbx.status).toBe("stopped"); + expect(sbx.currentSnapshotId).not.toBeNull(); + } finally { + await sbx.delete(); + } + }); + + it("does not call onResume when Sandbox.get does not resume", async () => { + let called = false; + await Sandbox.get({ + name: sandbox.name, + resume: true, + onResume: async () => { + called = true; + }, + }); + + expect(called).toBe(false); + }); + + it("paginates Sandbox.list across multiple pages", async () => { + const tag = `pagination-${Date.now()}`; + const [a, b] = await Promise.all([ + Sandbox.create({ + tags: { test: tag }, + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }), + Sandbox.create({ + tags: { test: tag }, + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }), + ]); + + try { + await Promise.all([a.stop(), b.stop()]); + + const firstPage = await Sandbox.list({ limit: 1, tags: { test: tag } }); + expect(firstPage.sandboxes).toHaveLength(1); + expect(firstPage.pagination.next).not.toBeNull(); + + const all = await firstPage.toArray(); + expect(all).toHaveLength(2); + expect(new Set(all.map((s) => s.name))).toEqual( + new Set([a.name, b.name]), + ); + } finally { + await Promise.all([a.delete(), b.delete()]); + } + }); + + it("paginates listSessions across multiple pages", async () => { + const sbx = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + await sbx.stop(); + await Sandbox.get({ name: sbx.name, resume: true }); + + const firstPage = await sbx.listSessions({ limit: 1 }); + expect(firstPage.sessions).toHaveLength(1); + expect(firstPage.pagination.next).not.toBeNull(); + + const all = await firstPage.toArray(); + expect(all).toHaveLength(2); + } finally { + await sbx.delete(); + } + }); + + it("paginates listSnapshots across multiple pages", async () => { + const sbx = await Sandbox.create({ + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + await sbx.snapshot(); + await sbx.snapshot(); + + const firstPage = await sbx.listSnapshots({ limit: 1 }); + expect(firstPage.snapshots).toHaveLength(1); + expect(firstPage.pagination.next).not.toBeNull(); + + const all = await firstPage.toArray(); + expect(all).toHaveLength(2); + } finally { + await sbx.delete(); + } + }); + + it("paginates Snapshot.list across multiple pages", async () => { + const sbx = await Sandbox.create({ + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + + try { + await sbx.snapshot(); + await sbx.snapshot(); + + const firstPage = await Snapshot.list({ name: sbx.name, limit: 1 }); + expect(firstPage.snapshots).toHaveLength(1); + expect(firstPage.pagination.next).not.toBeNull(); + + const all = await firstPage.toArray(); + expect(all).toHaveLength(2); + } finally { + await sbx.delete(); + } + }); + + describe("getOrCreate", () => { + it("creates a new sandbox and fires onCreate when no name is provided", async () => { + const onCreate = vi.fn<(sandbox: Sandbox) => Promise>( + async () => {}, + ); + sandbox = await Sandbox.getOrCreate({ + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + onCreate, + }); + + expect(sandbox.status).toBe("running"); + expect(onCreate).toHaveBeenCalledTimes(1); + expect(onCreate.mock.calls[0][0]).toBe(sandbox); + }); + + it("returns the existing sandbox without firing onCreate when the name exists", async () => { + const onCreate = vi.fn<(sandbox: Sandbox) => Promise>( + async () => {}, + ); + const fetched = await Sandbox.getOrCreate({ + name: sandbox.name, + onCreate, + }); + + expect(fetched.name).toBe(sandbox.name); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it("creates a new sandbox with the given name and fires onCreate when the name does not exist", async () => { + const name = `goc-new-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const onCreate = vi.fn<(sandbox: Sandbox) => Promise>( + async () => {}, + ); + + sandbox = await Sandbox.getOrCreate({ + name, + persistent: false, + snapshotExpiration: SNAPSHOT_EXPIRATION, + onCreate, + }); + + expect(sandbox.name).toBe(name); + expect(sandbox.status).toBe("running"); + expect(onCreate).toHaveBeenCalledTimes(1); + expect(onCreate.mock.calls[0][0]).toBe(sandbox); + }); + + it("recreates the sandbox with the same name when the snapshot is missing", async () => { + // A persistent sandbox + stop() reliably creates a snapshot and + // updates `currentSnapshotId`. Deleting that snapshot makes the next + // resume fail with `snapshot_not_found`. + sandbox = await Sandbox.create({ + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + }); + const name = sandbox.name; + const originalSessionId = sandbox.currentSession().sessionId; + await sandbox.stop(); + + const snapshotId = sandbox.currentSnapshotId; + expect(snapshotId).not.toBeNull(); + const snapshot = await Snapshot.get({ snapshotId: snapshotId! }); + await snapshot.delete(); + + const onCreate = vi.fn<(sandbox: Sandbox) => Promise>( + async () => {}, + ); + sandbox = await Sandbox.getOrCreate({ + name, + persistent: true, + snapshotExpiration: SNAPSHOT_EXPIRATION, + resume: true, + onCreate, + }); + + expect(sandbox.name).toBe(name); + expect(sandbox.currentSession().sessionId).not.toBe(originalSessionId); + expect(sandbox.status).toBe("running"); + expect(onCreate).toHaveBeenCalledTimes(1); + expect(onCreate.mock.calls[0][0]).toBe(sandbox); + }); + }); }); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 310aed33..c012643b 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -1,33 +1,44 @@ import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; -import { createWriteStream } from "fs"; -import { mkdir } from "fs/promises"; -import { dirname, resolve } from "path"; -import type { Writable } from "stream"; -import { pipeline } from "stream/promises"; -import type { WithFetchOptions } from "./api-client/api-client.js"; -import type { SandboxMetaData, SandboxRouteData } from "./api-client/index.js"; +import type { + SessionMetaData, + SandboxRouteData, + SandboxMetaData, + SnapshotMetadata, +} from "./api-client/index.js"; import { APIClient } from "./api-client/index.js"; -import { Command, CommandFinished } from "./command.js"; +import { APIError } from "./api-client/api-error.js"; +import { type Credentials, getCredentials } from "./utils/get-credentials.js"; +import { getPrivateParams, type WithPrivate } from "./utils/types.js"; +import type { WithFetchOptions } from "./api-client/api-client.js"; import type { RUNTIMES } from "./constants.js"; +import { Session, type RunCommandParams } from "./session.js"; +import type { Command, CommandFinished } from "./command.js"; +import type { Snapshot } from "./snapshot.js"; +import type { SandboxSnapshot } from "./utils/sandbox-snapshot.js"; import type { NetworkPolicy, - NetworkPolicyRule, - NetworkTransformer, + NetworkPolicyKeyValueMatcher, + NetworkPolicyMatch, + NetworkPolicyMatcher, } from "./network-policy.js"; -import { Snapshot } from "./snapshot.js"; -import { consumeReadable } from "./utils/consume-readable.js"; -import { type Credentials, getCredentials } from "./utils/get-credentials.js"; -import { - type SandboxSnapshot, - toSandboxSnapshot, -} from "./utils/sandbox-snapshot.js"; -import { getPrivateParams, type WithPrivate } from "./utils/types.js"; +import { fromAPINetworkPolicy } from "./utils/network-policy.js"; +import { attachPaginator } from "./utils/paginator.js"; +import { setTimeout } from "node:timers/promises"; import { FileSystem } from "./filesystem.js"; -export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; +export type { + NetworkPolicy, + NetworkPolicyKeyValueMatcher, + NetworkPolicyMatch, + NetworkPolicyMatcher, +}; /** @inline */ export interface BaseCreateSandboxParams { + /** + * The name of the sandbox. If omitted, a random name will be generated. + */ + name?: string; /** * The source of the sandbox. * @@ -69,19 +80,16 @@ export interface BaseCreateSandboxParams { * 2048 MB of memory per vCPU. */ resources?: { vcpus: number }; - /** * The runtime of the sandbox, currently only `node24`, `node22`, `node26` 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 @@ -95,11 +103,31 @@ export interface BaseCreateSandboxParams { * await sandbox.runCommand("node", ["app.js"]); */ env?: Record; + /** + * Key-value tags to associate with the sandbox. Maximum 5 tags. + * @example { env: "staging", team: "infra" } + */ + tags?: Record; /** * An AbortSignal to cancel sandbox creation. */ signal?: AbortSignal; + /** + * Enable or disable automatic restore of the filesystem between sessions. + */ + persistent?: boolean; + /** + * Default snapshot expiration in milliseconds. + * When set, snapshots created for this sandbox will expire after this duration. + * Use `0` for no expiration. + */ + snapshotExpiration?: number; + /** + * Called when the sandbox session is resumed (e.g., after a snapshot restore). + * Use this to re-warm caches, restore transient state, or run other setup logic. + */ + onResume?: (sandbox: Sandbox) => Promise; } export type CreateSandboxParams = @@ -111,61 +139,86 @@ export type CreateSandboxParams = /** @inline */ interface GetSandboxParams { /** - * Unique identifier of the sandbox. + * The name of the sandbox. */ - sandboxId: string; + name: string; + /** + * Whether to resume an existing session. Defaults to true. + */ + resume?: boolean; /** * An AbortSignal to cancel the operation. */ signal?: AbortSignal; + /** + * Called when the sandbox session is resumed (e.g., after a snapshot restore). + * Use this to re-warm caches, restore transient state, or run other setup logic. + */ + onResume?: (sandbox: Sandbox) => Promise; +} + +/** + * Extends both {@link BaseCreateSandboxParams} and {@link GetSandboxParams} + * (minus `name`, which is required on get but optional here) so that any + * new parameter added to either flow is picked up automatically. The + * structural overlap on `signal` / `onResume` is intentional — both + * interfaces declare them with identical optional types. + * @inline + */ +interface GetOrCreateSandboxParams + extends BaseCreateSandboxParams, + Omit { + /** + * Called once after a sandbox is freshly created (not when an existing + * sandbox is retrieved). Use this for one-time setup such as seeding + * files or warming caches. The returned promise is awaited before + * {@link Sandbox.getOrCreate} resolves. + */ + onCreate?: (sandbox: Sandbox) => Promise; +} + +function isSandboxStoppedError(err: unknown): boolean { + return err instanceof APIError && err.response.status === 410; +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof APIError && err.response.status === 404; +} + +function isSnapshotNotFoundError(err: unknown): boolean { + return ( + err instanceof APIError && + err.response.status === 400 && + (err.json as any)?.error?.code === "snapshot_not_found" + ); +} + +function isSandboxStoppingError(err: unknown): boolean { + return ( + err instanceof APIError && + err.response.status === 422 && + (err.json as any)?.error?.code === "sandbox_stopping" + ); +} + +function isSandboxSnapshottingError(err: unknown): boolean { + return ( + err instanceof APIError && + err.response.status === 422 && + (err.json as any)?.error?.code === "sandbox_snapshotting" + ); } /** * Serialized representation of a Sandbox for @workflow/serde. + * Fields `metadata` and `routes` are the original wire format from main. + * Fields `sandboxMetadata` and `projectId` are added for named-sandboxes. */ export interface SerializedSandbox { metadata: SandboxSnapshot; routes: SandboxRouteData[]; -} - -/** @inline */ -interface RunCommandParams { - /** - * The command to execute - */ - cmd: string; - /** - * Arguments to pass to the command - */ - args?: string[]; - /** - * Working directory to execute the command in - */ - cwd?: string; - /** - * Environment variables to set for this command - */ - env?: Record; - /** - * If true, execute this command with root privileges. Defaults to false. - */ - sudo?: boolean; - /** - * If true, the command will return without waiting for `exitCode` - */ - detached?: boolean; - /** - * A `Writable` stream where `stdout` from the command will be piped - */ - stdout?: Writable; - /** - * A `Writable` stream where `stderr` from the command will be piped - */ - stderr?: Writable; - /** - * An AbortSignal to cancel the command execution - */ - signal?: AbortSignal; + sandboxMetadata?: SandboxMetaData; + projectId?: string; } // ============================================================================ @@ -173,19 +226,47 @@ interface RunCommandParams { // ============================================================================ /** - * A Sandbox is an isolated Linux MicroVM to run commands in. - * + * 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 _client: APIClient | null = null; + private readonly projectId: string; + + /** + * In-flight resume promise, used to deduplicate concurrent resume calls. + */ + private resumePromise: Promise | null = null; + + /** + * Internal Session instance for the current VM. + */ + private session: Session | undefined; + + /** + * Internal metadata about the sandbox. + */ + private sandbox: SandboxMetaData; + + /** + * Hook that will be executed when a new session is created during resume. + */ + private readonly onResume?: (sandbox: Sandbox) => Promise; + + /** + * A `node:fs/promises`-compatible API for interacting with the sandbox filesystem. + * + * @example + * const content = await sandbox.fs.readFile('/etc/hostname', 'utf8'); + * await sandbox.fs.writeFile('/tmp/hello.txt', 'Hello, world!'); + * const files = await sandbox.fs.readdir('/tmp'); + * const stats = await sandbox.fs.stat('/tmp/hello.txt'); + */ + public readonly fs: FileSystem; /** * Lazily resolve credentials and construct an API client. - * This is used in step contexts where the Sandbox was deserialized - * without a client (e.g. when crossing workflow/step boundaries). - * Uses getCredentials() which resolves from OIDC or env vars. * @internal */ private async ensureClient(): Promise { @@ -199,94 +280,192 @@ export class Sandbox { return this._client; } + /** + * The name of this sandbox. + */ + public get name(): string { + return this.sandbox.name; + } + /** * Routes from ports to subdomains. - /* @hidden + * @hidden */ - public readonly routes: SandboxRouteData[]; + public get routes(): SandboxRouteData[] { + return this.currentSession().routes; + } /** - * A `node:fs/promises`-compatible API for interacting with the sandbox filesystem. - * - * @example - * const content = await sandbox.fs.readFile('/etc/hostname', 'utf8'); - * await sandbox.fs.writeFile('/tmp/hello.txt', 'Hello, world!'); - * const files = await sandbox.fs.readdir('/tmp'); - * const stats = await sandbox.fs.stat('/tmp/hello.txt'); + * Whether the sandbox persists the state. */ - public readonly fs: FileSystem; + public get persistent(): boolean { + return this.sandbox.persistent; + } /** - * Unique ID of this sandbox. + * The region this sandbox runs in. */ - public get sandboxId(): string { - return this.sandbox.id; + public get region(): string | undefined { + return this.sandbox.region; } - public get interactivePort(): number | undefined { - return this.sandbox.interactivePort ?? undefined; + /** + * Number of virtual CPUs allocated. + */ + public get vcpus(): number | undefined { + return this.sandbox.vcpus; + } + + /** + * Memory allocated in MB. + */ + public get memory(): number | undefined { + return this.sandbox.memory; + } + + /** Runtime identifier (e.g. "node24", "python3.13"). */ + public get runtime(): string | undefined { + return this.sandbox.runtime; + } + + /** + * Cumulative egress bytes across all sessions. + */ + public get totalEgressBytes(): number | undefined { + return this.sandbox.totalEgressBytes; + } + + /** + * Cumulative ingress bytes across all sessions. + */ + public get totalIngressBytes(): number | undefined { + return this.sandbox.totalIngressBytes; } /** - * The status of the sandbox. + * Cumulative active CPU duration in milliseconds across all sessions. */ - public get status(): SandboxMetaData["status"] { - return this.sandbox.status; + public get totalActiveCpuDurationMs(): number | undefined { + return this.sandbox.totalActiveCpuDurationMs; } /** - * The creation date of the sandbox. + * Cumulative wall-clock duration in milliseconds across all sessions. + */ + public get totalDurationMs(): number | undefined { + return this.sandbox.totalDurationMs; + } + + /** + * When this sandbox was last updated. + */ + public get updatedAt(): Date { + return new Date(this.sandbox.updatedAt); + } + + /** + * When the sandbox status was last updated. + */ + public get statusUpdatedAt(): Date | undefined { + return this.sandbox.statusUpdatedAt + ? new Date(this.sandbox.statusUpdatedAt) + : undefined; + } + + /** + * When this sandbox was created. */ public get createdAt(): Date { return new Date(this.sandbox.createdAt); } /** - * The timeout of the sandbox in milliseconds. + * Interactive port. */ - public get timeout(): number { + public get interactivePort(): number | undefined { + return this.currentSession().interactivePort; + } + + /** + * The status of the current session. + */ + public get status(): SessionMetaData["status"] { + return this.currentSession().status; + } + + /** + * The default timeout of this sandbox in milliseconds. + */ + public get timeout(): number | undefined { return this.sandbox.timeout; } /** - * The network policy of the sandbox. + * Key-value tags attached to the sandbox. + */ + public get tags(): Record | undefined { + return this.sandbox.tags; + } + + /** + * The default network policy of this sandbox. */ public get networkPolicy(): NetworkPolicy | undefined { - return this.sandbox.networkPolicy; + return this.sandbox.networkPolicy + ? fromAPINetworkPolicy(this.sandbox.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.currentSession().sourceSnapshotId; + } + + /** + * The current snapshot ID of this sandbox, if any. + */ + public get currentSnapshotId(): string | undefined { + return this.sandbox.currentSnapshotId; } /** - * The amount of CPU used by the sandbox. Only reported once the VM is stopped. + * The default snapshot expiration in milliseconds, if set. + */ + public get snapshotExpiration(): number | undefined { + return this.sandbox.snapshotExpiration; + } + + /** + * The amount of CPU used by the session. Only reported once the VM is stopped. */ public get activeCpuUsageMs(): number | undefined { - return this.sandbox.activeCpuDurationMs; + return this.currentSession().activeCpuUsageMs; } /** - * The amount of network data used by the sandbox. Only reported once the VM is stopped. + * 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.sandbox.networkTransfer; + return this.currentSession().networkTransfer; } - /** - * Internal metadata about this sandbox. - */ - private sandbox: SandboxSnapshot; - /** * Allow to get a list of sandboxes for a team narrowed to the given params. * It returns both the sandboxes and the pagination metadata to allow getting * the next page of results. + * + * The returned object is async-iterable to auto-paginate through all pages: + * + * ```ts + * const result = await Sandbox.list({ namePrefix: "ci-" }); + * for await (const sandbox of result) { ... } + * // or: await result.toArray(); + * // or: for await (const page of result.pages()) { ... } + * ``` */ static async list( params?: Partial[0]> & @@ -300,9 +479,19 @@ export class Sandbox { token: credentials.token, fetch: params?.fetch, }); - return client.listSandboxes({ - ...credentials, - ...params, + const fetchPage = async (cursor?: string) => { + const response = await client.listSandboxes({ + ...credentials, + ...params, + ...(cursor !== undefined && { cursor }), + }); + return response.json; + }; + const firstPage = await fetchPage(params?.cursor); + return attachPaginator(firstPage, { + itemsKey: "sandboxes", + fetchNext: fetchPage, + signal: params?.signal, }); } @@ -314,8 +503,10 @@ export class Sandbox { */ static [WORKFLOW_SERIALIZE](instance: Sandbox): SerializedSandbox { return { - metadata: instance.sandbox, - routes: instance.routes, + metadata: instance.session?._sessionSnapshot!, + routes: instance.session?.routes ?? [], + sandboxMetadata: instance.sandbox, + projectId: instance.projectId, }; } @@ -329,10 +520,15 @@ export class Sandbox { * @returns The reconstructed Sandbox instance */ static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Sandbox { - return new Sandbox({ - sandbox: data.metadata, + const sandbox = new Sandbox({ + sandbox: data.sandboxMetadata!, routes: data.routes, + projectId: data.projectId, }); + if (data.metadata) { + sandbox.session = new Session({ routes: data.routes, snapshot: data.metadata }); + } + return sandbox; } /** @@ -341,6 +537,10 @@ export class Sandbox { * @param params - Creation parameters and optional credentials. * @returns A promise resolving to the created {@link Sandbox}. * @example + * Create a sandbox with default options + * const sandbox = await Sandbox.create(); + * + * @example * Create a sandbox and drop it in the end of the block * async function fn() { * await using const sandbox = await Sandbox.create(); @@ -362,7 +562,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 ?? [], @@ -371,19 +571,26 @@ export class Sandbox { runtime: params && "runtime" in params ? params?.runtime : undefined, networkPolicy: params?.networkPolicy, env: params?.env, + tags: params?.tags, + snapshotExpiration: params?.snapshotExpiration, signal: params?.signal, + name: params?.name, + persistent: params?.persistent, ...privateParams, }); return new DisposableSandbox({ client, - sandbox: toSandboxSnapshot(sandbox.json.sandbox), - routes: sandbox.json.routes, + session: response.json.session, + sandbox: response.json.sandbox, + routes: response.json.routes, + projectId: credentials.projectId, + onResume: params?.onResume, }); } /** - * 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}. @@ -401,17 +608,125 @@ export class Sandbox { }); const privateParams = getPrivateParams(params); - const sandbox = await client.getSandbox({ - sandboxId: params.sandboxId, + const response = await client.getSandbox({ + name: params.name, + projectId: credentials.projectId, + resume: params.resume, signal: params.signal, ...privateParams, }); - return new Sandbox({ + const sandbox = new Sandbox({ client, - sandbox: toSandboxSnapshot(sandbox.json.sandbox), - routes: sandbox.json.routes, + session: response.json.session, + sandbox: response.json.sandbox, + routes: response.json.routes, + projectId: credentials.projectId, + onResume: params.onResume, }); + + if (response.json.resumed && params.onResume) { + await params.onResume(sandbox); + } + + return sandbox; + } + + /** + * Retrieve an existing named sandbox, or create a new one if none exists. + * + * If `name` is omitted, this always creates a new sandbox and fires + * `onCreate`. If `name` is provided, it first tries {@link Sandbox.get}; + * on `not_found` it creates a new sandbox with that name; on + * `snapshot_not_found` it deletes the stale named sandbox and creates + * a fresh one with the same name. + * + * @param params - Get/create parameters plus an optional `onCreate` hook. + * @returns A promise resolving to the {@link Sandbox}. + * + * @example + * Idempotent named sandbox with one-time setup + * const sandbox = await Sandbox.getOrCreate({ + * name: "my-workspace", + * onCreate: async (sbx) => { + * await sbx.writeFiles([ + * { path: "README.md", content: Buffer.from("# Hello") }, + * ]); + * }, + * }); + * + * @example + * Unnamed — always creates + * const sandbox = await Sandbox.getOrCreate({ + * onCreate: async (sbx) => { + * await sbx.runCommand("npm", ["install"]); + * }, + * }); + */ + static async getOrCreate( + params?: WithPrivate< + GetOrCreateSandboxParams | (GetOrCreateSandboxParams & Credentials) + > & + WithFetchOptions, + ): Promise { + "use step"; + // No name → always create, fire onCreate. + if (!params?.name) { + const sandbox = await Sandbox.create(params); + if (params?.onCreate) { + await params.onCreate(sandbox); + } + return sandbox; + } + + try { + return await Sandbox.get( + params as unknown as Parameters[0], + ); + } catch (err) { + if (isNotFoundError(err)) { + // Sandbox does not exist: re-create it. + const sandbox = await Sandbox.create(params); + if (params.onCreate) { + await params.onCreate(sandbox); + } + return sandbox; + } + + if (isSnapshotNotFoundError(err)) { + // Sandbox exists but the snapshot has expired. Delete it and create + // a new one. + const credentials = await getCredentials(params); + const client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params.fetch, + }); + const privateParams = getPrivateParams(params); + try { + await client.deleteSandbox({ + name: params.name, + projectId: credentials.projectId, + signal: params.signal, + ...privateParams, + }); + } catch (deleteErr) { + // Tolerate 404 — the named sandbox was already cleaned up by a + // concurrent request. Propagate anything else. + if (!isNotFoundError(deleteErr)) { + throw deleteErr; + } + } + + const sandbox = await Sandbox.create(params); + if (params.onCreate) { + await params.onCreate(sandbox); + } + return sandbox; + } + + throw err; + } } /** @@ -424,43 +739,118 @@ export class Sandbox { constructor({ client, routes, + session, sandbox, + projectId, + onResume, }: { client?: APIClient; routes: SandboxRouteData[]; - sandbox: SandboxSnapshot; + session?: SessionMetaData; + sandbox: SandboxMetaData; + projectId?: string; + onResume?: (sandbox: Sandbox) => Promise; }) { this._client = client ?? null; - this.routes = routes; + if (session) { + this.session = new Session({ client: client!, routes, session }); + } this.sandbox = sandbox; + this.projectId = projectId ?? ""; + this.onResume = onResume; this.fs = new FileSystem(this); } /** - * 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 { - "use step"; + currentSession(): Session { + if (!this.session) { + throw new Error("No active session. Run a command or call resume first."); + } + return this.session; + } + + /** + * Resume this sandbox by creating a new session via `getSandbox`. + */ + private async resume(signal?: AbortSignal): Promise { + if (!this.resumePromise) { + this.resumePromise = this.doResume(signal).finally(() => { + this.resumePromise = null; + }); + } + return this.resumePromise; + } + + private async doResume(signal?: AbortSignal): Promise { const client = await this.ensureClient(); - const command = await client.getCommand({ - sandboxId: this.sandbox.id, - cmdId, - signal: opts?.signal, + const response = await client.getSandbox({ + name: this.sandbox.name, + projectId: this.projectId, + resume: true, + signal, }); - - return new Command({ + this.session = new Session({ client, - sandboxId: this.sandbox.id, - cmd: command.json.command, + routes: response.json.routes, + session: response.json.session, }); + if (this.onResume && response.json.resumed) { + await this.onResume(this); + } + } + + /** + * Poll until the current session reaches a terminal state, then resume. + */ + private async waitForStopAndResume(signal?: AbortSignal): Promise { + "use step"; + const client = await this.ensureClient(); + const pollingInterval = 500; + let status = this.session!.status; + + while (status === "stopping" || status === "snapshotting") { + await setTimeout(pollingInterval, undefined, { signal }); + const poll = await client.getSession({ + sessionId: this.session!.sessionId, + signal, + }); + this.session = new Session({ + client, + routes: poll.json.routes, + session: poll.json.session, + }); + status = poll.json.session.status; + } + await this.resume(signal); + } + + /** + * Execute `fn`, and if the session is stopped/stopping/snapshotting, resume and retry. + */ + private async withResume( + fn: () => Promise, + signal?: AbortSignal, + ): Promise { + if (!this.session) { + await this.resume(signal); + } + try { + return await fn(); + } catch (err) { + if (isSandboxStoppedError(err)) { + await this.resume(signal); + return fn(); + } + if (isSandboxStoppingError(err) || isSandboxSnapshottingError(err)) { + await this.waitForStopAndResume(signal); + return fn(); + } + throw err; + } } /** @@ -477,7 +867,6 @@ export class Sandbox { args?: string[], opts?: { signal?: AbortSignal }, ): Promise; - /** * Start executing a command in detached mode. * @@ -494,6 +883,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( @@ -502,88 +892,32 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ): Promise { "use step"; - const client = await this.ensureClient(); - const params: RunCommandParams = + const signal = typeof commandOrParams === "string" - ? { cmd: commandOrParams, args, signal: opts?.signal } - : commandOrParams; - - const wait = params.detached ? false : true; - const pipeLogs = async (command: Command): Promise => { - if (!params.stdout && !params.stderr) { - return; - } - - try { - for await (const log of command.logs({ signal: params.signal })) { - if (log.stream === "stdout") { - params.stdout?.write(log.data); - } else if (log.stream === "stderr") { - params.stderr?.write(log.data); - } - } - } catch (err) { - if (params.signal?.aborted) { - return; - } - throw err; - } - }; - - if (wait) { - const commandStream = await client.runCommand({ - sandboxId: 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, - sandboxId: this.sandbox.id, - cmd: commandStream.command, - }); - - const [finished] = await Promise.all([ - commandStream.finished, - pipeLogs(command), - ]); - return new CommandFinished({ - client, - sandboxId: this.sandbox.id, - cmd: finished, - exitCode: finished.exitCode ?? 0, - }); - } - - const commandResponse = await 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, - sandboxId: this.sandbox.id, - cmd: commandResponse.json.command, - }); - - void pipeLogs(command).catch((err) => { - if (params.signal?.aborted) { - return; - } - (params.stderr ?? params.stdout)?.emit("error", err); - }); + ? opts?.signal + : commandOrParams.signal; + return this.withResume( + () => this.session!.runCommand(commandOrParams as any, args, opts), + signal, + ); + } - return command; + /** + * Internal helper to start a command in the sandbox. + * + * @param params - Command execution parameters. + * @returns A {@link Command} or {@link CommandFinished}, depending on `detached`. + * @internal + */ + async getCommand( + cmdId: string, + opts?: { signal?: AbortSignal }, + ): Promise { + "use step"; + return this.withResume( + () => this.session!.getCommand(cmdId, opts), + opts?.signal, + ); } /** @@ -595,12 +929,10 @@ export class Sandbox { */ async mkDir(path: string, opts?: { signal?: AbortSignal }): Promise { "use step"; - const client = await this.ensureClient(); - await client.mkDir({ - sandboxId: this.sandbox.id, - path: path, - signal: opts?.signal, - }); + return this.withResume( + () => this.session!.mkDir(path, opts), + opts?.signal, + ); } /** @@ -616,13 +948,10 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ): Promise { "use step"; - const client = await this.ensureClient(); - return 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, + ); } /** @@ -638,19 +967,10 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ): Promise { "use step"; - const client = await this.ensureClient(); - const stream = await 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, + ); } /** @@ -669,38 +989,10 @@ export class Sandbox { opts?: { mkdirRecursive?: boolean; signal?: AbortSignal }, ): Promise { "use step"; - const client = await this.ensureClient(); - 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 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, + ); } /** @@ -728,14 +1020,10 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ) { "use step"; - const client = await this.ensureClient(); - return 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, + ); } /** @@ -746,12 +1034,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.currentSession().domain(p); } /** @@ -759,27 +1042,27 @@ export class Sandbox { * * @param opts - Optional parameters. * @param opts.signal - An AbortSignal to cancel the operation. - * @param opts.blocking - If true, poll until the sandbox has fully stopped and return the final state. - * @returns The sandbox metadata at the time the stop was acknowledged, or after fully stopped if `blocking` is true. + * @returns The final session state after stopping, with optional snapshot metadata. */ async stop(opts?: { signal?: AbortSignal; - blocking?: boolean; - }): Promise { + }): Promise { "use step"; - const client = await this.ensureClient(); - const response = await client.stopSandbox({ - sandboxId: this.sandbox.id, - signal: opts?.signal, - blocking: opts?.blocking, - }); - this.sandbox = toSandboxSnapshot(response.json.sandbox); - return this.sandbox; + if (!this.session) { + throw new Error("No active session to stop."); + } + const { session, sandbox, snapshot } = await this.session.stop(opts); + if (sandbox) { + this.sandbox = sandbox; + } + return Object.assign(session, { snapshot }); } /** * Update the network policy for this sandbox. * + * @deprecated Use {@link Sandbox.update} instead. + * * @param networkPolicy - The new network policy to apply. * @param opts - Optional parameters. * @param opts.signal - An AbortSignal to cancel the operation. @@ -813,16 +1096,12 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ): Promise { "use step"; - const client = await this.ensureClient(); - const response = await client.updateNetworkPolicy({ - sandboxId: this.sandbox.id, - networkPolicy: networkPolicy, - signal: opts?.signal, - }); + await this.withResume( + () => this.session!.update({ networkPolicy: networkPolicy }, opts), + opts?.signal, + ); - // Update the internal sandbox metadata with the new timeout value - this.sandbox = toSandboxSnapshot(response.json.sandbox); - return this.sandbox.networkPolicy!; + return this.session!.networkPolicy!; } /** @@ -846,15 +1125,10 @@ export class Sandbox { opts?: { signal?: AbortSignal }, ): Promise { "use step"; - const client = await this.ensureClient(); - const response = await client.extendTimeout({ - sandboxId: this.sandbox.id, - duration, - signal: opts?.signal, - }); - - // Update the internal sandbox metadata with the new timeout value - this.sandbox = toSandboxSnapshot(response.json.sandbox); + return this.withResume( + () => this.session!.extendTimeout(duration, opts), + opts?.signal, + ); } /** @@ -872,19 +1146,151 @@ export class Sandbox { expiration?: number; signal?: AbortSignal; }): Promise { + "use step"; + return this.withResume( + () => this.session!.snapshot(opts), + opts?.signal, + ); + } + + /** + * Update the sandbox configuration. + * + * @param params - Fields to update. + * @param opts - Optional abort signal. + */ + async update( + params: { + persistent?: boolean; + resources?: { vcpus?: number }; + timeout?: number; + networkPolicy?: NetworkPolicy; + tags?: Record; + snapshotExpiration?: number; + currentSnapshotId?: string; + }, + opts?: { signal?: AbortSignal }, + ): Promise { "use step"; const client = await this.ensureClient(); - const response = await client.createSnapshot({ - sandboxId: this.sandbox.id, - expiration: opts?.expiration, + let resources: { vcpus: number; memory: number } | undefined; + if (params.resources?.vcpus) { + resources = { + vcpus: params.resources.vcpus, + memory: params.resources.vcpus * 2048, + }; + } + + // Update the sandbox config. This config will be used on the next session. + const response = await client.updateSandbox({ + name: this.sandbox.name, + projectId: this.projectId, + persistent: params.persistent, + resources, + timeout: params.timeout, + networkPolicy: params.networkPolicy, + tags: params.tags, + snapshotExpiration: params.snapshotExpiration, + currentSnapshotId: params.currentSnapshotId, signal: opts?.signal, }); + this.sandbox = response.json.sandbox; + + // Update the current session config. This only applies to network policy. + if (params.networkPolicy) { + try { + return await this.session?.update( + { networkPolicy: params.networkPolicy }, + opts, + ); + } catch (err) { + if (isSandboxStoppedError(err) || isSandboxStoppingError(err)) { + return; + } + throw err; + } + } + } - this.sandbox = toSandboxSnapshot(response.json.sandbox); + /** + * Delete this sandbox. + * + * After deletion the instance becomes inert — all further API calls will + * throw immediately. + */ + async delete(opts?: { signal?: AbortSignal }): Promise { + "use step"; + const client = await this.ensureClient(); + await client.deleteSandbox({ + name: this.sandbox.name, + projectId: this.projectId, + signal: opts?.signal, + }); + } - return new Snapshot({ - client, - snapshot: response.json.snapshot, + /** + * List sessions (VMs) that have been created for this sandbox. + * + * @param params - Optional pagination parameters. + * @returns The list of sessions and pagination metadata. + */ + async listSessions(params?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + signal?: AbortSignal; + }) { + "use step"; + const client = await this.ensureClient(); + const fetchPage = async (cursor?: string) => { + const response = await client.listSessions({ + projectId: this.projectId, + name: this.sandbox.name, + limit: params?.limit, + cursor, + sortOrder: params?.sortOrder, + signal: params?.signal, + }); + return response.json; + }; + const firstPage = await fetchPage(params?.cursor); + return attachPaginator(firstPage, { + itemsKey: "sessions", + fetchNext: fetchPage, + signal: params?.signal, + }); + } + + /** + * List snapshots that belong to this sandbox. + * + * @param params - Optional pagination parameters. + * @returns The list of snapshots and pagination metadata. + */ + async listSnapshots(params?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + signal?: AbortSignal; + }) { + "use step"; + const client = await this.ensureClient(); + const fetchPage = async (cursor?: string) => { + const response = await client.listSnapshots({ + projectId: this.projectId, + name: this.sandbox.name, + limit: params?.limit, + cursor, + sortOrder: params?.sortOrder, + signal: params?.signal, + }); + return response.json; + }; + const firstPage = await fetchPage(params?.cursor); + return attachPaginator(firstPage, { + itemsKey: "snapshots", + fetchNext: fetchPage, + 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..64b746c7 --- /dev/null +++ b/packages/vercel-sandbox/src/session.ts @@ -0,0 +1,763 @@ +import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; +import { type SessionMetaData, type SandboxRouteData, type SandboxMetaData, type SnapshotMetadata, APIClient } from "./api-client/index.js"; +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 { Command, CommandFinished } from "./command.js"; +import { Snapshot } from "./snapshot.js"; +import { consumeReadable } from "./utils/consume-readable.js"; +import type { + NetworkPolicy, + NetworkPolicyRule, + NetworkTransformer, +} from "./network-policy.js"; +import { toSandboxSnapshot, type SandboxSnapshot } from "./utils/sandbox-snapshot.js"; +import { getCredentials } from "./utils/get-credentials.js"; + +export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; + +/** + * Serialized representation of a Session for @workflow/serde. + */ +export interface SerializedSession { + session: SandboxSnapshot; + routes: SandboxRouteData[]; +} + +/** @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 {@link Sandbox}. + * + * Obtain a session via {@link Sandbox.currentSession}. + */ +export class Session { + private _client: APIClient | null = null; + + /** + * Lazily resolve credentials and construct an API client. + * This is used in step contexts where the Sandbox was deserialized + * without a client (e.g. when crossing workflow/step boundaries). + * Uses getCredentials() which resolves from OIDC or env vars. + * @internal + */ + private async ensureClient(): Promise { + "use step"; + if (this._client) return this._client; + const credentials = await getCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + }); + return this._client; + } + + /** + * Routes from ports to subdomains. + * @hidden + */ + public readonly routes: SandboxRouteData[]; + + /** + * Internal metadata about the current session. + */ + private session: SandboxSnapshot; + + private get client(): APIClient { + if (!this._client) throw new Error("API client not initialized"); + return this._client; + } + + /** @internal */ + get _sessionSnapshot(): SandboxSnapshot { + return this.session; + } + + /** + * 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(): SessionMetaData["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; + } + + /** + * Serialize a Session instance to plain data for @workflow/serde. + * + * Although Sandbox handles top-level serialization, Session needs these + * methods so the Workflow SWC compiler can resolve the class by name. + * The `new Session(...)` self-reference in WORKFLOW_DESERIALIZE forces + * rolldown to preserve the class name in the compiled output. + */ + static [WORKFLOW_SERIALIZE](instance: Session): SerializedSession { + return { + session: instance.session, + routes: instance.routes, + }; + } + + static [WORKFLOW_DESERIALIZE](data: SerializedSession): Session { + return new Session({ routes: data.routes, snapshot: data.session }); + } + + constructor(params: { + client: APIClient; + routes: SandboxRouteData[]; + session: SessionMetaData; + } | { + /** @internal – used during deserialization with an already-converted snapshot */ + routes: SandboxRouteData[]; + snapshot: SandboxSnapshot; + }) { + this.routes = params.routes; + if ("snapshot" in params) { + this.session = params.snapshot; + } else { + this._client = params.client; + this.session = toSandboxSnapshot(params.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 { + "use step"; + const client = await this.ensureClient(); + const command = await client.getCommand({ + sessionId: this.session.id, + cmdId, + signal: opts?.signal, + }); + + return new Command({ + client, + sessionId: 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 { + "use step"; + const client = await this.ensureClient(); + const params: RunCommandParams = + typeof commandOrParams === "string" + ? { cmd: commandOrParams, args, signal: opts?.signal } + : commandOrParams; + const wait = params.detached ? false : true; + const pipeLogs = async (command: Command): Promise => { + if (!params.stdout && !params.stderr) { + return; + } + + try { + for await (const log of command.logs({ signal: params.signal })) { + if (log.stream === "stdout") { + params.stdout?.write(log.data); + } else if (log.stream === "stderr") { + params.stderr?.write(log.data); + } + } + } catch (err) { + if (params.signal?.aborted) { + return; + } + throw err; + } + }; + + if (wait) { + const commandStream = await client.runCommand({ + sessionId: 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, + sessionId: this.session.id, + cmd: commandStream.command, + }); + + const [finished] = await Promise.all([ + commandStream.finished, + pipeLogs(command), + ]); + return new CommandFinished({ + client, + sessionId: this.session.id, + cmd: finished, + exitCode: finished.exitCode ?? 0, + }); + } + + const commandResponse = await client.runCommand({ + sessionId: 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, + sessionId: this.session.id, + cmd: commandResponse.json.command, + }); + + void pipeLogs(command).catch((err) => { + if (params.signal?.aborted) { + return; + } + (params.stderr ?? params.stdout)?.emit("error", err); + }); + + 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 { + "use step"; + const client = await this.ensureClient(); + await client.mkDir({ + sessionId: 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 { + "use step"; + const client = await this.ensureClient(); + return client.readFile({ + sessionId: 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 { + "use step"; + const client = await this.ensureClient(); + const stream = await client.readFile({ + sessionId: 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 { + "use step"; + const client = await this.ensureClient(); + 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 client.readFile({ + sessionId: 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: string | Uint8Array; mode?: number }[], + opts?: { signal?: AbortSignal }, + ) { + "use step"; + const client = await this.ensureClient(); + return client.writeFiles({ + sessionId: 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. + * @returns The final session state and optional sandbox metadata. + */ + async stop(opts?: { + signal?: AbortSignal; + }): Promise<{ session: SandboxSnapshot; sandbox?: SandboxMetaData; snapshot?: SnapshotMetadata }> { + "use step"; + const client = await this.ensureClient(); + const response = await client.stopSession({ + sessionId: this.session.id, + signal: opts?.signal, + }); + this.session = toSandboxSnapshot(response.json.session); + return { session: this.session, sandbox: response.json.sandbox, snapshot: response.json.snapshot }; + } + + /** + * Update the current session's settings. + * + * @param params - Fields to update. + * @param params.networkPolicy - The new network policy to apply. + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * + * @example + * // Restrict to specific domains + * await session.update({ + * networkPolicy: { + * allow: ["*.npmjs.org", "github.com"], + * } + * }); + * + * @example + * // Inject credentials with per-domain transformers + * await session.update({ + * networkPolicy: { + * allow: { + * "ai-gateway.vercel.sh": [{ + * transform: [{ + * headers: { authorization: "Bearer ..." } + * }] + * }], + * "*": [] + * } + * } + * }); + * + * @example + * // Deny all network access + * await session.update({ networkPolicy: "deny-all" }); + */ + async update( + params: { + networkPolicy?: NetworkPolicy; + }, + opts?: { signal?: AbortSignal }, + ): Promise { + "use step"; + if (params.networkPolicy !== undefined) { + const client = await this.ensureClient(); + const response = await client.updateNetworkPolicy({ + sessionId: this.session.id, + networkPolicy: params.networkPolicy, + signal: opts?.signal, + }); + + // Update the internal session with the new network policy + this.session = toSandboxSnapshot(response.json.session); + } + } + + /** + * 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 { + "use step"; + const client = await this.ensureClient(); + const response = await client.extendTimeout({ + sessionId: this.session.id, + duration, + signal: opts?.signal, + }); + + // Update the internal sandbox metadata with the new timeout value + this.session = toSandboxSnapshot(response.json.session); + } + + /** + * 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 { + "use step"; + const client = await this.ensureClient(); + const response = await client.createSnapshot({ + sessionId: this.session.id, + expiration: opts?.expiration, + signal: opts?.signal, + }); + + this.session = toSandboxSnapshot(response.json.session); + + return new Snapshot({ + client, + snapshot: response.json.snapshot, + }); + } +} diff --git a/packages/vercel-sandbox/src/snapshot.serialize.test.ts b/packages/vercel-sandbox/src/snapshot.serialize.test.ts index fd0ba05d..233d81f0 100644 --- a/packages/vercel-sandbox/src/snapshot.serialize.test.ts +++ b/packages/vercel-sandbox/src/snapshot.serialize.test.ts @@ -12,7 +12,7 @@ import { Snapshot, type SerializedSnapshot } from "./snapshot"; describe("Snapshot serialization", () => { const mockSnapshotMetadata: SnapshotMetadata = { id: "snap_test123", - sourceSandboxId: "sbx_source456", + sourceSessionId: "sbx_source456", region: "iad1", status: "created", sizeBytes: 253826392, @@ -53,7 +53,7 @@ describe("Snapshot serialization", () => { const serialized = serializeSnapshot(snapshot); expect(serialized.snapshot.id).toBe("snap_test123"); - expect(serialized.snapshot.sourceSandboxId).toBe("sbx_source456"); + expect(serialized.snapshot.sourceSessionId).toBe("sbx_source456"); expect(serialized.snapshot.region).toBe("iad1"); expect(serialized.snapshot.status).toBe("created"); expect(serialized.snapshot.sizeBytes).toBe(253826392); @@ -67,7 +67,7 @@ describe("Snapshot serialization", () => { const parsed = JSON.parse(jsonString); expect(parsed.snapshot.id).toBe("snap_test123"); - expect(parsed.snapshot.sourceSandboxId).toBe("sbx_source456"); + expect(parsed.snapshot.sourceSessionId).toBe("sbx_source456"); }); it("does not include the API client or credentials", () => { @@ -98,7 +98,7 @@ describe("Snapshot serialization", () => { const result = deserializeSnapshot(serialized); expect(result.snapshotId).toBe("snap_test123"); - expect(result.sourceSandboxId).toBe("sbx_source456"); + expect(result.sourceSessionId).toBe("sbx_source456"); expect(result.status).toBe("created"); expect(result.sizeBytes).toBe(253826392); expect(result.createdAt).toEqual(new Date(1775650621392)); @@ -118,7 +118,7 @@ describe("Snapshot serialization", () => { ) as Snapshot; expect(deserialized.snapshotId).toBe("snap_test123"); - expect(deserialized.sourceSandboxId).toBe("sbx_source456"); + expect(deserialized.sourceSessionId).toBe("sbx_source456"); expect(deserialized.status).toBe("created"); }); @@ -171,7 +171,7 @@ describe("Snapshot serialization", () => { expect(rehydrated).toBeInstanceOf(Snapshot); expect(rehydrated.snapshotId).toBe("snap_test123"); - expect(rehydrated.sourceSandboxId).toBe("sbx_source456"); + expect(rehydrated.sourceSessionId).toBe("sbx_source456"); }); it("preserves all metadata through runtime pipeline", async () => { diff --git a/packages/vercel-sandbox/src/snapshot.ts b/packages/vercel-sandbox/src/snapshot.ts index f23bb99d..48b64020 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -3,6 +3,7 @@ import type { WithFetchOptions } from "./api-client/api-client.js"; import type { SnapshotMetadata } from "./api-client/index.js"; import { APIClient } from "./api-client/index.js"; import { type Credentials, getCredentials } from "./utils/get-credentials.js"; +import { attachPaginator } from "./utils/paginator.js"; export interface SerializedSnapshot { snapshot: SnapshotMetadata; @@ -54,10 +55,10 @@ export class Snapshot { } /** - * The ID the sandbox from which this snapshot was created. + * The ID of the session from which this snapshot was created. */ - public get sourceSandboxId(): string { - return this.snapshot.sourceSandboxId; + public get sourceSessionId(): string { + return this.snapshot.sourceSessionId; } /** @@ -139,6 +140,15 @@ export class Snapshot { * Allow to get a list of snapshots for a team narrowed to the given params. * It returns both the snapshots and the pagination metadata to allow getting * the next page of results. + * + * The returned object is async-iterable to auto-paginate through all pages: + * + * ```ts + * const result = await Snapshot.list({ name: "my-sandbox" }); + * for await (const snapshot of result) { ... } + * // or: await result.toArray(); + * // or: for await (const page of result.pages()) { ... } + * ``` */ static async list( params?: Partial[0]> & @@ -152,9 +162,19 @@ export class Snapshot { token: credentials.token, fetch: params?.fetch, }); - return client.listSnapshots({ - ...credentials, - ...params, + const fetchPage = async (cursor?: string) => { + const response = await client.listSnapshots({ + ...credentials, + ...params, + ...(cursor !== undefined && { cursor }), + }); + return response.json; + }; + const firstPage = await fetchPage(params?.cursor); + return attachPaginator(firstPage, { + itemsKey: "snapshots", + fetchNext: fetchPage, + signal: params?.signal, }); } diff --git a/packages/vercel-sandbox/src/utils/get-credentials.test.ts b/packages/vercel-sandbox/src/utils/get-credentials.test.ts index 86f6ab46..ed473fe9 100644 --- a/packages/vercel-sandbox/src/utils/get-credentials.test.ts +++ b/packages/vercel-sandbox/src/utils/get-credentials.test.ts @@ -1,10 +1,20 @@ -import { test, expect, beforeEach } from "vitest"; +import { test, expect, beforeEach, vi } from "vitest"; import { getCredentials, LocalOidcContextError, VercelOidcContextError, } from "./get-credentials.js"; +// Force `getVercelOidcToken` to reject so the error-path in `getCredentials` +// runs deterministically. Without this, `@vercel/oidc` discovers the developer's +// linked project via `.vercel/project.json` and refreshes a real token from +// stored `vc` auth — masking the missing-context error these tests assert on. +vi.mock("@vercel/oidc", () => ({ + getVercelOidcToken: vi.fn(async () => { + throw new Error("no OIDC context"); + }), +})); + beforeEach(() => { delete process.env.VERCEL_OIDC_TOKEN; }); diff --git a/packages/vercel-sandbox/src/utils/network-policy.test.ts b/packages/vercel-sandbox/src/utils/network-policy.test.ts index 4d469e81..6ed452c5 100644 --- a/packages/vercel-sandbox/src/utils/network-policy.test.ts +++ b/packages/vercel-sandbox/src/utils/network-policy.test.ts @@ -14,8 +14,7 @@ describe("toAPINetworkPolicy", () => { expect( toAPINetworkPolicy({ allow: ["*.npmjs.org", "github.com"] }), ).toEqual({ - mode: "custom", - allowedDomains: ["*.npmjs.org", "github.com"], + allow: ["*.npmjs.org", "github.com"], }); }); @@ -25,9 +24,10 @@ describe("toAPINetworkPolicy", () => { subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, }), ).toEqual({ - mode: "custom", - allowedCIDRs: ["10.0.0.0/8"], - deniedCIDRs: ["10.1.0.0/16"], + subnets: { + allow: ["10.0.0.0/8"], + deny: ["10.1.0.0/16"], + }, }); }); @@ -38,25 +38,25 @@ describe("toAPINetworkPolicy", () => { subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, }), ).toEqual({ - mode: "custom", - allowedDomains: ["github.com"], - allowedCIDRs: ["10.0.0.0/8"], - deniedCIDRs: ["10.1.0.0/16"], + allow: ["github.com"], + subnets: { + allow: ["10.0.0.0/8"], + deny: ["10.1.0.0/16"], + }, }); }); - it("converts record-form domains to allowedDomains list", () => { + it("converts record-form domains to allow map", () => { expect( toAPINetworkPolicy({ allow: { "api.github.com": [], "github.com": [] }, }), ).toEqual({ - mode: "custom", - allowedDomains: ["api.github.com", "github.com"], + allow: { "api.github.com": [], "github.com": [] }, }); }); - it("converts record-form with multiple domains and transforms to injectionRules", () => { + it("keeps record-form rules in v2 allow-map shape", () => { expect( toAPINetworkPolicy({ allow: { @@ -86,39 +86,130 @@ describe("toAPINetworkPolicy", () => { subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, }), ).toEqual({ - mode: "custom", - allowedDomains: [ - "api.github.com", - "ai-gateway.vercel.sh", - "registry.npmjs.org", - "*", - ], - injectionRules: [ - { - domain: "api.github.com", - headers: { authorization: "Bearer sk-openai", "x-org-id": "org-123" }, + allow: { + "api.github.com": [ + { + transform: [ + { + headers: { + authorization: "Bearer sk-openai", + "x-org-id": "org-123", + }, + }, + ], + }, + ], + "ai-gateway.vercel.sh": [ + { + transform: [ + { headers: { "x-api-key": "sk-ant-test" } }, + { headers: { "anthropic-version": "2024-01-01" } }, + ], + }, + ], + "registry.npmjs.org": [], + "*": [], + }, + subnets: { + allow: ["10.0.0.0/8"], + deny: ["10.1.0.0/16"], + }, + }); + }); + + it("preserves matcher-bearing rules as ordered allow-map transform rules", () => { + expect( + toAPINetworkPolicy({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [ + { + key: { exact: "x-api-key" }, + value: { exact: "placeholder" }, + }, + ], + }, + transform: [{ headers: { "x-api-key": "real-secret" } }], + }, + { + transform: [{ headers: { "x-api-key": "fallback-secret" } }], + }, + ], }, - { - domain: "ai-gateway.vercel.sh", - headers: { - "x-api-key": "sk-ant-test", - "anthropic-version": "2024-01-01", + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [ + { + key: { exact: "x-api-key" }, + value: { exact: "placeholder" }, + }, + ], + }, + transform: [{ headers: { "x-api-key": "real-secret" } }], + }, + { + transform: [{ headers: { "x-api-key": "fallback-secret" } }], }, + ], + }, + }); + }); + + it("converts record-form forwardURL rules to allow-map forwardURL rules", () => { + expect( + toAPINetworkPolicy({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + }, + forwardURL: "https://proxy.example.com", + }, + { + forwardURL: "https://fallback-proxy.example.com", + }, + ], + "registry.npmjs.org": [], }, - ], - allowedCIDRs: ["10.0.0.0/8"], - deniedCIDRs: ["10.1.0.0/16"], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + }, + forwardURL: "https://proxy.example.com", + }, + { + forwardURL: "https://fallback-proxy.example.com", + }, + ], + "registry.npmjs.org": [], + }, }); }); it("converts empty custom object", () => { - expect(toAPINetworkPolicy({})).toEqual({ mode: "custom" }); + expect(toAPINetworkPolicy({})).toEqual({}); }); it("omits undefined subnet fields", () => { expect(toAPINetworkPolicy({ subnets: { allow: ["10.0.0.0/8"] } })).toEqual({ - mode: "custom", - allowedCIDRs: ["10.0.0.0/8"], + subnets: { allow: ["10.0.0.0/8"] }, }); }); }); @@ -171,21 +262,24 @@ describe("fromAPINetworkPolicy", () => { expect(fromAPINetworkPolicy({ mode: "custom" })).toEqual({}); }); - it("roundtrips string-form policies through both conversions", () => { - const policies = [ - "allow-all" as const, - "deny-all" as const, - { allow: ["github.com"] }, - { subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] } }, - { - allow: ["*.npmjs.org"], - subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, - }, - ]; - - for (const policy of policies) { - expect(fromAPINetworkPolicy(toAPINetworkPolicy(policy))).toEqual(policy); - } + it("parses legacy/mode-form responses", () => { + expect(fromAPINetworkPolicy({ mode: "allow-all" })).toEqual("allow-all"); + expect(fromAPINetworkPolicy({ mode: "deny-all" })).toEqual("deny-all"); + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["github.com"], + }), + ).toEqual({ allow: ["github.com"] }); + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedCIDRs: ["10.0.0.0/8"], + deniedCIDRs: ["10.1.0.0/16"], + }), + ).toEqual({ + subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, + }); }); it("converts injectionRules with multiple domains, headers, and subnets", () => { @@ -237,4 +331,126 @@ describe("fromAPINetworkPolicy", () => { subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, }); }); + + it("converts ordered injectionRules with matchers", () => { + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["api.example.com"], + injectionRules: [ + { + domain: "api.example.com", + headerNames: ["x-api-key"], + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + queryString: [{ key: { exact: "model" } }], + }, + }, + { + domain: "api.example.com", + headerNames: ["x-api-key"], + }, + ], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + queryString: [{ key: { exact: "model" } }], + }, + transform: [{ headers: { "x-api-key": "" } }], + }, + { + transform: [{ headers: { "x-api-key": "" } }], + }, + ], + }, + }); + }); + + it("converts forwardRules with matchers", () => { + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["api.example.com", "registry.npmjs.org"], + forwardRules: [ + { + domain: "api.example.com", + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [{ key: { exact: "x-route" }, value: { exact: "proxy" } }], + }, + forwardURL: "https://proxy.example.com", + }, + { + domain: "api.example.com", + forwardURL: "https://fallback-proxy.example.com", + }, + ], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [{ key: { exact: "x-route" }, value: { exact: "proxy" } }], + }, + forwardURL: "https://proxy.example.com", + }, + { + forwardURL: "https://fallback-proxy.example.com", + }, + ], + "registry.npmjs.org": [], + }, + }); + }); + + it("converts mixed injectionRules and forwardRules", () => { + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["api.example.com"], + injectionRules: [ + { + domain: "api.example.com", + headerNames: ["authorization"], + }, + ], + forwardRules: [ + { + domain: "api.example.com", + forwardURL: "https://proxy.example.com", + }, + { + domain: "proxy-only.example.com", + forwardURL: "https://proxy-only.example.com", + }, + ], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + transform: [{ headers: { authorization: "" } }], + }, + { + forwardURL: "https://proxy.example.com", + }, + ], + "proxy-only.example.com": [ + { + forwardURL: "https://proxy-only.example.com", + }, + ], + }, + }); + }); }); diff --git a/packages/vercel-sandbox/src/utils/network-policy.ts b/packages/vercel-sandbox/src/utils/network-policy.ts index febb6b19..f4e1844b 100644 --- a/packages/vercel-sandbox/src/utils/network-policy.ts +++ b/packages/vercel-sandbox/src/utils/network-policy.ts @@ -1,52 +1,29 @@ import { z } from "zod"; import { NetworkPolicy, NetworkPolicyRule } from "../network-policy.js"; import { - NetworkPolicyValidator, - InjectionRuleValidator, + NetworkPolicyRequestValidator, + NetworkPolicyResponseValidator, } from "../api-client/validators.js"; -type APINetworkPolicy = z.infer; +type APIRequestNetworkPolicy = z.infer; +type APIResponseNetworkPolicy = z.infer; -export function toAPINetworkPolicy(policy: NetworkPolicy): APINetworkPolicy { - if (policy === "allow-all") return { mode: "allow-all" }; - if (policy === "deny-all") return { mode: "deny-all" }; - - if (policy.allow && !Array.isArray(policy.allow)) { - const allowedDomains = Object.keys(policy.allow); - const injectionRules: z.infer[] = []; - - for (const [domain, rules] of Object.entries(policy.allow)) { - const merged: Record = {}; - for (const rule of rules) { - for (const t of rule.transform ?? []) { - Object.assign(merged, t.headers); - } - } - if (Object.keys(merged).length > 0) { - injectionRules.push({ domain, headers: merged }); - } - } - - return { - mode: "custom", - ...(allowedDomains.length > 0 && { allowedDomains }), - ...(injectionRules.length > 0 && { injectionRules }), - ...(policy.subnets?.allow && { allowedCIDRs: policy.subnets.allow }), - ...(policy.subnets?.deny && { deniedCIDRs: policy.subnets.deny }), - }; +export function toAPINetworkPolicy( + policy: NetworkPolicy, +): APIRequestNetworkPolicy { + if (policy === "allow-all" || policy === "deny-all") { + return { mode: policy }; } - return { - mode: "custom", - ...(policy.allow && { allowedDomains: policy.allow }), - ...(policy.subnets?.allow && { allowedCIDRs: policy.subnets.allow }), - ...(policy.subnets?.deny && { deniedCIDRs: policy.subnets.deny }), - }; + return policy; } -export function fromAPINetworkPolicy(api: APINetworkPolicy): NetworkPolicy { - if (api.mode === "allow-all") return "allow-all"; - if (api.mode === "deny-all") return "deny-all"; +export function fromAPINetworkPolicy( + api: APIResponseNetworkPolicy, +): NetworkPolicy { + if (api.mode === "allow-all" || api.mode === "deny-all") { + return api.mode; + } const subnets = api.allowedCIDRs || api.deniedCIDRs @@ -58,33 +35,42 @@ export function fromAPINetworkPolicy(api: APINetworkPolicy): NetworkPolicy { } : undefined; - // If injectionRules are present, reconstruct the record form. + // If L7 rules are present, reconstruct the record form. // The API returns headerNames (secret values are stripped), so we // populate each header value with "". - if (api.injectionRules && api.injectionRules.length > 0) { - const rulesByDomain = new Map( - api.injectionRules.map((r) => [r.domain, r.headerNames ?? []]), - ); + if ( + (api.injectionRules && api.injectionRules.length > 0) || + (api.forwardRules && api.forwardRules.length > 0) + ) { + const rulesByDomain = new Map(); + for (const rule of api.injectionRules ?? []) { + const headers = Object.fromEntries( + (rule.headerNames ?? []).map((n) => [n, ""]), + ); + const rules = rulesByDomain.get(rule.domain) ?? []; + rules.push({ + ...(rule.match ? { match: rule.match } : {}), + transform: [{ headers }], + }); + rulesByDomain.set(rule.domain, rules); + } + for (const rule of api.forwardRules ?? []) { + const rules = rulesByDomain.get(rule.domain) ?? []; + rules.push({ + ...(rule.match ? { match: rule.match } : {}), + forwardURL: rule.forwardURL, + }); + rulesByDomain.set(rule.domain, rules); + } const allow: Record = {}; for (const domain of api.allowedDomains ?? []) { - const headerNames = rulesByDomain.get(domain); - if (headerNames && headerNames.length > 0) { - const headers = Object.fromEntries( - headerNames.map((n) => [n, ""]), - ); - allow[domain] = [{ transform: [{ headers }] }]; - } else { - allow[domain] = []; - } + allow[domain] = rulesByDomain.get(domain) ?? []; } - // Include injection rules for domains not in allowedDomains - for (const rule of api.injectionRules) { + // Include L7 rules for domains not in allowedDomains + for (const rule of [...(api.injectionRules ?? []), ...(api.forwardRules ?? [])]) { if (!(rule.domain in allow)) { - const headers = Object.fromEntries( - (rule.headerNames ?? []).map((n) => [n, ""]), - ); - allow[rule.domain] = [{ transform: [{ headers }] }]; + allow[rule.domain] = rulesByDomain.get(rule.domain) ?? []; } } diff --git a/packages/vercel-sandbox/src/utils/paginator.test.ts b/packages/vercel-sandbox/src/utils/paginator.test.ts new file mode 100644 index 00000000..8fd08888 --- /dev/null +++ b/packages/vercel-sandbox/src/utils/paginator.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from "vitest"; +import { attachPaginator } from "./paginator.js"; + +type Page = { + items: number[]; + pagination: { count: number; next: string | null }; +}; + +function makePages(pages: number[][]): Page[] { + return pages.map((items, idx) => ({ + items, + pagination: { + count: items.length, + next: idx < pages.length - 1 ? `cursor-${idx + 1}` : null, + }, + })); +} + +function mockFetcher(pages: Page[]) { + return vi.fn(async (cursor: string) => { + const idx = Number(cursor.split("-")[1]); + const page = pages[idx]; + if (!page) throw new Error(`no page for cursor ${cursor}`); + return page; + }); +} + +describe("attachPaginator", () => { + it("preserves the first page fields", async () => { + const [first] = makePages([[1, 2]]); + const p = attachPaginator(first, { + itemsKey: "items", + fetchNext: async () => { + throw new Error("should not fetch"); + }, + }); + expect(p.items).toEqual([1, 2]); + expect(p.pagination).toEqual({ count: 2, next: null }); + }); + + it("iterates items across pages via for-await", async () => { + const pages = makePages([[1, 2], [3, 4], [5]]); + const fetchNext = mockFetcher(pages); + const p = attachPaginator(pages[0], { + itemsKey: "items", + fetchNext, + }); + const seen: number[] = []; + for await (const n of p) seen.push(n); + expect(seen).toEqual([1, 2, 3, 4, 5]); + expect(fetchNext).toHaveBeenCalledTimes(2); + expect(fetchNext).toHaveBeenNthCalledWith(1, "cursor-1"); + expect(fetchNext).toHaveBeenNthCalledWith(2, "cursor-2"); + }); + + it("toArray materializes all items", async () => { + const pages = makePages([[1], [2], [3]]); + const p = attachPaginator(pages[0], { + itemsKey: "items", + fetchNext: mockFetcher(pages), + }); + expect(await p.toArray()).toEqual([1, 2, 3]); + }); + + it("pages() yields full page objects", async () => { + const pages = makePages([[1, 2], [3]]); + const p = attachPaginator(pages[0], { + itemsKey: "items", + fetchNext: mockFetcher(pages), + }); + const collected: Page[] = []; + for await (const page of p.pages()) collected.push(page); + expect(collected).toHaveLength(2); + expect(collected[0].items).toEqual([1, 2]); + expect(collected[0].pagination.next).toBe("cursor-1"); + expect(collected[1].items).toEqual([3]); + expect(collected[1].pagination.next).toBeNull(); + }); + + it("does not fetch when first page has next=null", async () => { + const [first] = makePages([[1, 2, 3]]); + const fetchNext = vi.fn(); + const p = attachPaginator(first, { + itemsKey: "items", + fetchNext, + }); + expect(await p.toArray()).toEqual([1, 2, 3]); + expect(fetchNext).not.toHaveBeenCalled(); + }); + + it("stops iteration when signal is aborted before first yield", async () => { + const pages = makePages([[1], [2]]); + const controller = new AbortController(); + controller.abort(); + const p = attachPaginator(pages[0], { + itemsKey: "items", + fetchNext: mockFetcher(pages), + signal: controller.signal, + }); + await expect(p.toArray()).rejects.toThrow(); + }); + + it("stops iteration when signal is aborted between pages", async () => { + const pages = makePages([[1, 2], [3, 4], [5, 6]]); + const controller = new AbortController(); + const fetchNext = vi.fn(async (cursor: string) => { + if (cursor === "cursor-2") controller.abort(); + const idx = Number(cursor.split("-")[1]); + return pages[idx]; + }); + + const p = attachPaginator(pages[0], { + itemsKey: "items", + fetchNext, + signal: controller.signal, + }); + + const seen: number[] = []; + await expect(async () => { + for await (const n of p) seen.push(n); + }).rejects.toThrow(); + expect(seen).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/packages/vercel-sandbox/src/utils/paginator.ts b/packages/vercel-sandbox/src/utils/paginator.ts new file mode 100644 index 00000000..b6265388 --- /dev/null +++ b/packages/vercel-sandbox/src/utils/paginator.ts @@ -0,0 +1,73 @@ +type CursorPaginationMeta = { + count: number; + next: string | null; +}; + +type HasPagination = { pagination: CursorPaginationMeta }; + +type ItemOf = Page[Key] extends Array + ? Item + : never; + +export type Paginator = + Page & + AsyncIterable> & { + pages(): AsyncIterable; + toArray(): Promise[]>; + }; + +type AttachPaginatorOptions = { + itemsKey: Key; + fetchNext: (cursor: string) => Promise; + signal?: AbortSignal; +}; + +export function attachPaginator< + Page extends HasPagination, + Key extends keyof Page, +>( + firstPage: Page, + options: AttachPaginatorOptions, +): Paginator { + const { itemsKey, fetchNext, signal } = options; + + async function* iteratePages(): AsyncGenerator { + throwIfAborted(signal); + let page = firstPage; + yield page; + while (page.pagination.next !== null) { + throwIfAborted(signal); + page = await fetchNext(page.pagination.next); + yield page; + } + } + + async function* iterateItems(): AsyncGenerator> { + for await (const page of iteratePages()) { + const items = page[itemsKey] as unknown as ItemOf[]; + for (const item of items) { + throwIfAborted(signal); + yield item; + } + } + } + + return { + ...firstPage, + [Symbol.asyncIterator]: iterateItems, + pages: iteratePages, + toArray: async () => { + const all: ItemOf[] = []; + for await (const item of iterateItems()) { + all.push(item); + } + return all; + }, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } +} diff --git a/packages/vercel-sandbox/src/utils/sandbox-snapshot.ts b/packages/vercel-sandbox/src/utils/sandbox-snapshot.ts index 7c0e9029..7ec36bb7 100644 --- a/packages/vercel-sandbox/src/utils/sandbox-snapshot.ts +++ b/packages/vercel-sandbox/src/utils/sandbox-snapshot.ts @@ -1,12 +1,12 @@ -import type { SandboxMetaData } from "../api-client/index.js"; +import type { SessionMetaData } from "../api-client/index.js"; import type { NetworkPolicy } from "../network-policy.js"; import { fromAPINetworkPolicy } from "./network-policy.js"; -export type SandboxSnapshot = Omit & { +export type SandboxSnapshot = Omit & { networkPolicy?: NetworkPolicy; }; -export function toSandboxSnapshot(sandbox: SandboxMetaData): SandboxSnapshot { +export function toSandboxSnapshot(sandbox: SessionMetaData): SandboxSnapshot { const { networkPolicy, ...rest } = sandbox; return { ...rest, diff --git a/packages/vercel-sandbox/src/version.ts b/packages/vercel-sandbox/src/version.ts index c905f6dd..7a92a995 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.10.2"; +export const VERSION = "2.0.0-beta.19";