-
Notifications
You must be signed in to change notification settings - Fork 46
Ws transport #253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Ws transport #253
Conversation
|
commit: |
🐳 Docker Image PublishedFROM cloudflare/sandbox:0.0.0-pr-253-be15028Version: You can use this Docker image with the preview package from this PR. |
Documents the new WebSocket transport feature that enables multiplexing SDK operations over a single WebSocket connection to reduce sub-request count in Workers. Changes: - Add comprehensive WebSocket transport concept page - Document useWebSocket option in sandbox configuration - Update architecture overview to mention transport options Related PR: cloudflare/sandbox-sdk#253 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
- WSTransport: Add fetch-based WebSocket connection for Workers/DO context - Uses containerFetch with upgrade headers instead of raw new WebSocket() - Required because DOs cannot use direct WebSocket() connections to containers - Transport: Pass stub and port to WSTransport for proper routing - CommandClient: Use doStreamFetch for streaming (supports both HTTP and WS) - comprehensive-workflow.test.ts: Run all tests with both HTTP and WebSocket transport
Updated test files to run with both HTTP and WebSocket transport modes: - comprehensive-workflow.test.ts - file-operations-workflow.test.ts - streaming-operations-workflow.test.ts - environment-workflow.test.ts - git-clone-workflow.test.ts - process-lifecycle-workflow.test.ts - process-readiness-workflow.test.ts - keepalive-workflow.test.ts - code-interpreter-workflow.test.ts - build-test-workflow.test.ts Each test suite now runs twice - once with HTTP transport (default) and once with WebSocket transport (X-Use-WebSocket header). This validates that the WebSocket transport works identically to HTTP for all SDK operations.
…ileClient - ProcessClient.streamProcessLogs: use doStreamFetch instead of doFetch - FileClient.readFileStream: use doStreamFetch instead of doFetch This ensures proper streaming over WebSocket transport.
- base-client.ts: Add method parameter to doStreamFetch for GET/POST support - transport.ts: Update requestStream and httpRequestStream for GET/POST - process-client.ts: Use doStreamFetch with GET for process log streaming - file-client.ts: Use doStreamFetch for file streaming - interpreter-client.ts: Use doStreamFetch for code execution streaming - interpreter.ts: Use doStreamFetch for runCodeStream - process-lifecycle-workflow.test.ts: Accept 'already exposed' in port test All 105 e2e tests now pass with both HTTP and WebSocket transport!
7d6c6f5 to
611ca4b
Compare
🐳 Docker Images PublishedDefault (no Python): FROM cloudflare/sandbox:0.0.0-pr-253-ffde886With Python: FROM cloudflare/sandbox:0.0.0-pr-253-ffde886-pythonVersion: Use the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude Code Review
The WebSocket transport feature is architecturally sound and addresses a real need. However, 5 critical issues from the previous review remain unresolved, plus several new issues were discovered.
Critical Issues (Must Fix)
Issue #1: SSE parsing data loss bug (ws-handler.ts:286)
buffer.indexOf('', processedIndex)returnsprocessedIndex, breaking buffer tracking- Will cause stream corruption for multi-line SSE events
- See inline comment for fix
Issue #4: Send failures leave connections hung (ws-handler.ts:312)
- When
ws.send()fails, error is logged but connection stays open - Client hangs waiting for response that never arrives
- Need to close connection and abort processing
Issue #7: Buffer overflow DoS (ws-handler.ts:220)
- Unbounded buffer growth in SSE parsing
- If parsing fails or stream lacks delimiters, memory grows without limit
- Add MAX_BUFFER_SIZE check
Important Issues (Should Fix)
Issue #2: Module-level mutable state (index.ts:67)
wsHandlerviolates DI pattern used throughout codebase- Makes testing harder, creates fragile initialization order
- Move into
createApplication()return value
Issue #3: Unsafe type casting (index.ts:78)
as unknown as ServerWebSocket<WSData>bypasses type safety- Violates CLAUDE.md "never use any" rule
- Define proper type interface for Bun's WebSocket
Issue #6: Connection race condition (ws-transport.ts:108)
- Two simultaneous
connect()calls can create duplicate connections - Set
connectPromisebefore initiating connection
Issue #9: Incomplete cleanup (ws-transport.ts:488)
handleCloserejects promises but doesn't close stream controllers- Client-side streams left in error state without proper closure
Testing Gaps
- No E2E tests for WebSocket transport (tests cover exposing WS ports, not WS transport)
- No tests for send failures, mid-stream errors, buffer overflow
- Missing SSE parsing edge case tests (would have caught Issue #1)
Security Findings
- DoS via buffer overflow (Issue #7)
- No limit on concurrent pending requests (memory exhaustion)
- No size limit on incoming JSON messages
Verdict
Request changes. The bugs aren't edge cases - they'll hit production. The SSE parsing bug will corrupt streams, send failures will hang clients, and buffer overflow is a security issue. These are fixable but need attention before merge.
See inline comments for detailed fixes.
| data: currentEvent.data.join('\n') | ||
| }); | ||
| currentEvent = { data: [] }; | ||
| processedIndex = buffer.indexOf(line, processedIndex) + line.length + 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: SSE parsing bug causes data loss
When line === '', buffer.indexOf(line, processedIndex) returns processedIndex (empty string matches at current position). This makes processedIndex = processedIndex + 0 + 1, only advancing 1 char, which breaks buffer tracking.
This bug is repeated at lines 292, 295, 297.
Fix: Replace with split-based approach:
private parseSSEEvents(buffer: string): {
events: Array<{ event?: string; data: string }>;
remaining: string;
} {
const events: Array<{ event?: string; data: string }> = [];
const blocks = buffer.split('\n\n');
const remaining = blocks.pop() || '';
for (const block of blocks) {
const lines = block.split('\n');
let event: string | undefined;
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith('event:')) event = line.substring(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.substring(5).trim());
}
if (dataLines.length > 0) {
events.push({ event, data: dataLines.join('\n') });
}
}
return { events, remaining };
}| } | ||
|
|
||
| // Decode chunk and add to buffer | ||
| buffer += decoder.decode(value, { stream: true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Unbounded buffer growth (DoS vector)
If parseSSEEvents has bugs (Issue #1) or stream lacks proper delimiters, buffer grows unboundedly. Add limit:
const MAX_BUFFER_SIZE = 1024 * 1024; // 1MB
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_BUFFER_SIZE) {
this.sendError(ws, requestId, 'STREAM_ERROR',
'Buffer overflow: stream too large without delimiters', 413);
reader.releaseLock();
return;
}| */ | ||
| private send(ws: ServerWebSocket<WSData>, message: WSServerMessage): void { | ||
| try { | ||
| ws.send(JSON.stringify(message)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Send failures leave client hung
When ws.send() fails, error is logged but connection stays open. Client waits for response that never arrives (until 2min timeout).
Fix: Return status and close on failure:
private send(ws: ServerWebSocket<WSData>, message: WSServerMessage): boolean {
try {
ws.send(JSON.stringify(message));
return true;
} catch (error) {
this.logger.error('Send failed', error instanceof Error ? error : new Error(String(error)));
try { ws.close(1011, 'Send failed'); } catch {}
return false;
}
}
// In handleStreamingResponse:
if (!this.send(ws, chunk)) {
reader.releaseLock();
return;
}| const app = await createApplication(); | ||
|
|
||
| // Initialize WebSocket handler with the router | ||
| wsHandler = new WebSocketHandler(app.router, logger); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue: Module-level mutable state violates DI pattern
wsHandler declared at module level but initialized here. This creates fragile initialization order and makes testing harder. Move into createApplication() return:
return {
fetch: async (req, server) => { ... },
router,
wsHandler: new WebSocketHandler(router, logger)
};Then use app.wsHandler in websocket handlers below.
| async message() { | ||
| // WebSocket functionality can be added here in the future | ||
| open(ws) { | ||
| wsHandler?.onOpen(ws as unknown as ServerWebSocket<WSData>); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue: Unsafe type cast bypasses type safety
as unknown as ServerWebSocket<WSData> violates CLAUDE.md "never use any" rule. Define proper type:
interface BunWebSocket {
data: WSData;
send(message: string): void;
close(code?: number, reason?: string): void;
readyState: number;
}
// Then:
open(ws: BunWebSocket) {
app.wsHandler.onOpen(ws);
}Update WebSocketHandler signature to match.
| return this.connectPromise; | ||
| } | ||
|
|
||
| this.state = 'connecting'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue: Race condition in connection state
If two requests call connect() simultaneously, both can pass the if (!this.connectPromise) check before either sets it, creating duplicate connections.
Fix: Set promise before starting:
if (this.connectPromise) {
return this.connectPromise;
}
this.connectPromise = this.doConnect();
try {
await this.connectPromise;
} finally {
this.connectPromise = null;
}Extract connection logic to private async doConnect().
| this.ws = null; | ||
|
|
||
| // Reject all pending requests | ||
| for (const [id, pending] of this.pendingRequests) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue: Incomplete cleanup for streaming requests
handleClose rejects promises but doesn't close stream controllers. Streams are left in error state without proper closure.
Fix:
for (const [id, pending] of this.pendingRequests) {
if (pending.streamController) {
try {
pending.streamController.error(error);
} catch {}
}
pending.reject(error);
}| } | ||
|
|
||
| // Get the WebSocket from the response (Workers-specific API) | ||
| const ws = (response as unknown as { webSocket?: WebSocket }).webSocket; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: More unsafe casting
Define proper type for Workers WebSocket upgrade:
interface WorkersWebSocketUpgradeResponse extends Response {
webSocket: WebSocket & { accept: () => void };
}
const wsResponse = response as WorkersWebSocketUpgradeResponse;
if (!wsResponse.webSocket) throw new Error('No WebSocket in upgrade response');
wsResponse.webSocket.accept();
this.ws = wsResponse.webSocket;
Adds a WS transport as an alternative to HTTP for all sandbox operations. When running inside workers/DOs this lets you multiplex everything over a single WS connection instead of making individual HTTP requests for each file op, command, etc. which counted towards sub-request limits.
The DO keeps a persistent WS connection to the container and routes all SDK calls through it. From the sdk side you just pass
useWebSocket: truein options it handles everything transparently.