feat(ext/web): transferable {Readable,Writable,Transform}Stream (#31126)

https://streams.spec.whatwg.org/#rs-transfer
https://streams.spec.whatwg.org/#ws-transfer
https://streams.spec.whatwg.org/#ts-transfer

Remaining test failures are due to our `DOMException` not correctly
being serializable and can be solved in a followup.


```js
// example

const INDEX_HTML = Deno.readTextFileSync("./index.html");
const worker = new Worker("./the_algorithm.js", { type: "module" });
Deno.serve(async (req) => {
  if (req.method === "POST" && req.path === "/the-algorithm") {
    const { port1, port2 } = new MessageChannel();
    worker.postMessage({ stream: req.body, port: port1 }, { transfer: [req.body, port1] });
    const res = await new Promise((resolve) => {
      port1.onmessage = (e) => resolve(e.data);
    });
    return new Response(res);
  }
  if (req.path === "/") {
    return new Response(INDEX_HTML, { "content-type": "text/html" });
  }
  return new Response(null, { status: 404 });
});
```

---------

Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com>
This commit is contained in:
snek 2025-12-08 13:31:43 +01:00 committed by GitHub
parent 642f2a46a6
commit a15cafeb3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 380 additions and 16 deletions

3
.gitignore vendored
View file

@ -45,3 +45,6 @@ Untitled*.ipynb
/.ms-playwright /.ms-playwright
**/.claude/settings.local.json **/.claude/settings.local.json
# pyenv
/.python-version

View file

@ -60,6 +60,7 @@ const {
PromisePrototypeThen, PromisePrototypeThen,
PromiseReject, PromiseReject,
PromiseResolve, PromiseResolve,
PromiseWithResolvers,
RangeError, RangeError,
ReflectHas, ReflectHas,
SafeFinalizationRegistry, SafeFinalizationRegistry,
@ -5157,6 +5158,8 @@ class ReadableStream {
/** @type {Deferred<void>} */ /** @type {Deferred<void>} */
[_isClosedPromise]; [_isClosedPromise];
[core.hostObjectBrand] = "ReadableStream";
/** /**
* @param {UnderlyingSource<R>=} underlyingSource * @param {UnderlyingSource<R>=} underlyingSource
* @param {QueuingStrategy<R>=} strategy * @param {QueuingStrategy<R>=} strategy
@ -6164,6 +6167,8 @@ class TransformStream {
/** @type {WritableStream<I>} */ /** @type {WritableStream<I>} */
[_writable]; [_writable];
[core.hostObjectBrand] = "TransformStream";
/** /**
* @param {Transformer<I, O>} transformer * @param {Transformer<I, O>} transformer
* @param {QueuingStrategy<I>} writableStrategy * @param {QueuingStrategy<I>} writableStrategy
@ -6174,6 +6179,10 @@ class TransformStream {
writableStrategy = { __proto__: null }, writableStrategy = { __proto__: null },
readableStrategy = { __proto__: null }, readableStrategy = { __proto__: null },
) { ) {
if (transformer === _brand) {
this[_brand] = _brand;
return;
}
const prefix = "Failed to construct 'TransformStream'"; const prefix = "Failed to construct 'TransformStream'";
if (transformer !== undefined) { if (transformer !== undefined) {
transformer = webidl.converters.object(transformer, prefix, "Argument 1"); transformer = webidl.converters.object(transformer, prefix, "Argument 1");
@ -6374,6 +6383,8 @@ class WritableStream {
/** @type {Deferred<void>[]} */ /** @type {Deferred<void>[]} */
[_writeRequests]; [_writeRequests];
[core.hostObjectBrand] = "WritableStream";
/** /**
* @param {UnderlyingSink<W>=} underlyingSink * @param {UnderlyingSink<W>=} underlyingSink
* @param {QueuingStrategy<W>=} strategy * @param {QueuingStrategy<W>=} strategy
@ -6740,6 +6751,283 @@ function createProxy(stream) {
return stream.pipeThrough(new TransformStream()); return stream.pipeThrough(new TransformStream());
} }
function packAndPostMessage(port, type, value) {
port.postMessage({ type, value, __proto__: null });
}
function crossRealmTransformSendError(port, error) {
packAndPostMessage(port, "error", error);
}
function packAndPostMessageHandlingError(port, type, value) {
try {
packAndPostMessage(port, type, value);
} catch (e) {
crossRealmTransformSendError(port, e);
throw e;
}
}
/**
* @param stream {ReadableStream<any>}
* @param port {MessagePort}
*/
function setUpCrossRealmTransformReadable(stream, port) {
initializeReadableStream(stream);
const controller = new ReadableStreamDefaultController(_brand);
port.addEventListener("message", (event) => {
if (event.data.type === "chunk") {
readableStreamDefaultControllerEnqueue(controller, event.data.value);
} else if (event.data.type === "close") {
readableStreamDefaultControllerClose(controller);
port.close();
} else if (event.data.type === "error") {
readableStreamDefaultControllerError(controller, event.data.value);
port.close();
}
});
port.addEventListener("messageerror", (event) => {
crossRealmTransformSendError(port, event.error);
readableStreamDefaultControllerError(controller, event.error);
port.close();
});
port.start();
const startAlgorithm = () => undefined;
const pullAlgorithm = () => {
packAndPostMessage(port, "pull", undefined);
return PromiseResolve(undefined);
};
const cancelAlgorithm = (reason) => {
try {
packAndPostMessageHandlingError(port, "error", reason);
} catch (e) {
return PromiseReject(e);
} finally {
port.close();
}
return PromiseResolve(undefined);
};
const sizeAlgorithm = () => 1;
setUpReadableStreamDefaultController(
stream,
controller,
startAlgorithm,
pullAlgorithm,
cancelAlgorithm,
0,
sizeAlgorithm,
);
}
/**
* @param stream {WritableStream<any>}
* @param port {MessagePort}
*/
function setUpCrossRealmTransformWritable(stream, port) {
initializeWritableStream(stream);
const controller = new WritableStreamDefaultController(_brand);
let backpressurePromise = PromiseWithResolvers();
port.addEventListener("message", (event) => {
if (event.data.type === "pull") {
if (backpressurePromise) {
backpressurePromise.resolve();
backpressurePromise = undefined;
}
} else if (event.data.type === "error") {
writableStreamDefaultControllerErrorIfNeeded(
controller,
event.data.value,
);
if (backpressurePromise) {
backpressurePromise.resolve();
backpressurePromise = undefined;
}
}
});
port.addEventListener("messageerror", (event) => {
crossRealmTransformSendError(port, event.error);
writableStreamDefaultControllerErrorIfNeeded(controller, event.error);
port.close();
});
port.start();
const startAlgorithm = () => undefined;
const writeAlgorithm = (chunk) => {
if (!backpressurePromise) {
backpressurePromise = PromiseWithResolvers();
backpressurePromise.resolve();
}
return PromisePrototypeThen(backpressurePromise.promise, () => {
backpressurePromise = PromiseWithResolvers();
try {
packAndPostMessageHandlingError(port, "chunk", chunk);
} catch (e) {
port.close();
throw e;
}
});
};
const closeAlgorithm = () => {
packAndPostMessage(port, "close", undefined);
port.close();
return PromiseResolve(undefined);
};
const abortAlgorithm = (reason) => {
try {
packAndPostMessageHandlingError(port, "error", reason);
return PromiseResolve(undefined);
} catch (error) {
return PromiseReject(error);
} finally {
port.close();
}
};
const sizeAlgorithm = () => 1;
setUpWritableStreamDefaultController(
stream,
controller,
startAlgorithm,
writeAlgorithm,
closeAlgorithm,
abortAlgorithm,
1,
sizeAlgorithm,
);
}
/**
* @param value {ReadableStream<any>}
* @param port {MessagePort}
*/
function readableStreamTransferSteps(value, port) {
if (isReadableStreamLocked(value)) {
throw new DOMException(
"Cannot transfer a locked ReadableStream",
"DataCloneError",
);
}
const writable = new WritableStream(_brand);
setUpCrossRealmTransformWritable(writable, port);
const promise = readableStreamPipeTo(value, writable, false, false, false);
setPromiseIsHandledToTrue(promise);
}
/**
* @param port {MessagePort}
* @returns {ReadableStream<any>}
*/
function readableStreamTransferReceivingSteps(port) {
const stream = new ReadableStream(_brand);
setUpCrossRealmTransformReadable(stream, port);
return stream;
}
/**
* @param value {WritableStream<any>}
* @param port {MessagePort}
*/
function writableStreamTransferSteps(value, port) {
if (isWritableStreamLocked(value)) {
throw new DOMException(
"Cannot transfer a locked WritableStream",
"DataCloneError",
);
}
const readable = new ReadableStream(_brand);
setUpCrossRealmTransformReadable(readable, port);
const promise = readableStreamPipeTo(readable, value, false, false, false);
setPromiseIsHandledToTrue(promise);
}
/**
* @param port {MessagePort}
* @returns {WritableStream<any>}
*/
function writableStreamTransferReceivingSteps(port) {
const stream = new WritableStream(_brand);
setUpCrossRealmTransformWritable(stream, port);
return stream;
}
/**
* @param value {TransformStream<any>}
* @param portR {MessagePort}
* @param portW {MessagePort}
*/
function transformStreamTransferSteps(value, portR, portW) {
if (isReadableStreamLocked(value.readable)) {
throw new DOMException(
"Cannot transfer a locked ReadableStream",
"DataCloneError",
);
}
if (isWritableStreamLocked(value.writable)) {
throw new DOMException(
"Cannot transfer a locked WritableStream",
"DataCloneError",
);
}
readableStreamTransferSteps(value.readable, portR);
writableStreamTransferSteps(value.writable, portW);
}
/**
* @param portR {MessagePort}
* @param portW {MessagePort}
* @returns {TransformStream<any>}
*/
function transformStreamTransferReceivingSteps(portR, portW) {
const stream = new TransformStream(_brand);
stream[_readable] = new ReadableStream(_brand);
setUpCrossRealmTransformReadable(stream[_readable], portR);
stream[_writable] = new WritableStream(_brand);
setUpCrossRealmTransformWritable(stream[_writable], portW);
return stream;
}
core.registerTransferableResource(
"ReadableStream",
(value) => {
const { port1, port2 } = new MessageChannel();
readableStreamTransferSteps(value, port1);
return core.getTransferableResource("MessagePort").send(port2);
},
(rid) => {
const port = core.getTransferableResource("MessagePort").receive(rid);
return readableStreamTransferReceivingSteps(port);
},
);
core.registerTransferableResource(
"WritableStream",
(value) => {
const { port1, port2 } = new MessageChannel();
writableStreamTransferSteps(value, port1);
return core.getTransferableResource("MessagePort").send(port2);
},
(rid) => {
const port = core.getTransferableResource("MessagePort").receive(rid);
return writableStreamTransferReceivingSteps(port);
},
);
core.registerTransferableResource(
"TransformStream",
(value) => {
const { port1: portR1, port2: portR2 } = new MessageChannel();
const { port1: portW1, port2: portW2 } = new MessageChannel();
transformStreamTransferSteps(value, portR1, portW1);
return [
core.getTransferableResource("MessagePort").send(portR2),
core.getTransferableResource("MessagePort").send(portW2),
];
},
(rids) => {
const portR = core.getTransferableResource("MessagePort").receive(rids[0]);
const portW = core.getTransferableResource("MessagePort").receive(rids[1]);
return transformStreamTransferReceivingSteps(portR, portW);
},
);
webidl.converters.ReadableStream = webidl webidl.converters.ReadableStream = webidl
.createInterfaceConverter("ReadableStream", ReadableStream.prototype); .createInterfaceConverter("ReadableStream", ReadableStream.prototype);
webidl.converters.WritableStream = webidl webidl.converters.WritableStream = webidl

View file

@ -385,6 +385,13 @@ function deserializeJsMessageData(messageData) {
ArrayPrototypePush(hostObjects, hostObj); ArrayPrototypePush(hostObjects, hostObj);
break; break;
} }
case "multiResource": {
const { 0: type, 1: rids } = transferable.data;
const hostObj = core.getTransferableResource(type).receive(rids);
ArrayPrototypePush(transferables, hostObj);
ArrayPrototypePush(hostObjects, hostObj);
break;
}
case "arrayBuffer": { case "arrayBuffer": {
ArrayPrototypePush(transferredArrayBuffers, transferable.data); ArrayPrototypePush(transferredArrayBuffers, transferable.data);
const index = ArrayPrototypePush(transferables, null); const index = ArrayPrototypePush(transferables, null);
@ -460,10 +467,17 @@ function serializeJsMessageData(data, transferables) {
if (transferable[core.hostObjectBrand]) { if (transferable[core.hostObjectBrand]) {
const type = transferable[core.hostObjectBrand]; const type = transferable[core.hostObjectBrand];
const rid = core.getTransferableResource(type).send(transferable); const rid = core.getTransferableResource(type).send(transferable);
ArrayPrototypePush(serializedTransferables, { if (typeof rid === "number") {
kind: "resource", ArrayPrototypePush(serializedTransferables, {
data: [type, rid], kind: "resource",
}); data: [type, rid],
});
} else {
ArrayPrototypePush(serializedTransferables, {
kind: "multiResource",
data: [type, rid],
});
}
} else if (isArrayBuffer(transferable)) { } else if (isArrayBuffer(transferable)) {
ArrayPrototypePush(serializedTransferables, { ArrayPrototypePush(serializedTransferables, {
kind: "arrayBuffer", kind: "arrayBuffer",

View file

@ -46,6 +46,7 @@ pub enum MessagePortError {
pub enum Transferable { pub enum Transferable {
Resource(String, Box<dyn TransferredResource>), Resource(String, Box<dyn TransferredResource>),
MultiResource(String, Vec<Box<dyn TransferredResource>>),
ArrayBuffer(u32), ArrayBuffer(u32),
} }
@ -180,6 +181,7 @@ pub fn op_message_port_create_entangled(
pub enum JsTransferable { pub enum JsTransferable {
ArrayBuffer(u32), ArrayBuffer(u32),
Resource(String, ResourceId), Resource(String, ResourceId),
MultiResource(String, Vec<ResourceId>),
} }
pub fn deserialize_js_transferables( pub fn deserialize_js_transferables(
@ -197,6 +199,18 @@ pub fn deserialize_js_transferables(
let tx = resource.transfer().map_err(MessagePortError::Generic)?; let tx = resource.transfer().map_err(MessagePortError::Generic)?;
transferables.push(Transferable::Resource(name, tx)); transferables.push(Transferable::Resource(name, tx));
} }
JsTransferable::MultiResource(name, rids) => {
let mut txs = Vec::with_capacity(rids.len());
for rid in rids {
let resource = state
.resource_table
.take_any(rid)
.map_err(|_| MessagePortError::InvalidTransfer)?;
let tx = resource.transfer().map_err(MessagePortError::Generic)?;
txs.push(tx);
}
transferables.push(Transferable::MultiResource(name, txs));
}
JsTransferable::ArrayBuffer(id) => { JsTransferable::ArrayBuffer(id) => {
transferables.push(Transferable::ArrayBuffer(id)); transferables.push(Transferable::ArrayBuffer(id));
} }
@ -217,6 +231,13 @@ pub fn serialize_transferables(
let rid = state.resource_table.add_rc_dyn(rx); let rid = state.resource_table.add_rc_dyn(rx);
js_transferables.push(JsTransferable::Resource(name, rid)); js_transferables.push(JsTransferable::Resource(name, rid));
} }
Transferable::MultiResource(name, txs) => {
let rids = txs
.into_iter()
.map(|tx| state.resource_table.add_rc_dyn(tx.receive()))
.collect();
js_transferables.push(JsTransferable::MultiResource(name, rids));
}
Transferable::ArrayBuffer(id) => { Transferable::ArrayBuffer(id) => {
js_transferables.push(JsTransferable::ArrayBuffer(id)); js_transferables.push(JsTransferable::ArrayBuffer(id));
} }

View file

@ -5371,17 +5371,27 @@
"queuing-strategies-size-function-per-global.window.html": false, "queuing-strategies-size-function-per-global.window.html": false,
"transferable": { "transferable": {
"deserialize-error.window.html": false, "deserialize-error.window.html": false,
"transfer-with-messageport.window.html": false, "transfer-with-messageport.window.html": true,
"readable-stream.html": false, "readable-stream.html": [
"reason.html": false, "cancel should be propagated to the original",
"cancel should abort a pending read()",
"transferring a non-serializable chunk should error both sides"
],
"reason.html": [
"DOMException errors should be preserved"
],
"service-worker.https.html": false, "service-worker.https.html": false,
"shared-worker.html": false, "shared-worker.html": false,
"transform-stream-members.any.html": true, "transform-stream-members.any.html": true,
"transform-stream-members.any.worker.html": true, "transform-stream-members.any.worker.html": true,
"transform-stream.html": false, "transform-stream.html": true,
"window.html": false, "window.html": [
"worker.html": false, "transfer to and from an iframe should work"
"writable-stream.html": false ],
"worker.html": true,
"writable-stream.html": [
"writing a unclonable object should error the stream"
]
} }
}, },
"user-timing": { "user-timing": {
@ -13093,7 +13103,6 @@
"An object whose interface is deleted from the global must still deserialize", "An object whose interface is deleted from the global must still deserialize",
"A subclass instance will deserialize as its closest serializable superclass", "A subclass instance will deserialize as its closest serializable superclass",
"Growable SharedArrayBuffer", "Growable SharedArrayBuffer",
"A subclass instance will be received as its closest transferable superclass",
"Transferring OOB TypedArray throws" "Transferring OOB TypedArray throws"
], ],
"structured-clone.any.worker.html": [ "structured-clone.any.worker.html": [
@ -13121,7 +13130,6 @@
"An object whose interface is deleted from the global must still deserialize", "An object whose interface is deleted from the global must still deserialize",
"A subclass instance will deserialize as its closest serializable superclass", "A subclass instance will deserialize as its closest serializable superclass",
"Growable SharedArrayBuffer", "Growable SharedArrayBuffer",
"A subclass instance will be received as its closest transferable superclass",
"Transferring OOB TypedArray throws" "Transferring OOB TypedArray throws"
], ],
"structured-clone-cross-realm-method.html": false "structured-clone-cross-realm-method.html": false

View file

@ -189,6 +189,35 @@ export async function runSingleTest(
} }
} }
function getShim(test: string): string {
const shim = [];
shim.push("globalThis.window = globalThis;");
if (test.includes("streams/transferable")) {
shim.push(`
{
const { port1, port2 } = new MessageChannel();
port2.addEventListener('message', (e) => {
queueMicrotask(() => {
globalThis.dispatchEvent(e);
});
});
port2.start();
globalThis.postMessage = (message, targetOriginOrOptions, transfer) => {
let options = targetOriginOrOptions;
if (transfer || typeof targetOriginOrOptions === 'string') {
options = { transfer };
}
return port1.postMessage(message, options);
};
}
`);
}
return shim.join("\n");
}
async function generateBundle(location: URL): Promise<string> { async function generateBundle(location: URL): Promise<string> {
const res = await fetch(location); const res = await fetch(location);
const body = await res.text(); const body = await res.text();
@ -206,6 +235,7 @@ async function generateBundle(location: URL): Promise<string> {
`globalThis.META_TITLE=${JSON.stringify(title)}`, `globalThis.META_TITLE=${JSON.stringify(title)}`,
]); ]);
} }
const shim = getShim(location.pathname);
for (const script of scripts) { for (const script of scripts) {
const src = script.getAttribute("src"); const src = script.getAttribute("src");
if (src === "/resources/testharnessreport.js") { if (src === "/resources/testharnessreport.js") {
@ -213,20 +243,20 @@ async function generateBundle(location: URL): Promise<string> {
join(ROOT_PATH, "./tests/wpt/runner/testharnessreport.js"), join(ROOT_PATH, "./tests/wpt/runner/testharnessreport.js"),
); );
const contents = await Deno.readTextFile(url); const contents = await Deno.readTextFile(url);
scriptContents.push([url.href, "globalThis.window = globalThis;"]); scriptContents.push([url.href, shim]);
scriptContents.push([url.href, contents]); scriptContents.push([url.href, contents]);
} else if (src) { } else if (src) {
const url = new URL(src, location); const url = new URL(src, location);
const res = await fetch(url); const res = await fetch(url);
if (res.ok) { if (res.ok) {
const contents = await res.text(); const contents = await res.text();
scriptContents.push([url.href, "globalThis.window = globalThis;"]); scriptContents.push([url.href, shim]);
scriptContents.push([url.href, contents]); scriptContents.push([url.href, contents]);
} }
} else { } else {
const url = new URL(`#${inlineScriptCount}`, location); const url = new URL(`#${inlineScriptCount}`, location);
inlineScriptCount++; inlineScriptCount++;
scriptContents.push([url.href, "globalThis.window = globalThis;"]); scriptContents.push([url.href, shim]);
scriptContents.push([url.href, script.textContent]); scriptContents.push([url.href, script.textContent]);
} }
} }