the universal sandbox runtime for agents and humans. pocketenv.io
sandbox openclaw agent claude-code vercel-sandbox deno-sandbox cloudflare-sandbox atproto sprites daytona
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Use WebSockets for terminal streams

Replace SSE/EventSource and POST-based input/resize with WebSocket
connections. Send resize messages as JSON and forward keystrokes over
the
ws. Include token as a ws query param and improve close/error handling
and cleanup.

+76 -144
+36 -71
apps/web/src/components/terminal/Terminal.tsx
··· 1 1 /** eslint-disable @typescript-eslint/no-explicit-any */ 2 - import { useEffect, useRef, useMemo, useCallback, useState } from "react"; 2 + import { useEffect, useRef, useMemo, useState } from "react"; 3 3 import { useXTerm } from "react-xtermjs"; 4 4 import { FitAddon } from "@xterm/addon-fit"; 5 5 import { API_URL } from "../../consts"; ··· 70 70 onClose, 71 71 }: TerminalContentProps) { 72 72 const sessionIdRef = useRef<string | null>(null); 73 - const eventSourceRef = useRef<EventSource | null>(null); 73 + const wsRef = useRef<WebSocket | null>(null); 74 74 const fitAddonRef = useRef<FitAddon | null>(null); 75 75 76 76 const theme = isDarkMode ? darkTheme : lightTheme; ··· 93 93 94 94 const { ref, instance } = useXTerm({ options }); 95 95 96 - const sendInput = useCallback(async (data: string) => { 97 - const sid = sessionIdRef.current; 98 - if (!sid) return; 99 - try { 100 - await fetch(`${API_URL}/ssh/input/${sid}`, { 101 - method: "POST", 102 - headers: { 103 - "Content-Type": "application/json", 104 - ...(localStorage.getItem("token") && { 105 - Authorization: `Bearer ${localStorage.getItem("token")}`, 106 - }), 107 - }, 108 - body: JSON.stringify({ data }), 109 - }); 110 - } catch { 111 - // Silently ignore input errors (session may have closed) 112 - } 113 - }, []); 114 - 115 - const sendResize = useCallback(async (cols: number, rows: number) => { 116 - const sid = sessionIdRef.current; 117 - if (!sid) return; 118 - try { 119 - await fetch(`${API_URL}/ssh/resize/${sid}`, { 120 - method: "POST", 121 - headers: { 122 - "Content-Type": "application/json", 123 - ...(localStorage.getItem("token") && { 124 - Authorization: `Bearer ${localStorage.getItem("token")}`, 125 - }), 126 - }, 127 - 128 - body: JSON.stringify({ cols, rows }), 129 - }); 130 - } catch { 131 - // Silently ignore resize errors 132 - } 133 - }, []); 134 - 135 96 useEffect(() => { 136 97 if (!instance) return; 137 98 ··· 157 118 window.addEventListener("resize", handleResize); 158 119 159 120 const resizeDisposable = instance.onResize(({ cols, rows }) => { 160 - sendResize(cols, rows); 121 + if (wsRef.current?.readyState === WebSocket.OPEN) { 122 + wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); 123 + } 161 124 }); 162 125 163 126 const connect = async () => { ··· 167 130 168 131 instance.write(`\x1b[35mConnecting to SSH session...\x1b[0m\r\n`); 169 132 133 + const token = localStorage.getItem("token"); 170 134 const response = await fetch(`${API_URL}/ssh/connect`, { 171 135 method: "POST", 172 136 headers: { 173 137 "Content-Type": "application/json", 174 138 "X-Sandbox-Id": sandboxId, 175 - ...(localStorage.getItem("token") && { 176 - Authorization: `Bearer ${localStorage.getItem("token")}`, 177 - }), 139 + ...(token && { Authorization: `Bearer ${token}` }), 178 140 }, 179 141 body: JSON.stringify({ cols, rows }), 180 142 }); ··· 192 154 const { sessionId } = await response.json(); 193 155 sessionIdRef.current = sessionId; 194 156 195 - const es = new EventSource(`${API_URL}/ssh/stream/${sessionId}`); 196 - eventSourceRef.current = es; 157 + const wsBase = API_URL.replace(/^http/, "ws"); 158 + const params = token ? `?token=${encodeURIComponent(token)}` : ""; 159 + const ws = new WebSocket(`${wsBase}/ssh/${sessionId}/ws${params}`); 160 + wsRef.current = ws; 197 161 198 - es.addEventListener("connected", () => { 162 + ws.onopen = () => { 199 163 instance.focus(); 200 - }); 164 + }; 201 165 202 - es.onmessage = (event) => { 166 + ws.onmessage = (event) => { 203 167 // Data is base64-encoded 204 - const bytes = atob(event.data); 168 + const bytes = atob(event.data as string); 205 169 const arr = new Uint8Array(bytes.length); 206 170 for (let i = 0; i < bytes.length; i++) { 207 171 arr[i] = bytes.charCodeAt(i); ··· 210 174 instance.write(text); 211 175 }; 212 176 213 - es.addEventListener("close", () => { 214 - instance.write("\r\n\x1b[38;5;250mSSH session closed.\x1b[0m\r\n"); 215 - es.close(); 216 - eventSourceRef.current = null; 177 + ws.onclose = (event) => { 178 + if (event.code === 1000) { 179 + instance.write("\r\n\x1b[38;5;250mSSH session closed.\x1b[0m\r\n"); 180 + } else if (wsRef.current) { 181 + instance.write("\r\n\x1b[38;5;203mSSH connection lost.\x1b[0m\r\n"); 182 + } 183 + wsRef.current = null; 217 184 sessionIdRef.current = null; 218 185 onClose(); 219 - }); 186 + }; 220 187 221 - es.addEventListener("error", () => { 222 - // EventSource error can be a reconnect or a real error 223 - if (es.readyState === EventSource.CLOSED) { 224 - instance.write("\r\n\x1b[38;5;203mSSH connection lost.\x1b[0m\r\n"); 225 - eventSourceRef.current = null; 226 - sessionIdRef.current = null; 227 - } 228 - }); 188 + ws.onerror = () => { 189 + // onclose fires right after onerror, so just let it handle cleanup 190 + }; 229 191 } catch (err: any) { 230 192 instance.write( 231 193 `\x1b[38;5;203mFailed to connect: ${err.message}\x1b[0m\r\n`, ··· 237 199 238 200 // Forward keyboard input to SSH 239 201 const dataDisposable = instance.onData((data) => { 240 - sendInput(data); 202 + if (wsRef.current?.readyState === WebSocket.OPEN) { 203 + wsRef.current.send(data); 204 + } 241 205 }); 242 206 243 207 return () => { ··· 246 210 dataDisposable.dispose(); 247 211 resizeDisposable.dispose(); 248 212 249 - // Cleanup SSH session 250 - if (eventSourceRef.current) { 251 - eventSourceRef.current.close(); 252 - eventSourceRef.current = null; 213 + if (wsRef.current) { 214 + wsRef.current.onclose = null; 215 + wsRef.current.close(); 216 + wsRef.current = null; 253 217 } 254 218 if (sessionIdRef.current) { 255 219 // Fire-and-forget disconnect 220 + const token = localStorage.getItem("token"); 256 221 fetch(`${API_URL}/ssh/disconnect/${sessionIdRef.current}`, { 257 222 method: "DELETE", 258 223 headers: { 259 - Authorization: `Bearer ${localStorage.getItem("token")}`, 224 + ...(token && { Authorization: `Bearer ${token}` }), 260 225 }, 261 226 }).catch(() => {}); 262 227 sessionIdRef.current = null; 263 228 } 264 229 }; 265 - }, [instance, sendInput, sendResize, sandboxId, onClose]); 230 + }, [instance, sandboxId, onClose]); 266 231 267 232 return ( 268 233 <div
+40 -73
apps/web/src/components/terminal/TtyTerminal.tsx
··· 64 64 pty?: boolean; 65 65 } 66 66 67 - function authHeaders(): Record<string, string> { 68 - const token = localStorage.getItem("token"); 69 - return token ? { Authorization: `Bearer ${token}` } : {}; 70 - } 71 - 72 67 function TerminalContent({ 73 68 isDarkMode, 74 69 sandboxId, ··· 76 71 pty, 77 72 }: TerminalContentProps) { 78 73 // Stable refs so the main effect never re-runs because these changed 79 - const eventSourceRef = useRef<EventSource | null>(null); 74 + const wsRef = useRef<WebSocket | null>(null); 80 75 const fitAddonRef = useRef<FitAddon | null>(null); 81 76 const sandboxIdRef = useRef(sandboxId); 82 77 const onCloseRef = useRef(onClose); 83 78 84 - const BASE_URL = `${API_URL}/${pty ? "pty" : "tty"}`; 79 + const type = pty ? "pty" : "tty"; 85 80 86 81 // Keep refs in sync with the latest props after every render, 87 82 // without listing them as effect deps (which would retrigger connect). ··· 138 133 }; 139 134 window.addEventListener("resize", handleResize); 140 135 141 - // --- Helper functions that read latest values from refs --- 142 - const sendInput = async (data: string) => { 143 - try { 144 - await fetch(`${BASE_URL}/${sandboxIdRef.current}/input`, { 145 - method: "POST", 146 - headers: { "Content-Type": "text/plain", ...authHeaders() }, 147 - body: data, 148 - }); 149 - } catch { 150 - // session may have closed 136 + const resizeDisposable = instance.onResize(({ cols, rows }) => { 137 + if (wsRef.current?.readyState === WebSocket.OPEN) { 138 + wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); 151 139 } 152 - }; 153 - 154 - const sendResize = async (cols: number, rows: number) => { 155 - try { 156 - await fetch(`${BASE_URL}/${sandboxIdRef.current}/resize`, { 157 - method: "POST", 158 - headers: { "Content-Type": "application/json", ...authHeaders() }, 159 - body: JSON.stringify({ cols, rows }), 160 - }); 161 - } catch { 162 - // ignore 163 - } 164 - }; 165 - 166 - const resizeDisposable = instance.onResize(({ cols, rows }) => { 167 - sendResize(cols, rows); 168 140 }); 169 141 170 - // --- SSE stream --- 142 + // --- WebSocket stream --- 171 143 const connect = () => { 172 144 // Guard: don't open a second connection if one is already live. 173 145 // This covers React 18 Strict Mode double-invocation and any accidental 174 146 // re-render that manages to reach this code path. 175 - if ( 176 - eventSourceRef.current && 177 - eventSourceRef.current.readyState !== EventSource.CLOSED 178 - ) { 147 + if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { 179 148 return; 180 149 } 181 150 182 151 instance.write(`\x1b[35mConnecting to terminal...\x1b[0m\r\n`); 183 152 184 - const es = new EventSource(`${BASE_URL}/${sandboxIdRef.current}/stream`); 185 - eventSourceRef.current = es; 153 + const token = localStorage.getItem("token"); 154 + const wsBase = API_URL.replace(/^http/, "ws"); 155 + const params = token ? `?token=${encodeURIComponent(token)}` : ""; 156 + const ws = new WebSocket( 157 + `${wsBase}/${type}/${sandboxIdRef.current}/ws${params}`, 158 + ); 159 + wsRef.current = ws; 186 160 187 - es.addEventListener("open", () => { 161 + ws.onopen = () => { 188 162 // Clear the "Connecting…" line and focus the terminal 189 163 instance.write("\r\x1b[K"); 190 164 instance.focus(); 191 165 // Sync terminal dimensions immediately after connecting 192 - sendResize(instance.cols, instance.rows); 193 - }); 166 + ws.send( 167 + JSON.stringify({ type: "resize", cols: instance.cols, rows: instance.rows }), 168 + ); 169 + }; 194 170 195 - // The server emits `event: output` with `data: { "data": "..." }` 196 - es.addEventListener("output", (event: MessageEvent) => { 197 - try { 198 - const { data } = JSON.parse(event.data) as { data: string }; 199 - instance.write(data); 200 - } catch { 201 - instance.write(event.data); 202 - } 203 - }); 171 + ws.onmessage = (event) => { 172 + instance.write(event.data as string); 173 + }; 204 174 205 - // The server emits `event: exit` when the process terminates 206 - es.addEventListener("exit", (event: MessageEvent) => { 207 - try { 208 - const { code } = JSON.parse(event.data) as { code: number }; 175 + ws.onclose = (event) => { 176 + if (event.code === 1000) { 209 177 instance.write( 210 - `\r\n\x1b[38;5;250mProcess exited with code ${code}.\x1b[0m\r\n`, 178 + `\r\n\x1b[38;5;250mProcess exited.\x1b[0m\r\n`, 211 179 ); 212 - } catch { 213 - instance.write(`\r\n\x1b[38;5;250mProcess exited.\x1b[0m\r\n`); 214 - } 215 - es.close(); 216 - eventSourceRef.current = null; 217 - onCloseRef.current(); 218 - }); 219 - 220 - es.onerror = () => { 221 - if (es.readyState === EventSource.CLOSED) { 180 + wsRef.current = null; 181 + onCloseRef.current(); 182 + } else if (wsRef.current) { 222 183 instance.write( 223 184 "\r\n\x1b[38;5;203mTerminal connection lost.\x1b[0m\r\n", 224 185 ); 225 - eventSourceRef.current = null; 186 + wsRef.current = null; 226 187 } 227 - // readyState === CONNECTING means the browser is auto-retrying — let it. 188 + }; 189 + 190 + ws.onerror = () => { 191 + // onclose fires right after onerror, so just let it handle cleanup 228 192 }; 229 193 }; 230 194 ··· 232 196 233 197 // Forward keyboard input to the TTY 234 198 const dataDisposable = instance.onData((data) => { 235 - sendInput(data); 199 + if (wsRef.current?.readyState === WebSocket.OPEN) { 200 + wsRef.current.send(data); 201 + } 236 202 }); 237 203 238 204 return () => { ··· 241 207 dataDisposable.dispose(); 242 208 resizeDisposable.dispose(); 243 209 244 - if (eventSourceRef.current) { 245 - eventSourceRef.current.close(); 246 - eventSourceRef.current = null; 210 + if (wsRef.current) { 211 + wsRef.current.onclose = null; 212 + wsRef.current.close(); 213 + wsRef.current = null; 247 214 } 248 215 }; 249 216 }, [instance]);