Explainer
Problem
No standardized Web Extension API to send or transfer TypedArrays or ArrayBuffers or
ReadableStreams or TransformStreams to and from MV3 Web Extensions from arbitrary Web pages.
Solution
Chromium based browsers (Chromium, Chrome, Brave, Opera, Edge)
Define "web_accessible_resources" in manifest.json; use iframe appended
to arbitrary Web page to establish a WindowClient in ServiceWorker context;
use Transferable Streams with postMessage(readable, "*", [readable]) to
transfer a ReadableStream from the Web page to the FetchEvent in
ServiceWorker which becomes a full-duplex stream due to Chromium's use of Mojo
between WindowClient and CLient and fetch event to handle fetch().
Implementation details
Internally Chromium has full-duplex stream capabilities between WindowClient
and Client of ServiceWorker via Mojo. We use this to initialize a TransformStream
with writable side the arbitrary Web page, and readable side sent to
ServiceWorker as a streaming request with fetch() and duplex: "half" RequestInit
option set.
When the writable side gets a WritableStreamDefaultWriter, data in the
form of Uint8Array written to write() is sent to the streaming request
and read in the ServiceWorker fetch event, where any data can be enqueued
into the TransformStreamDefaultController and sent to the WindowClient (iframe)
where we read the data sent from the ServiceWorker.
The ServiceWorker is persistent, remains active due to the fact we have a
live WindowClient and an indefinite fetch() request being handled by fetch
event handler.
Since we have a WindowClient we have an id for the iframe nested context,
so we can send messages to the arbitrary Web page that initiated a fetch()
request from the ServiceWorker by filtering client ids, without necessarily
waiting on messages from the client.
Something like
for (const [id, controller] of messageClients) {
controller.enqueue(new TextEncoder().encode(id));
console.log(await clients.get(id));
}
If we add a query string to the URL used to request the Web extension
web accessible resources iframe, we add citeria to filter further,
if needed
const { readable, writable, transferableWindow } = await WebExtensionMessageStream(location.host) // optional;
Multiple iframes can be appended to the same Web page, and multiple discrete Web pages.
Removing the iframe from the Web page closes the messaging session, and
results in a network type error because we have abruptly aborted the
fetch() request. We can probably handle that a little differently using AbortController,
if we wanted to.
We'll use a modern design for full-duplex asynchronous messaging in the form of a WHATWG TransformStream, similar to the design used by WebSocketStream,
WebTrasnport.
Usage
const encoder = new TextEncoder();
const { readable, writable, transferableWindow } = await WebExtensionMessageStream(location.host) // optional;
const writer = writable.getWriter();
readable.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({
write(message) {
console.log(message);
},
close() {
console.log("Stream close");
},
abort(reason) {
console.log(reason);
},
}),
).then(() => console.log("Done streaming"))
.catch((e) => {
console.log(e);
})
.finally(() => {
console.log("WebExtensionMessageStream closed");
});
await writer.ready;
await writer.write(encoder.encode(`Message from ${document.title}`));
Later
await writer.close();
Source code
https://github.com/guest271314/WebExtensionMessageStream
background.js
globalThis.messageClients = new Map();
addEventListener("install", async (e) => {
console.log(e.type);
e.addRoutes({
condition: {
urlPattern: new URLPattern({ hostname: "*" }),
requestMethod: "post",
},
source: "fetch-event",
});
e.waitUntil(self.skipWaiting());
});
addEventListener("activate", async (e) => {
console.log(e.type);
e.waitUntil(self.clients.claim());
});
addEventListener("fetch", async (e) => {
if (e.request.url.includes("stream")) {
e.respondWith((async () =>
new Response(
e.request.body
.pipeThrough(
new TransformStream({
async start(controller) {
globalThis.messageClients.set(e.clientId, controller);
console.log("start", Object.fromEntries(messageClients));
},
async transform(value, controller) {
// Logic for handling messages from specific WindowClient/Client
// Do stuff with Uint8Array message from Web page
console.log(value);
controller.enqueue(
new TextEncoder().encode(
new TextDecoder().decode(value).toUpperCase(),
),
);
},
flush() {
globalThis.messageClients.delete(e.clientId);
console.log("flush");
},
}),
),
))());
}
});
async function WebExtensionMessageStream(
target = "generic",
id = "chrome.runtime.id",
) {
const { readable, writable } = new TransformStream();
function removeFrame(url) {
const frames = document.querySelectorAll(`[src="${url}"]`);
frames.forEach((iframes) => {
iframes.remove();
});
return document.querySelectorAll(`[src="${url}"]`).length;
}
// Dynamically generate extension ID for path, if necessary:
// const extensionId = await generateIdForPath("/home/user/WebExtensionMessageStream");
// const url = new URL(
// `chrome-extension://${extensionId}/transferableStream.html`,
// );
async function generateIdForPath(path) {
return [
...[
...new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(path)),
),
].map((u8) => u8.toString(16).padStart(2, "0")).join("").slice(0, 32),
].map((hex) => String.fromCharCode(parseInt(hex, 16) + "a".charCodeAt(0)))
.join("");
}
const url = new URL(
`chrome-extension://${id}/transferableStream.html`,
);
return new Promise((resolve) => {
function handleMessage(e) {
if (e.origin === url.origin) {
if (e.data instanceof ReadableStream) {
resolve({
readable: e.data.pipeThrough(
new TransformStream({
transform(value, controller) {
controller.enqueue(value);
},
flush() {
console.log(removeFrame(url.href));
},
}),
),
transferableWindow,
writable,
});
removeEventListener("message", handleMessage);
} else {
e.source.postMessage(readable, "*", [readable]);
}
}
}
addEventListener("message", handleMessage);
const transferableWindow = document.createElement("iframe");
transferableWindow.style.display = "none";
transferableWindow.name = location.href;
transferableWindow.src = `${url.href}?target=${target}`;
transferableWindow.addEventListener("load", (e) => {
// console.log(e);
});
document.body.appendChild(transferableWindow);
}).catch((err) => {
throw err;
});
}
chrome.tabs.onUpdated.addListener(async (id, info, tab) => {
if (info.status === "complete" && !tab.url.startsWith("chrome:")) {
const script = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (fn, url) => {
globalThis.WebExtensionMessageStream = new Function(`return ${fn}`)();
console.log("WebExtensionMessageStream declared");
return `WebExtensionMessageStream defined globally in ${url}`;
},
args: [
WebExtensionMessageStream.toString().replace(
/chrome.runtime.id/,
chrome.runtime.id,
),
tab.url,
],
world: "MAIN",
});
console.log(script[0].result);
//console.log(info, tab);
}
// Send message (Uint8Array) to a specific WindowClient or Client
// messageClients
// .get("91af745a-7350-44f2-8910-d70e98fe48fc")
// .enqueue(new TextEncoder()
// .encode("Message from MV3 ServiceWorker"))
});
chrome.runtime.onInstalled.addListener((reason) => {
console.log(reason, globalThis.messageClients);
});
manifest.json
{
"name": "WebExtensionMessageStream",
"version": "1.0",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": [
"tabs",
"activeTab",
"scripting"
],
"host_permissions": [
"<all_urls>",
"chrome://*/*",
"file://*/*",
"*://*/*"
],
"web_accessible_resources": [
{
"resources": [
"*.html",
"*.js",
"*"
],
"matches": [
"<all_urls>"
],
"extension_ids": ["*"]
}
],
"action": {},
"homepage_url": "https://github.com/guest271314/WebExtensionMessageStream"
}
References
- https://issues.chromium.org/issues/40321352
- https://groups.google.com/a/chromium.org/g/blink-network-dev/c/9MKtF4fPtMA?pli=1
- https://docs.google.com/document/d/1_KuZzg5c3pncLJPFa8SuVm23AP4tft6mzPCL5at3I9M/edit?pli=1&tab=t.0#heading=h.ctj5hkqmripj
- https://issues.chromium.org/issues/40597904
- https://github.com/whatwg/streams/blob/dd4e1806f894eff6ba006fb3c96657b8b104263b/explainers/transferable-streams.md
- https://groups.google.com/a/chromium.org/g/chromium-dev/c/I_hfhGILbDc/m/OVlsxYjUBAAJ
- https://chromium.googlesource.com/chromium/src/+/main/mojo/README.md
- https://github.com/chromium/chromium/blob/5a06296706a275b6539c39acedb0dc09a18b8543/content/browser/service_worker/service_worker_fetch_dispatcher.cc
- https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
License
Do What the Fuck You Want to Public License WTFPLv2