From 979d93d3f691b0eeefe5443011a4e41be2426c1b Mon Sep 17 00:00:00 2001 From: Gabriel Massadas Date: Mon, 15 Jun 2026 19:36:00 +0100 Subject: [PATCH 1/2] JSRPC: Implement serialization of Blob. Registers Blob as a serializable host type (new `blob` SerializationTag), so it can be passed over built-in JSRPC and cloned via structuredClone() / postMessage(), matching the spec which lists Blob as a serializable object. It is serialized as its MIME type followed by its raw bytes. The File subclass is intentionally left non-serializable. --- src/workerd/api/blob.c++ | 25 +++++++++++++++++++ src/workerd/api/blob.h | 8 ++++++ src/workerd/api/tests/global-scope-test.js | 21 +++++++++++++++- src/workerd/api/tests/js-rpc-test.js | 20 +++++++++++++++ src/workerd/api/tests/messageport-test.js | 19 +++++++------- src/workerd/io/worker-interface.capnp | 3 +++ .../server/tests/python/python-rpc/worker.py | 8 ++++-- 7 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/workerd/api/blob.c++ b/src/workerd/api/blob.c++ index a1388f556af..0e59808e107 100644 --- a/src/workerd/api/blob.c++ +++ b/src/workerd/api/blob.c++ @@ -347,4 +347,29 @@ jsg::Ref File::constructor( return js.alloc(kj::mv(name), kj::mv(type), lastModified); } +void Blob::serialize(jsg::Lock& js, jsg::Serializer& serializer) { + serializer.writeLengthDelimited(type); + serializer.writeLengthDelimited(data); +} + +jsg::Ref Blob::deserialize( + jsg::Lock& js, rpc::SerializationTag tag, jsg::Deserializer& deserializer) { + auto type = deserializer.readLengthDelimitedString(); + + // Guard against a bogus length triggering a huge allocation. + uint64_t size = deserializer.readRawUint64(); + auto limit = Worker::Isolate::from(js).getLimitEnforcer().getBlobSizeLimit(); + JSG_REQUIRE(size <= limit, DOMDataCloneError, "Deserialization failed: Blob size exceeds limit (", + size, ")"); + + if (size == 0) { + return js.alloc(kj::mv(type)); + } + + auto bytes = deserializer.readRawBytes(size); + auto u8 = jsg::JsUint8Array::create(js, size); + u8.asArrayPtr().copyFrom(bytes); + return js.alloc(js, jsg::JsBufferSource(u8), kj::mv(type)); +} + } // namespace workerd::api diff --git a/src/workerd/api/blob.h b/src/workerd/api/blob.h index a2556c6805f..02bdf1a4ae6 100644 --- a/src/workerd/api/blob.h +++ b/src/workerd/api/blob.h @@ -5,7 +5,9 @@ #pragma once #include +#include #include +#include namespace workerd::api { @@ -78,6 +80,12 @@ class Blob: public jsg::Object { }); } + // Serialized as MIME type + raw bytes. + void serialize(jsg::Lock& js, jsg::Serializer& serializer); + static jsg::Ref deserialize( + jsg::Lock& js, rpc::SerializationTag tag, jsg::Deserializer& deserializer); + JSG_SERIALIZABLE(rpc::SerializationTag::BLOB); + void visitForMemoryInfo(jsg::MemoryTracker& tracker) const { KJ_SWITCH_ONEOF(ownData) { KJ_CASE_ONEOF(_, Empty) {} diff --git a/src/workerd/api/tests/global-scope-test.js b/src/workerd/api/tests/global-scope-test.js index cea7e285ee3..c0114328197 100644 --- a/src/workerd/api/tests/global-scope-test.js +++ b/src/workerd/api/tests/global-scope-test.js @@ -212,7 +212,7 @@ export const unhandledRejectionHandler4 = { }; export const structuredClone = { - test() { + async test() { { strictEqual(globalThis.structuredClone('hello'), 'hello'); } @@ -307,6 +307,25 @@ export const structuredClone = { strictEqual(cloned.get('bar'), 'abc'); } + // Blob is a serializable platform object. + { + let orig = new Blob(['abc', 'def'], { type: 'text/plain' }); + let cloned = globalThis.structuredClone(orig); + ok(cloned instanceof Blob); + notStrictEqual(cloned, orig); + strictEqual(cloned.type, 'text/plain'); + strictEqual(cloned.size, orig.size); + strictEqual(await cloned.text(), 'abcdef'); + } + + // An empty Blob round-trips too. + { + let cloned = globalThis.structuredClone(new Blob([])); + ok(cloned instanceof Blob); + strictEqual(cloned.size, 0); + strictEqual(cloned.type, ''); + } + // Verify that trying to serialize a non-serializable API type throws. throws(() => globalThis.structuredClone(new TextEncoder()), { name: 'DataCloneError', diff --git a/src/workerd/api/tests/js-rpc-test.js b/src/workerd/api/tests/js-rpc-test.js index 4558cd517fa..9f940ad5466 100644 --- a/src/workerd/api/tests/js-rpc-test.js +++ b/src/workerd/api/tests/js-rpc-test.js @@ -1709,6 +1709,26 @@ export let serializeHttpTypes = { }, }; +export let serializeBlob = { + async test(controller, env, ctx) { + // Blob round-trips over RPC, both directly and nested in a plain object. + let blob = await env.MyService.roundTrip( + new Blob(['hello ', 'world'], { type: 'text/plain' }) + ); + assert.ok(blob instanceof Blob); + assert.strictEqual(blob.type, 'text/plain'); + assert.strictEqual(await blob.text(), 'hello world'); + + let doc = await env.MyService.roundTrip({ + name: 'doc.txt', + blob: new Blob(['contents']), + }); + assert.strictEqual(doc.name, 'doc.txt'); + assert.ok(doc.blob instanceof Blob); + assert.strictEqual(await doc.blob.text(), 'contents'); + }, +}; + // Test that exceptions thrown from async native functions have a proper stack trace. (This is // not specific to RPC but RPC is a convenient place to test it since we can easily define the // callee to throw an exception.) diff --git a/src/workerd/api/tests/messageport-test.js b/src/workerd/api/tests/messageport-test.js index dccce468b81..64f3d049b8b 100644 --- a/src/workerd/api/tests/messageport-test.js +++ b/src/workerd/api/tests/messageport-test.js @@ -107,15 +107,16 @@ export const simple5 = { // Refs: https://github.com/web-platform-tests/wpt/blob/master/webmessaging/Channel_postMessage_DataCloneErr.any.js export const postMessageBlob = { - test() { - // Per the spec, Blob is a serializable object, but we don't implement it as such currently. - // So attempting to post a blob should throw an error. - const { port1 } = new MessageChannel(); - throws(() => port1.postMessage(new Blob([''])), { - code: 25, // DATA_CLONE_ERR, - name: 'DataCloneError', - message: /Could not serialize/, - }); + async test() { + // Per the spec, Blob is a serializable object, so it round-trips through postMessage. + const { port1, port2 } = new MessageChannel(); + const { promise, resolve } = Promise.withResolvers(); + port2.onmessage = (event) => resolve(event.data); + port1.postMessage(new Blob(['hello'], { type: 'text/plain' })); + const received = await promise; + ok(received instanceof Blob); + strictEqual(received.type, 'text/plain'); + strictEqual(await received.text(), 'hello'); }, }; diff --git a/src/workerd/io/worker-interface.capnp b/src/workerd/io/worker-interface.capnp index 0029fb00766..c11b3984fad 100644 --- a/src/workerd/io/worker-interface.capnp +++ b/src/workerd/io/worker-interface.capnp @@ -481,6 +481,9 @@ enum SerializationTag { # # Similar to serviceStub, this refers to the entrypoint of a Worker that can be instantiated # anywhere and any time, and thus can be persisted and used in `env` and `ctx.props`, etc. + + blob @13; + # A Blob, serialized as its MIME type followed by its raw bytes. } enum StreamEncoding { diff --git a/src/workerd/server/tests/python/python-rpc/worker.py b/src/workerd/server/tests/python/python-rpc/worker.py index 55320463234..f7c3b7d3d61 100644 --- a/src/workerd/server/tests/python/python-rpc/worker.py +++ b/src/workerd/server/tests/python/python-rpc/worker.py @@ -237,12 +237,16 @@ async def test(ctrl, env, ctx): py_response2 = await env.PythonRpc.handle_response(js.Response.new("a JS response")) assert await py_response2.text() == "a JS response" + # - Verify that a Blob can be round-tripped. + blob = await env.PythonRpc.identity(Blob("print(42)", content_type="text/python")) + assert isinstance(blob, Blob) + assert blob.content_type == "text/python" + assert await blob.text() == "print(42)" + # Verify that sending unsupported types fails. data_clone_regex = "^DataCloneError" with assertRaisesRegex(JsException, data_clone_regex): await env.PythonRpc.one_arg(CustomType(42)) - with assertRaisesRegex(JsException, data_clone_regex): - await env.PythonRpc.one_arg(Blob("print(42)", content_type="text/python")) with assertRaisesRegex(JsException, data_clone_regex): await env.PythonRpc.identity(complex(1.23)) with assertRaises(TypeError): From adea49b73cb97cb5012a594df0dd33c57e16aa31 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:26:08 +0100 Subject: [PATCH 2/2] Update src/workerd/api/blob.c++ Co-authored-by: James M Snell --- src/workerd/api/blob.c++ | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/workerd/api/blob.c++ b/src/workerd/api/blob.c++ index 0e59808e107..ecb650df07a 100644 --- a/src/workerd/api/blob.c++ +++ b/src/workerd/api/blob.c++ @@ -367,8 +367,7 @@ jsg::Ref Blob::deserialize( } auto bytes = deserializer.readRawBytes(size); - auto u8 = jsg::JsUint8Array::create(js, size); - u8.asArrayPtr().copyFrom(bytes); + auto u8 = jsg::JsUint8Array::create(js, bytes); return js.alloc(js, jsg::JsBufferSource(u8), kj::mv(type)); }