Streaming output and webhook-based completion when run in workflow#118
Streaming output and webhook-based completion when run in workflow#118
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Sandbox.create, writeFiles, runCommand, and stop all have "use step" internally, so they don't need to be wrapped in separate step functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use detached runCommand with stdout/stderr piped to workflow writable streams (via getWritable with namespaces) - Add SSE endpoint to stream stdout/stderr to the browser via run.getReadable - Update UI to render live streaming output as it arrives Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4610aa1 to
fea1505
Compare
runCommand now accepts both Node.js Writable and Web WritableStream for stdout/stderr options. The conversion happens inside the step (where Node.js APIs are available), so workflow code can pass Web streams from getWritable() directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a CommandFinished is deserialized across a step boundary, it has no client. getCachedOutput() now calls ensureClient() before logs() so that stderr()/stdout() work on deserialized instances. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The transparent webhook approach failed because _webhookWait (a Promise) can't survive workflow checkpointing between await expressions. The Command gets serialized/deserialized and the promise is lost. The webhook must be created and awaited at the workflow level where the workflow runtime manages its lifecycle across checkpoints. The SDK provides onCompleteUrl on runCommand to wrap the command in a shell script that POSTs the exit code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
runCommand (no "use step") runs in workflow context and:
1. Creates a webhook via dynamic import("workflow").createWebhook()
2. Passes the webhook URL to _runCommandStep (the actual step)
3. Injects the webhook object onto the returned Command._webhook
The webhook is a workflow primitive that survives checkpointing via
WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE on Command.
Command.wait() checks _webhook first — if present, awaits the webhook
(suspending the workflow). If absent (non-workflow context), falls
back to _waitStep which polls via a step.
DX is completely transparent:
const cmd = await sandbox.runCommand({ detached: true, ... });
const finished = await cmd.wait(); // suspends workflow, no step blocked
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Function('return import("workflow")') trick hid the import from
the workflow bundler, so it was never resolved and createWebhook was
undefined at runtime.
Use a regular dynamic import with @ts-expect-error instead. Mark
"workflow" as external in tsdown config to prevent bundling it into
the dist.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SSE endpoint only handled stdout and stderr, not status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Show creating-sandbox, writing, and stopping phases in the UI alongside generating/fixing/running. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CodeBlock: shiki-powered syntax highlighting with github-dark theme - Terminal: styled terminal output with ANSI color support, auto-scroll, window chrome dots, and title bar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Code pane (left): persists generated code across status updates, shows script.js with attempt count and pass/fail badge - Terminal pane (right): stdout on top, stderr below, with "Waiting for output..." placeholder while running - Code no longer disappears between phases — stored in dedicated state - Top bar with prompt, status indicator, and error messages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Pure black backgrounds matching Vercel/geistdocs design - Line numbers via CSS counters on shiki output - github-dark-default shiki theme - Consistent border/muted/foreground CSS variables - Terminal dots and text use matching muted color Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Runtime dropdown in the UI next to the prompt - Workflow accepts runtime param, configures sandbox, filename, command, and AI prompts accordingly - Code block uses correct syntax highlighting language - File tab shows script.js or script.py based on runtime Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bash uses a node24 sandbox (bash is available in all runtimes), writes script.sh, and runs with bash. AI generates shell scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // When _onCompleteUrl is set (workflow webhook), wrap the command in a | ||
| // shell script that runs the original command then POSTs the exit code. | ||
| let cmd = params.cmd; | ||
| let cmdArgs = params.args ?? []; | ||
| if (params._onCompleteUrl) { | ||
| const escaped = [params.cmd, ...cmdArgs] | ||
| .map((a) => `'${a.replace(/'/g, "'\\''")}'`) | ||
| .join(" "); | ||
| cmd = "sh"; | ||
| cmdArgs = [ | ||
| "-c", | ||
| `${escaped}; EXIT_CODE=$?; curl -s -X POST -H 'Content-Type: application/json' -d "{\\"exitCode\\":$EXIT_CODE}" '${params._onCompleteUrl}'; exit $EXIT_CODE`, | ||
| ]; | ||
| } | ||
|
|
There was a problem hiding this comment.
this part seem hacky and needs review - not sure how safe this is in production where we're wrapping all sorts of arbitrary user code/programs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| "react-dom": "19.2.3", | ||
| "workflow": "4.2.0-beta.73", | ||
| "shiki": "^4.0.2", | ||
| "workflow": "https://workflow-docs-3fkphgcis.vercel.sh/workflow.tgz", |
There was a problem hiding this comment.
for demo only - need to switch back to main before publishing. It's not required to work but just testing a perf improvement in workflow
There was a problem hiding this comment.
Pull request overview
This PR enhances @vercel/sandbox’s Workflow DevKit integration by enabling webhook-based completion for detached commands and live stdout/stderr streaming into workflow named streams, plus updates the workflow code-runner example to demonstrate multi-runtime execution with real-time UI output.
Changes:
- SDK:
runCommand({ detached: true })attempts to create a workflow webhook andCommand.wait()can suspend on webhook completion;runCommandstdout/stderr now accept NodeWritableor WebWritableStream;writeFilesnow acceptsstring | Buffer. - SDK:
Commandserialization/deserialization includes the webhook primitive;getCachedOutput()ensures the client is initialized before streaming logs. - Example app: adds SSE streaming for stdout/stderr/status, multi-runtime runner (Node 24/22, Python 3.13, Bash), and a split-pane UI with syntax highlighting + ANSI terminal rendering.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Adds example dependencies (shiki, ansi-to-react) and switches workflow to a tarball URL. |
| packages/vercel-sandbox/tsdown.config.ts | Marks workflow as external to avoid bundling an optional runtime import. |
| packages/vercel-sandbox/src/sandbox.ts | Adds webhook wiring for detached commands, Web WritableStream support for log piping, and writeFiles string support. |
| packages/vercel-sandbox/src/command.ts | Adds webhook field to serialization and webhook-based wait() path; ensures client before logs() usage in cached output. |
| examples/workflow-code-runner/workflows/code-runner.ts | Reworks workflow to use Sandbox directly, add multi-runtime support, named stream piping, and webhook-based detached execution. |
| examples/workflow-code-runner/steps/status.ts | Adds a workflow step to emit structured status updates to a named stream. |
| examples/workflow-code-runner/steps/sandbox.ts | Removes old sandbox helper steps (now handled inline in the workflow). |
| examples/workflow-code-runner/steps/ai.ts | Updates AI steps to generate/fix code for a selected language/runtime. |
| examples/workflow-code-runner/package.json | Adds shiki, ansi-to-react, and switches workflow dependency to a tarball URL. |
| examples/workflow-code-runner/app/page.tsx | Updates UI to multi-runtime input + split panes and consumes SSE streams for live output/status. |
| examples/workflow-code-runner/app/globals.css | Adds theme variables and Shiki line-number styling. |
| examples/workflow-code-runner/app/components/terminal.tsx | Adds ANSI-rendering terminal component with autoscroll. |
| examples/workflow-code-runner/app/components/code-block.tsx | Adds Shiki-based syntax-highlighted code block with fallback rendering. |
| examples/workflow-code-runner/app/api/run/route.ts | Adds SSE streaming endpoints for stdout/stderr/status and keeps polling endpoint for completion. |
| examples/workflow-code-runner/NOTES.md | Documents known limitations for local webhook resumption and preview protection. |
| examples/workflow-code-runner/.gitignore | Expands ignores for local Vercel and env files (one path needs correction). |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const stdoutSource = new EventSource( | ||
| `/api/run?runId=${runId}&stream=stdout`, | ||
| ); | ||
| const stderrSource = new EventSource( | ||
| `/api/run?runId=${runId}&stream=stderr`, |
There was a problem hiding this comment.
These EventSource connections are only closed on the “completed/failed” poll paths. If polling throws (network/JSON error) or the handler exits early, the SSE connections can remain open and leak. Consider storing the sources in refs and closing them in the catch path and/or via an effect cleanup on unmount.
| if (!stream) return undefined; | ||
| if ("getWriter" in stream) { | ||
| const writer = stream.getWriter(); | ||
| return { write: (data: string) => writer.write(data) }; | ||
| } |
There was a problem hiding this comment.
For Web WritableStreams, writer.write() is async and can apply backpressure / reject; currently pipeLogs() doesn’t await writes, so failures/backpressure are ignored and may surface as unhandled rejections or memory growth. Consider awaiting Web writes and releasing the writer lock in finally (and/or aborting the stream on error).
| /next-env.d.ts | ||
| .vercel | ||
| .env*.local | ||
| examples/workflow-code-runner/public/.well-known/ |
There was a problem hiding this comment.
This .gitignore entry likely won’t match because it’s already inside examples/workflow-code-runner/; examples/workflow-code-runner/public/.well-known/ is an extra path prefix. Use a path relative to this directory (e.g. public/.well-known/ or /public/.well-known/) so the well-known folder is actually ignored.
| examples/workflow-code-runner/public/.well-known/ | |
| /public/.well-known/ |
| } else { | ||
| setTimeout(() => pollResult(runId), 1000); | ||
| setTimeout(pollResult, 1000); | ||
| } |
There was a problem hiding this comment.
setTimeout(pollResult, 1000) isn’t tracked/cleared. If the component unmounts or a new run starts, this can still fire and set state on an unmounted component / race with the next run. Consider storing the timeout id and clearing it in cleanup/unmount.
| @@ -1,48 +1,90 @@ | |||
| import { FatalError } from "workflow"; | |||
| import { createSandbox, execute, stopSandbox } from "@/steps/sandbox"; | |||
| import { createWebhook, getWritable } from "workflow"; | |||
There was a problem hiding this comment.
Unused import: createWebhook is imported but never referenced in this workflow. If it’s no longer needed now that the SDK handles webhook creation internally, remove it to avoid confusion and keep the example minimal.
| import { createWebhook, getWritable } from "workflow"; | |
| import { getWritable } from "workflow"; |
| async wait(params?: { signal?: AbortSignal }): Promise<CommandFinished> { | ||
| // In workflow context, the webhook is a workflow primitive that was | ||
| // set by runCommand. Awaiting it suspends the workflow without blocking | ||
| // a step — the sandbox POSTs the exit code when the command finishes. | ||
| if (this._webhook) { |
There was a problem hiding this comment.
This introduces a webhook-based completion branch in wait(), but there’s no unit coverage for the webhook path (payload parsing/validation, abort behavior). Adding a small unit test for the _webhook branch would help prevent regressions between workflow and non-workflow contexts.
| if (params._onCompleteUrl) { | ||
| const escaped = [params.cmd, ...cmdArgs] | ||
| .map((a) => `'${a.replace(/'/g, "'\\''")}'`) | ||
| .join(" "); | ||
| cmd = "sh"; |
There was a problem hiding this comment.
The _onCompleteUrl path changes the executed command to sh -c ... and relies on the webhook URL being wired through correctly, but there’s no unit test asserting the wrapped command/args. Consider adding a unit test around this branch to lock in the behavior and avoid regressions.
| if (errStream && "emit" in errStream) { | ||
| errStream.emit("error", err); |
There was a problem hiding this comment.
Detached log piping errors are only forwarded via .emit('error') when the provided stream is a Node Writable. If callers pass a Web WritableStream, errors are silently dropped. Consider detecting Web streams and signaling errors via writer.abort(err) (or another explicit error channel).
| if (errStream && "emit" in errStream) { | |
| errStream.emit("error", err); | |
| if (errStream && typeof (errStream as any).emit === "function") { | |
| (errStream as any).emit("error", err); | |
| } else if (errStream && typeof (errStream as any).getWriter === "function") { | |
| // Support Web WritableStream: forward error via writer.abort(err) | |
| try { | |
| const writer = (errStream as any).getWriter(); | |
| void writer.abort(err).catch(() => { | |
| // Ignore abort errors to avoid unhandled rejections | |
| }); | |
| } catch { | |
| // Ignore failures obtaining writer or aborting | |
| } |
| cmd = "sh"; | ||
| cmdArgs = [ | ||
| "-c", | ||
| `${escaped}; EXIT_CODE=$?; curl -s -X POST -H 'Content-Type: application/json' -d "{\\"exitCode\\":$EXIT_CODE}" '${params._onCompleteUrl}'; exit $EXIT_CODE`, |
There was a problem hiding this comment.
Webhook completion wraps the command with sh -c and uses curl to POST the exit code. The sandbox README’s system section doesn’t document curl as available, so this may be brittle across images/runtimes and could leave workflows hanging if curl is missing. Consider using a more guaranteed mechanism (e.g. Node runtime fetch) or documenting/ensuring curl availability.
| cmd = "sh"; | |
| cmdArgs = [ | |
| "-c", | |
| `${escaped}; EXIT_CODE=$?; curl -s -X POST -H 'Content-Type: application/json' -d "{\\"exitCode\\":$EXIT_CODE}" '${params._onCompleteUrl}'; exit $EXIT_CODE`, | |
| const onCompleteUrlEscaped = params._onCompleteUrl.replace(/'/g, "'\\''"); | |
| cmd = "sh"; | |
| cmdArgs = [ | |
| "-c", | |
| `${escaped}; EXIT_CODE=$?; HOOK_URL='${onCompleteUrlEscaped}'; PAYLOAD="{\\"exitCode\\":$EXIT_CODE}"; if command -v curl >/dev/null 2>&1; then curl -s -X POST -H 'Content-Type: application/json' -d "$PAYLOAD" "$HOOK_URL" >/dev/null 2>&1 || true; elif command -v node >/dev/null 2>&1; then node -e "const url=process.argv[2];const body=process.argv[3];(async()=>{try{await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body});}catch(e){}})();" "$HOOK_URL" "$PAYLOAD" >/dev/null 2>&1 || true; fi; exit $EXIT_CODE`, |
| await writer.write(JSON.stringify({ phase, attempt, code })); | ||
| writer.releaseLock(); |
There was a problem hiding this comment.
updateStatus() should release the writer lock even if writer.write() throws/rejects; currently releaseLock() won’t run on errors and the stream can remain permanently locked. Wrap the write in try/finally (and consider reusing a writer if this is called frequently).
| await writer.write(JSON.stringify({ phase, attempt, code })); | |
| writer.releaseLock(); | |
| try { | |
| await writer.write(JSON.stringify({ phase, attempt, code })); | |
| } finally { | |
| writer.releaseLock(); | |
| } |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Summary
Example app and SDK improvements that showcase
@vercel/sandbox+ Workflow DevKit integration:Webhook-based command completion: Detached
runCommandautomatically creates a workflow webhook and wraps the command in a shell script that POSTs the exit code when done.cmd.wait()suspends the workflow until the webhook fires — no step is blocked polling, and we're not bound by serverless function timeouts while waiting for long-running sandbox processes.Live streaming stdout/stderr: Sandbox command output is piped to workflow named streams (
getWritable), which the client reads via SSE in real-time.writeFilesaccepts strings:Bufferisn't available in the workflow VM, sowriteFilesnow acceptsstring | Bufferfor content, converting inside the step where Node.js APIs are available.runCommandaccepts Web WritableStream: In addition to Node.jsWritable, enabling direct use of workflow'sgetWritable()streams.How it works
SDK changes
writeFilesacceptsstring | Buffercontentsandbox.tsrunCommandstdout/stderr acceptWritable | WritableStreamsandbox.tsrunCommandwithdetached: trueauto-creates workflow webhooksandbox.tscmd.wait()uses webhook in workflow context, falls back to pollingcommand.tsWORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZEcommand.tsensureClient()called beforelogs()ingetCachedOutputcommand.ts"workflow"marked as external in tsdown configtsdown.config.tsExample app
Multi-runtime (Node.js 24/22, Python 3.13, Bash) code runner with:
Test plan
_waitStep)🤖 Generated with Claude Code