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

> Stream Cloudflare Computer exec output as Server-Sent Events for real-time command output. Learn how to pipe ReadableStream to an HTTP handler for live stdout, stderr, and exit codes.

- Repository: [Cloudflare/computer](https://github.com/cloudflare/computer)
- Tags: how-to-guide
- Published: 2026-08-16

---

**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`](https://github.com/cloudflare/computer/blob/main/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`](https://github.com/cloudflare/computer/blob/main/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`](https://github.com/cloudflare/computer/blob/main/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()`:

```typescript
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

```typescript
// 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:

```javascript
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`](https://github.com/cloudflare/computer/blob/main/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`](https://github.com/cloudflare/computer/blob/main/packages/rpc/src/server.ts)
- The **exec runner** in [`packages/computerd/src/exec/runner.ts`](https://github.com/cloudflare/computer/blob/main/packages/computerd/src/exec/runner.ts) generates events for stdout, stderr, exit status, and errors
- **Type definitions** in [`packages/rpc/src/interface.ts`](https://github.com/cloudflare/computer/blob/main/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:

```typescript
// 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.