How to Stream Exec Output as Server-Sent Events for Real-Time Command Output in Cloudflare Computer

Use Cloudflare Computer's ReadableStream<ExecEvent> from client.shell.exec() and pipe it through a thin HTTP handler that sets Content-Type: text/event-stream to deliver real-time stdout, stderr, and exit codes to browsers.

The Computer project by Cloudflare provides a containerized execution environment where commands run inside Durable Objects and stream results over a capnweb RPC protocol. When you need to expose this live output to web clients, Server-Sent Events (SSE) offer a lightweight, browser-native solution. This article explains the architecture and provides production-ready code to bridge Computer's exec streams to SSE endpoints.


How Computer Streams Command Output

At the heart of the system is the exec runner in packages/computerd/src/exec/runner.ts. The exec(command, options) method spawns a subprocess and immediately returns a handle containing:

  • id: A unique identifier for the execution
  • events: A ReadableStream<ExecEvent> yielding structured events

The ExecEvent type (defined in packages/rpc/src/interface.ts) supports these variants:

Event Type Payload Purpose
stdout data: Uint8Array Live standard output bytes
stderr data: Uint8Array Live standard error bytes
exit code: number Process exit status
error message: string Execution failure details

The RPC surface in packages/rpc/src/server.ts exposes this through the ShellRPC interface, making the same stream available to any RPC client running inside a Cloudflare Worker or container.


Building the SSE Bridge

Computer does not include a built-in SSE endpoint—you implement a thin wrapper that consumes the ReadableStream<ExecEvent> and formats it as SSE messages. This design keeps the core system agnostic while allowing flexible HTTP integrations.

Step 1: Invoke the Shell RPC

In your Cloudflare Worker, obtain an RPC client and call shell.exec():

const client = env.COMPUTER_RPC_CLIENT;
const exec = await client.shell.exec({ source: "ping -c 10 example.com" });
// exec.events is the ReadableStream<ExecEvent>

Step 2: Transform to SSE Format

The SSE protocol requires event: and data: fields separated by newlines, with a double newline terminating each message:


event: stdout
data: PING example.com (93.184.216.34)

Your Worker must:

  • Set Content-Type: text/event-stream
  • Disable caching with Cache-Control: no-cache, no-transform
  • Keep the connection alive with Connection: keep-alive
  • Map each ExecEvent to the appropriate SSE event type

Step 3: Full Worker Implementation

// src/exec-sse.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname !== "/exec") {
      return new Response("Not found", { status: 404 });
    }

    const client = env.COMPUTER_RPC_CLIENT;
    const command = url.searchParams.get("cmd") ?? "ls";
    
    const exec = await client.shell.exec({ source: command });
    const encoder = new TextEncoder();

    const stream = new ReadableStream({
      async start(controller) {
        const reader = exec.events.getReader();
        
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          
          const sseMessage = formatSSE(value);
          controller.enqueue(encoder.encode(sseMessage));
          
          // Terminate stream on exit event
          if (value.type === "exit") {
            break;
          }
        }
        
        controller.close();
      },
      
      cancel() {
        // Cleanup if client disconnects
        exec.events.cancel?.();
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache, no-transform",
        "Connection": "keep-alive",
      },
    });
  },
};

function formatSSE(event: ExecEvent): string {
  switch (event.type) {
    case "stdout":
      return `event: stdout\ndata: ${decode(event.data)}\n\n`;
    case "stderr":
      return `event: stderr\ndata: ${decode(event.data)}\n\n`;
    case "exit":
      return `event: exit\ndata: ${event.code}\n\n`;
    case "error":
      return `event: error\ndata: ${event.message ?? "execution error"}\n\n`;
    default:
      return `event: unknown\ndata: ${JSON.stringify(event)}\n\n`;
  }
}

function decode(data: Uint8Array | undefined): string {
  return new TextDecoder().decode(data ?? new Uint8Array());
}

Browser Client Consumption

With the SSE endpoint active, browsers consume the stream via the native EventSource API—no polling or WebSocket handshake required:

const evtSrc = new EventSource("/exec?cmd=ping%20-c%205%20example.com");

evtSrc.addEventListener("stdout", (e) => {
  console.log("stdout:", e.data);
  document.getElementById("output").textContent += e.data;
});

evtSrc.addEventListener("stderr", (e) => {
  console.error("stderr:", e.data);
});

evtSrc.addEventListener("exit", (e) => {
  console.log("Process exited with code:", e.data);
  evtSrc.close();
});

evtSrc.onerror = (err) => {
  console.error("SSE connection error:", err);
  evtSrc.close();
};

The browser receives each chunk of output as it is emitted by the underlying ExecLog in SQLite, with latency bounded only by network round-trips—not batching or buffering delays.


Key Design Decisions

Why SSE Over WebSockets?

Aspect SSE WebSockets
Protocol complexity HTTP/1.1 or HTTP/2 Custom handshake
Browser support Native EventSource Requires library or manual frame handling
Firewall/proxy compatibility Excellent (standard HTTP) Often blocked
Directionality Server→client (ideal for logs) Bidirectional (overkill for streaming output)

For unidirectional command output, SSE provides sufficient capability with significantly less operational overhead.

Backpressure Handling

The ReadableStream implementation in Computer respects backpressure—if the browser client slows down, the stream pauses automatically. This prevents memory exhaustion when commands produce high-volume output. The cancel() handler in the Worker implementation allows graceful cleanup if the client disconnects mid-stream.

Event Persistence vs. Real-Time

The runner.ts implementation writes all events to ExecLog in SQLite for durability, but the events stream delivers them immediately. Your SSE bridge receives live data without waiting for command completion, enabling true real-time experiences.


Summary

  • Cloudflare Computer exposes command output as ReadableStream<ExecEvent> via client.shell.exec() in packages/rpc/src/server.ts
  • The exec runner in packages/computerd/src/exec/runner.ts generates events for stdout, stderr, exit status, and errors
  • Type definitions in packages/rpc/src/interface.ts standardize the event shapes across RPC boundaries
  • SSE bridging requires a thin Worker wrapper that sets Content-Type: text/event-stream and formats ExecEvent objects as SSE messages
  • Browser clients consume the stream natively via EventSource with zero dependencies

Frequently Asked Questions

How do I handle long-running commands that exceed Worker CPU limits?

Cloudflare Workers have a 50ms CPU time limit for free plans and 30 seconds for paid plans, but event wait time does not count toward CPU limits. The SSE handler spends most of its time awaiting reader.read(), so the connection can remain open indefinitely. Ensure your Worker plan supports extended CPU time if the command itself performs computation, or move heavy work into the Computer container where limits differ.

Can I stream to multiple clients from the same exec invocation?

The ReadableStream returned by exec() is single-consumer. To broadcast to multiple clients, either:

  • Invoke client.shell.exec() separately for each client (isolated executions), or
  • Use a teed stream (stream.tee()) if you need to fan out a single execution to multiple HTTP responses

What encoding should I use for binary data in SSE?

SSE mandates UTF-8 in the data: field. For binary output, Base64-encode the payload and decode on the client:

// Server
`event: stdout\ndata: ${btoa(String.fromCharCode(...event.data))}\n\n`

// Client
const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0));

Alternatively, use data: ... with JSON-escaped byte arrays for smaller overhead with mostly-text output.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →