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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/workflow-code-runner/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.next
/next-env.d.ts
4 changes: 1 addition & 3 deletions examples/workflow-code-runner/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { withWorkflow } from "workflow/next";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
serverExternalPackages: ["@vercel/sandbox"],
};
const nextConfig: NextConfig = {};

export default withWorkflow(nextConfig);
4 changes: 2 additions & 2 deletions examples/workflow-code-runner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"build": "next build",
"start": "next start"
},
"dependencies": {
Expand All @@ -13,7 +13,7 @@
"next": "16.0.10",
"react": "19.2.3",
"react-dom": "19.2.3",
"workflow": "^4.2.0-beta.71",
"workflow": "4.2.0-beta.73",
"zod": "^4.1.9"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions examples/workflow-code-runner/public/file.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 19 additions & 23 deletions examples/workflow-code-runner/steps/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import { Sandbox, setSandboxCredentials } from "@vercel/sandbox";
import { Sandbox, setSandboxCredentials } from '@vercel/sandbox';

function initCredentials() {
setSandboxCredentials({
token: process.env.VERCEL_TOKEN!,
teamId: process.env.VERCEL_TEAM_ID!,
projectId: process.env.VERCEL_PROJECT_ID!,
});
}
// Set credentials at module scope so they're available in every step
// context, including nested steps from SDK methods like writeFiles(),
// runCommand(), etc. which are each executed as their own step.
setSandboxCredentials({
token: process.env.VERCEL_TOKEN!,
teamId: process.env.VERCEL_TEAM_ID!,
projectId: process.env.VERCEL_PROJECT_ID!,
});

export async function createSandbox() {
"use step";
initCredentials();
'use step';

console.log("[Sandbox] Creating...");
console.log('[Sandbox] Creating...');

const sandbox = await Sandbox.create({
resources: { vcpus: 1 },
timeout: 5 * 60 * 1000,
runtime: "node22",
runtime: 'node22',
});

console.log("[Sandbox] Created:", sandbox.sandboxId);
console.log('[Sandbox] Created:', sandbox.sandboxId);

// Return the full Sandbox object — workflow serialization
// automatically dehydrates it via WORKFLOW_SERIALIZE
Expand All @@ -30,16 +30,13 @@ export async function createSandbox() {
// The Sandbox object is deserialized via WORKFLOW_DESERIALIZE when
// it crosses the step boundary — no need for Sandbox.get(id)
export async function execute(sandbox: Sandbox, code: string) {
"use step";
initCredentials();
'use step';

console.log("[Sandbox] Executing code...");
console.log('[Sandbox] Executing code...');

await sandbox.writeFiles([
{ path: "script.js", content: Buffer.from(code) },
]);
await sandbox.writeFiles([{ path: 'script.js', content: Buffer.from(code) }]);

const finished = await sandbox.runCommand("node", ["script.js"]);
const finished = await sandbox.runCommand('node', ['script.js']);
const stdout = await finished.stdout();
const stderr = await finished.stderr();

Expand All @@ -51,9 +48,8 @@ export async function execute(sandbox: Sandbox, code: string) {
}

export async function stopSandbox(sandbox: Sandbox) {
"use step";
initCredentials();
'use step';

console.log("[Sandbox] Stopping...");
console.log('[Sandbox] Stopping...');
await sandbox.stop();
}
3 changes: 3 additions & 0 deletions examples/workflow-code-runner/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"outputDirectory": "./.next"
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@
"pre-commit": "lint-staged"
}
},
"packageManager": "pnpm@10.30.2+sha512.36cdc707e7b7940a988c9c1ecf88d084f8514b5c3f085f53a2e244c2921d3b2545bc20dd4ebe1fc245feec463bb298aecea7a63ed1f7680b877dc6379d8d0cb4"
"packageManager": "pnpm@10.30.2+sha512.36cdc707e7b7940a988c9c1ecf88d084f8514b5c3f085f53a2e244c2921d3b2545bc20dd4ebe1fc245feec463bb298aecea7a63ed1f7680b877dc6379d8d0cb4",
"dependencies": {
"workflow": "4.2.0-beta.73"
}
}
64 changes: 38 additions & 26 deletions packages/vercel-sandbox/src/command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { APIClient, type CommandData } from "./api-client/index.js";
import { Signal, resolveSignal } from "./utils/resolveSignal.js";
import { getSandboxCredentials } from "./utils/sandbox-credentials.js";
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde';
import { APIClient, type CommandData } from './api-client/index.js';
import { getCredentials } from './utils/get-credentials.js';
import { resolveSignal, type Signal } from './utils/resolveSignal.js';

/**
* Cached output from a command execution.
Expand Down Expand Up @@ -49,17 +49,17 @@ export class Command {
protected _client: APIClient | null = null;

/**
* Lazily get or create the API client.
* If no client was provided during construction, creates one using global credentials.
* Lazily resolve credentials and construct an API client.
* @internal
*/
get client(): APIClient {
if (!this._client) {
const credentials = getSandboxCredentials();
this._client = new APIClient({
teamId: credentials.teamId,
token: credentials.token,
});
}
protected async ensureClient(): Promise<APIClient> {
'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;
}

Expand Down Expand Up @@ -190,7 +190,12 @@ export class Command {
* to access output as a string.
*/
logs(opts?: { signal?: AbortSignal }) {
return this.client.getLogs({
if (!this._client) {
throw new Error(
'logs() requires an API client. Call an async method first to initialize the client.'
);
Comment on lines +194 to +196

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Command.logs() now throws when _client is unset, which is a behavior change vs the previous lazy client creation and breaks log streaming for deserialized Command instances (or any instance constructed without a client). If this is intentional for workflow compatibility, please document it in the JSDoc; otherwise consider an alternative API that can initialize the client asynchronously (or accept a client/fetch options) so logs can still be consumed without a prior async call.

Suggested change
throw new Error(
'logs() requires an API client. Call an async method first to initialize the client.'
);
const credentials = getSandboxCredentials();
this._client = new APIClient(credentials);

Copilot uses AI. Check for mistakes.
}
return this._client.getLogs({
sandboxId: this.sandboxId,
cmdId: this.cmd.id,
signal: opts?.signal,
Expand All @@ -217,17 +222,19 @@ export class Command {
* @returns A {@link CommandFinished} instance with populated exit code.
*/
async wait(params?: { signal?: AbortSignal }) {
'use step';
const client = await this.ensureClient();
params?.signal?.throwIfAborted();

const command = await this.client.getCommand({
const command = await client.getCommand({
sandboxId: this.sandboxId,
cmdId: this.cmd.id,
wait: true,
signal: params?.signal,
});

return new CommandFinished({
client: this.client,
client,
sandboxId: this.sandboxId,
cmd: command.json.command,
exitCode: command.json.command.exitCode,
Expand All @@ -246,12 +253,12 @@ export class Command {
if (!this.outputCache) {
this.outputCache = (async () => {
try {
let stdout = "";
let stderr = "";
let both = "";
let stdout = '';
let stderr = '';
let both = '';

@vercel vercel Bot Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deserialized Command instances without cached output throw "logs() requires an API client" when calling output(), stdout(), or stderr() because getCachedOutput() never initializes the API client before calling the synchronous logs() method.

Fix on Vercel

for await (const log of this.logs({ signal: opts?.signal })) {
both += log.data;
if (log.stream === "stdout") {
if (log.stream === 'stdout') {
stdout += log.data;
} else {
stderr += log.data;
Expand Down Expand Up @@ -283,9 +290,10 @@ export class Command {
* @returns The output of the specified stream(s) as a string.
*/
async output(
stream: "stdout" | "stderr" | "both" = "both",
stream: 'stdout' | 'stderr' | 'both' = 'both',
opts?: { signal?: AbortSignal }
) {
'use step';
const cached = await this.getCachedOutput(opts);
return cached[stream];
}
Expand All @@ -301,7 +309,8 @@ export class Command {
* @returns The standard output of the command.
*/
async stdout(opts?: { signal?: AbortSignal }) {
return this.output("stdout", opts);
'use step';
return this.output('stdout', opts);
}

/**
Expand All @@ -315,7 +324,8 @@ export class Command {
* @returns The standard error output of the command.
*/
async stderr(opts?: { signal?: AbortSignal }) {
return this.output("stderr", opts);
'use step';
return this.output('stderr', opts);
}

/**
Expand All @@ -327,10 +337,12 @@ export class Command {
* @returns Promise<void>.
*/
async kill(signal?: Signal, opts?: { abortSignal?: AbortSignal }) {
await this.client.killCommand({
'use step';
const client = await this.ensureClient();
await client.killCommand({
sandboxId: this.sandboxId,
commandId: this.cmd.id,
signal: resolveSignal(signal ?? "SIGTERM"),
signal: resolveSignal(signal ?? 'SIGTERM'),
abortSignal: opts?.abortSignal,
});
}
Expand Down
Loading
Loading