feat: add Blob serialization support over RPC#155
feat: add Blob serialization support over RPC#155G4brym wants to merge 4 commits intocloudflare:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 0ebae8c The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
All contributors have signed the CLA ✍️ ✅ |
commit: |
|
I have read the CLA Document and I hereby sign the CLA |
fb28cd8 to
539e59a
Compare
Encode Blob as ["blob", contentType, ["readable", pipeId]] on the wire, mirroring the existing Firefox Request body technique so devaluateImpl() stays synchronous and e-order is preserved. On the receive side, bytes are collected from the pipe stream via a new LocatedBlobPromise mechanism in RpcPayload.deliverTo(), substituting the real Blob before user code runs — analogous to how LocatedPromise handles RpcPromise substitution. ## Core changes - src/core.ts: add "blob" TypeForRpc, BLOB_PROTOTYPE detection, LocatedBlobPromise type, RpcPayload blobPromises field, forEvaluate(), deliverTo(), dispose(), deepCopy(), disposeImpl(), ignoreUnhandledRejectionsImpl(), and followPath() coverage - src/serialize.ts: streamToBlob() helper, Devaluator encode case, Evaluator decode case with sync fast-path for bytes/string content and async LocatedBlobPromise path for ReadableStream content - src/types.d.ts: add Blob to BaseType - .changeset/blob-rpc-support.md: minor bump changeset ## Tests - blob serialization: decode-only unit tests via deserialize() covering bytes content, string content, empty blob, malformed wire values, and unsupported content expression types - Blob over RPC: full round-trip tests including binary data, MIME type preservation, empty blob, compound return values, multiple blobs in one call, blob array returns, no MIME type, all 256 byte values, 1 MB large blob, loopback (local) stub, blob alongside ReadableStream, dispose without reading, and an e-order regression test verifying that blob call messages are dispatched synchronously
539e59a to
a08d1a8
Compare
|
Hmm, it looks like you went straight for the ReadableStream approach instead of the Uint8Array approach I recommended. This approach presents additional complexities. On the receiving end, the RPC system will wait for the blob bytes to arrive before delivering anything to the app. This means the call is delivered out-of-order, technically a violation of e-order. Of course, this is the norm when delivering payloads containing promises, so we could just say "blobs behave like promises, they may delay delivery". But I'm not totally sure if we have to make that compromise. Reading from a Either way, I do think that for small blobs, it is not worth the overhead for streaming, and we really should still encode it as |
Small blobs (≤ 64 KB) are now encoded as ["blob", type, ["bytes", base64]] directly in the call message. The receiver reconstructs the Blob synchronously from the inline bytes — no delivery delay, preserving receive-side e-order. Large blobs (> 64 KB) continue using the pipe approach: ["blob", type, ["readable", pipeId]]. For the inline path, blob bytes must be pre-read asynchronously via blob.arrayBuffer() before the call message is sent. This is handled by returning a PromiseStubHook from RpcImportHook.call(), which defers the entire sendCall() (import entry + push message) until bytes are ready — keeping protocol IDs in sync. Send-side e-order relative to subsequent synchronous calls is not preserved, matching the existing semantics of promise-pipelined calls. The async path is skipped when args also contain ReadableStream or WritableStream (ensureDeepCopied() cannot deep-copy streams). In that case, the small blob falls through to the pipe approach. Also simplifies the large blob encode path: uses blob.stream() directly instead of wrapping blob.arrayBuffer() in a manual ReadableStream (the Firefox body hack pattern). ## serialize.ts - Add BLOB_INLINE_THRESHOLD (64 KB), hasSmallBlobImpl, hasStreamImpl, collectBlobBytes tree-walk helpers - Add static methods: Devaluator.hasSmallBlob(), .hasStream(), .preReadBlobs() - Add blobBytes param to Devaluator constructor and devaluate() - Update blob encode case: check blobBytes map first, fall through to simplified blob.stream() pipe path ## rpc.ts - Update RpcImportHook.call(): detect small blobs → PromiseStubHook path (unless args contain streams) - Update sendCall(): accept optional blobBytes, pass to devaluate() - Update ensureResolvingExport() resolve path: pre-read small blobs before encoding return values ## Tests - Rework e-order test: verify blob push is deferred (2 sync messages before microtask yield, 3 after) - Add wire-format test: small blob uses ["bytes", base64] on wire - Add wire-format test: large blob uses ["readable", pipeId] on wire
blob.arrayBuffer() is a microtask in Node/workerd but a macrotask in browsers (Chromium, Firefox, WebKit). Two tests assumed the blob read resolved within pumpMicrotasks(), which only drains microtasks. - e-order test: remove the fragile pendingCount==3 assertion after pumpMicrotasks(). The meaningful assertion (pendingCount==2, proving the blob push was NOT sent synchronously) already passes everywhere. The test still validates correctness via await Promise.all(). - wire-format test: replace pumpMicrotasks() with setTimeout(100ms) to give browsers a full event loop turn for blob.arrayBuffer().
|
Hey @kentonv, ive updated the pr to encode small blobs as Uint8Array, because encoding calls |
Summary
Adds `Blob` as a first-class serializable type — `Blob` objects can now be passed as RPC call arguments and return values, with MIME type preserved across the wire.
Wire format
Encoding is threshold-based:
Small blobs (≤ 64 KB): bytes are read upfront and encoded inline in the call message:
```
["blob", "image/png", ["bytes", "aGVsbG8h"]]
```
The receiver reconstructs the Blob synchronously — no delivery delay, receive-side e-order preserved.
Large blobs (> 64 KB): bytes stream through a pipe:
```
["blob", "image/png", ["readable", 5]]
```
The call message is dispatched synchronously; bytes follow asynchronously. This mirrors the existing technique used for Firefox `Request` bodies.
The decoder accepts both forms, as well as a plain string content expression.
E-order notes
For small blobs, `blob.arrayBuffer()` must complete before the call message is sent (there is no synchronous Blob read API in any JS environment). The implementation handles this by returning a `PromiseStubHook` from `RpcImportHook.call()`, deferring the entire `sendCall()` until bytes are ready. The user gets their `RpcPromise` synchronously, but the wire message may be sent after subsequent synchronous calls — the same semantics as promise-pipelined calls.
For large blobs, the call message is sent synchronously (send-side e-order preserved), but the receiver waits for bytes before delivering the call to the application (same semantics as a payload containing a promise).
Changes
CI results
Not yet supported