Skip to content

feat: add Blob serialization support over RPC#155

Open
G4brym wants to merge 4 commits intocloudflare:mainfrom
G4brym:feat/blob-serialization
Open

feat: add Blob serialization support over RPC#155
G4brym wants to merge 4 commits intocloudflare:mainfrom
G4brym:feat/blob-serialization

Conversation

@G4brym
Copy link
Member

@G4brym G4brym commented Mar 19, 2026

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

  • `src/core.ts` — `"blob"` `TypeForRpc`, `BLOB_PROTOTYPE`, `LocatedBlobPromise`, `RpcPayload` field/factory/delivery/disposal, all exhaustive switch coverage
  • `src/serialize.ts` — `BLOB_INLINE_THRESHOLD`, `hasSmallBlob()`/`hasStream()`/`preReadBlobs()` helpers, `Devaluator` encode case (inline path + simplified `blob.stream()` pipe path), `Evaluator` decode case, `streamToBlob()` helper
  • `src/rpc.ts` — `RpcImportHook.call()` async path for small blobs via `PromiseStubHook`; `sendCall()` accepts optional `blobBytes`; `ensureResolvingExport()` pre-reads blobs before encoding return values
  • `src/types.d.ts` — `Blob` added to `BaseType`
  • `README.md` — `Blob` added to pass-by-value types list
  • `tests/test-util.ts` — `echoBlob()` on `TestTarget`
  • `tests/index.test.ts` — decode unit tests, 13 RPC round-trip tests, wire-format verification tests, e-order regression test
  • `.changeset/blob-rpc-support.md` — minor bump

CI results

Environment Result
node
workerd
browsers (chromium, firefox, webkit)
`test:types` (tsc)
build (DTS)

Not yet supported

  • `File` (subclass of `Blob`) — its prototype differs from `Blob.prototype` so it hits `"unsupported"`. Would need a `["file", name, lastModified, type, content]` wire format.
  • Blobs inside `map()` callbacks — `MapBuilder.createPipe()` already throws `"Cannot send ReadableStream inside a mapper function."`, which covers this naturally.
  • Blobs mixed with streams in the same call args — falls back to the pipe path for the blob (the async pre-read path requires `ensureDeepCopied()`, which cannot deep-copy streams).

@changeset-bot
Copy link

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: 0ebae8c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
capnweb Minor

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

@github-actions
Copy link
Contributor

github-actions bot commented Mar 19, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 19, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@155

commit: 0ebae8c

@G4brym
Copy link
Member Author

G4brym commented Mar 19, 2026

I have read the CLA Document and I hereby sign the CLA

github-actions bot added a commit that referenced this pull request Mar 19, 2026
@G4brym G4brym force-pushed the feat/blob-serialization branch 2 times, most recently from fb28cd8 to 539e59a Compare March 19, 2026 21:16
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
@G4brym G4brym force-pushed the feat/blob-serialization branch from 539e59a to a08d1a8 Compare March 19, 2026 21:19
@G4brym G4brym marked this pull request as ready for review March 19, 2026 21:25
@kentonv
Copy link
Member

kentonv commented Mar 19, 2026

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 Blob is asynchronous, so in theory we could construct a Blob on the receiving end and deliver it before all the bytes have arrived, then we wait for the bytes when the app actually tries to read the Blob. However, I'm not sure the JS Blob API provides any way to construct a Blob from a stream, only from already-available in-memory bytes. So perhaps we can't really achieve this, absent some new constructor API? I'm not really sure.

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 Uint8Array. Probably anything up to 64k at least should not be streamed.

G4brym added 3 commits March 23, 2026 17:11
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().
@G4brym
Copy link
Member Author

G4brym commented Mar 23, 2026

Hey @kentonv, ive updated the pr to encode small blobs as Uint8Array, because encoding calls blob.arrayBuffer() and this returns a promise, it means smaller blobs will be out of e-order on the sender

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants