WebSocket client: throw on file- or S3-backed Blobs in send(), ping() and pong() instead of sending an empty frame

Closed
RO
1mo ago
+155-1

Problem

  • On the client WebSocket, ws.send(Bun.file("payload.bin")) puts an empty binary frame on the wire and reports nothing: the server's message handler receives a 0-byte message. ws.ping(...) and ws.pong(...) do the same with an empty ping/pong, and S3 blobs (s3.file(...)) and slices of Bun.file() behave the same way.
  • ServerWebSocket.send(Bun.file(...)) throws send cannot send a file- or S3-backed Blob synchronously; await blob.bytes() first for the same input (since #36032), so the two sides of the same API disagree, and the client side loses data silently.
  • Cause: WebSocket::send(JSBlob*), ping(JSBlob*) and pong(JSBlob*) in src/jsc/bindings/webcore/WebSocket.cpp read the payload with Blob__getDataPtr / Blob__getSize (src/runtime/webcore/Blob.rs), which return the blob's in-memory view. A file- or S3-backed blob has no in-memory bytes, so that view is empty and the overloads take their "empty blob" branch, which deliberately sends a 0-byte frame. Nothing distinguishes "empty blob" from "bytes not in memory".

Fix

  • Adds Blob__needsAsyncRead (Blob.rs, declared in blob.h): true when the blob's store is file- or S3-backed, the same predicate ServerWebSocket's blob_payload uses.
  • The three client Blob overloads throw a TypeError with the server's message (send/ping/pong prefix) when it is true. The check sits after the existing readyState checks, so CONNECTING still throws InvalidStateError and CLOSING/CLOSED is still a silent no-op, exactly as for every other payload type (and as ServerWebSocket, which returns 0 when closed before validating the blob).
  • Why throwing is the right fix: these send paths are synchronous, and making send(Bun.file()) actually deliver the bytes would need an ordered send queue in the client (every later send/ping/pong/close has to wait behind the read) plus async failure reporting for the 125-byte ping/pong limit. That is a feature; the bug here is silent data loss, and the merged server-side API already resolved the same question by refusing loudly with a message that says what to do instead. In-memory blobs (including empty ones) are unaffected; the require("ws") client wrapper passes blobs straight through, so with a callback it now reports the error there instead of sending an empty frame.
  • Verified with test/js/web/websocket/websocket-blob.test.ts: three new cases (Bun.file(), Bun.file().slice(), S3Client.file()) assert that send, ping and pong each throw the expected TypeError and that the server received nothing but the trailing sentinel message; a fourth case pins the readyState ordering. The three Blob cases fail on the current release (undefined thrown, binary:0 / ping:0 / pong:0 received) and pass with this change.
  • Also run: test/js/web/websocket/websocket-client.test.ts (37 pass) and the Blob / publish tests in test/js/bun/websocket/websocket-server.test.ts (34 pass) against the debug build.

Background

  • Blob in Bun is backed by a Store, which is one of Bytes (in memory), File (Bun.file(): a path or fd, read on demand) or S3 (an object key, fetched on demand). shared_view() returns the in-memory bytes and is empty for the last two; needs_to_read_file() / is_s3() identify them. S3File is a JSBlob subclass on the C++ side, so it reaches the same Blob overloads.
  • The client WebSocket is WebCore's C++ class; its methods return ExceptionOr<void>, and the bindings turn a returned Exception { TypeError, message } into a thrown JS TypeError. That is how the existing 125-byte RangeError for ping/pong payloads is produced, and this change follows the same pattern.