Show the output of a command in a browser while the command runs. The Durable Object sends standard output and standard error as server-sent events ↗︎, reports the exit code, and stops the command when the client disconnects.
- A Worker with a Durable Object that starts a container with the Durable Object scheduling policy. To create one, refer to Run a Linux command.
-
Add a method to your Durable Object that runs a command and returns its output as an event stream:
src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async stream(argv: string[]): Promise<Response> { const container = this.ctx.container; if (!container) { throw new Error("The container binding is not configured"); } if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], enableInternet: false, }); } const process = await container.exec(argv); const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const send = (event: string, data: unknown) => writer.write( encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), ); const forward = async ( stream: ReadableStream<Uint8Array>, event: "stdout" | "stderr", ) => { for await (const text of stream.pipeThrough(new TextDecoderStream())) { await send(event, text); } }; let exited = false; const markExited = () => { exited = true; }; process.exitCode.then(markExited, markExited); // Signaling a process that has exited records an internal error. const stop = () => { if (!exited) process.kill(); }; // Stop the process when the client disconnects. writer.closed.catch(stop); // Detect a disconnected client while the process writes nothing. const heartbeat = setInterval(() => { writer.write(encoder.encode(": keep-alive\n\n")).catch(() => {}); }, 5_000); const pump = async () => { try { await Promise.all([ forward(process.stdout!, "stdout"), forward(process.stderr!, "stderr"), ]); await send("exit", { exitCode: await process.exitCode }); await writer.close(); } catch { stop(); } finally { clearInterval(heartbeat); } }; // Keep streaming after this method returns the response. void pump(); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); } }The method returns the response before the command finishes.
pump()then writes each chunk as the process produces it.pump()reads standard output and standard error at the same time, because a stream that nobody reads can block a process that keeps writing to it.TextDecoderStreamkeeps characters that span two chunks intact.When the client disconnects, the next write fails and the method calls
kill(), which sendsSIGTERMto the command. The keep-alive comment every five seconds makes that write happen while the command is silent. -
Add a route to your Worker that streams a command your application chooses:
src/index.jsjs const build = [ "sh", "-c", 'for i in 1 2 3; do echo "step $i"; sleep 1; done; echo "warning: almost done" >&2; exit 3', ]; export default { async fetch(request, env) { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/build$/.exec( url.pathname, ); if (!match || request.method !== "GET") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); return sandbox.stream(build); }, };src/index.tsts const build = [ "sh", "-c", 'for i in 1 2 3; do echo "step $i"; sleep 1; done; echo "warning: almost done" >&2; exit 3', ]; export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/build$/.exec(url.pathname); if (!match || request.method !== "GET") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); return sandbox.stream(build); }, } satisfies ExportedHandler<Env>;The example command writes a line each second, writes a warning to standard error, and exits with code
3. Replace it with your build or test command. The Worker chooses the command instead of reading one from the request. For more information, refer to Sandbox security. -
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
Stream the command in the sandbox named
ada. Replace the example hostname with theworkers.devURL that Wrangler prints:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/build --no-bufferThe events arrive about one second apart:
event: stdout data: "step 1\n" event: stdout data: "step 2\n" event: stdout data: "step 3\n" event: stderr data: "warning: almost done\n" event: exit data: {"exitCode":3}
Each event has a type and a JSON-encoded data value. A stdout or stderr event can hold part of a line or several lines, so append the text instead of treating each event as a line. The exit event comes last. Lines that start with : are comments, which clients ignore.
The route answers GET requests, so a page can read it with EventSource:
const output = document.querySelector("pre");
const events = new EventSource("/sandboxes/ada/build");
for (const type of ["stdout", "stderr"]) {
events.addEventListener(type, (event) => {
output.textContent += JSON.parse(event.data);
});
}
events.addEventListener("exit", (event) => {
output.textContent += `\nExit code ${JSON.parse(event.data).exitCode}\n`;
events.close();
});EventSource reconnects when a stream ends, which would run the command again. The page calls close() on the exit event to prevent that.
A disconnect stops only the process that exec() started. Processes that the command starts, such as the commands in an sh -c script, keep the output streams open until they exit. To stop a command and every process it starts after a time limit, run it under GNU coreutils timeout:
const build = ["timeout", "--kill-after=5", "60", "sh", "-c", "npm test"];The command exits with code 124 when it runs longer than 60 seconds. For more information, refer to Stop the processes a command starts.
- Execute commands: combine standard error with standard output, send standard input, and handle exit codes.
- Run background processes: commands that outlive the request.
exec()