From fc7609eb6bdfcbf6325a821982dca80df4c01820 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 20 May 2026 00:18:36 +0100
Subject: [PATCH 1/3] Specify browser demo handler sandbox task
---
tasks/browser-demo-handler-sandbox.md | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 tasks/browser-demo-handler-sandbox.md
diff --git a/tasks/browser-demo-handler-sandbox.md b/tasks/browser-demo-handler-sandbox.md
new file mode 100644
index 0000000..c47a8a6
--- /dev/null
+++ b/tasks/browser-demo-handler-sandbox.md
@@ -0,0 +1,20 @@
+---
+status: in-progress
+size: medium
+base_pr: 7
+---
+
+Summary: Stacked follow-up to the browser demo page. Move editable handler execution out of the first-party page context while preserving the no-build static docs demo and request/response logging.
+
+- [ ] Run editable handler source in an isolated execution context instead of `Function(...)` in `docs/browser-demo.js`.
+- [ ] Preserve support for handlers that return `Response`, string, `Blob`, `ReadableStream`, or `Uint8Array`.
+- [ ] Preserve tunnel request logs, handler logs, and the built-in probe behavior.
+- [ ] Surface compile/runtime errors clearly in the existing log and probe output UI.
+- [ ] Keep the demo static and GitHub-Pages-friendly; do not add a bundler or app framework.
+- [ ] Verify with syntax checks, repo checks, tests, and a local browser smoke where practical.
+
+## Assumptions
+
+- A dedicated Web Worker is enough isolation for this follow-up: it prevents handler code from touching the page DOM and local storage, while keeping web-standard `Request`/`Response` available.
+- Stronger sandboxing, such as origin isolation or SES-style policy controls, can remain out of scope unless the static worker approach proves insufficient.
+- This PR should use PR #7 as its base and stay focused on handler execution isolation.
From efd78d8574443b3d54a7bfa8337561be4d3b06c7 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 20 May 2026 00:29:35 +0100
Subject: [PATCH 2/3] Run browser demo handlers in a worker
---
docs/browser-demo-handler-worker.js | 140 +++++++++++++
docs/browser-demo.html | 4 +-
docs/browser-demo.js | 193 ++++++++++++++----
tasks/browser-demo-handler-sandbox.md | 20 --
...2026-05-19-browser-demo-handler-sandbox.md | 34 +++
5 files changed, 331 insertions(+), 60 deletions(-)
create mode 100644 docs/browser-demo-handler-worker.js
delete mode 100644 tasks/browser-demo-handler-sandbox.md
create mode 100644 tasks/complete/2026-05-19-browser-demo-handler-sandbox.md
diff --git a/docs/browser-demo-handler-worker.js b/docs/browser-demo-handler-worker.js
new file mode 100644
index 0000000..3240e04
--- /dev/null
+++ b/docs/browser-demo-handler-worker.js
@@ -0,0 +1,140 @@
+self.addEventListener("message", (event) => {
+ handleMessage(event.data).catch((error) => {
+ postError(error);
+ });
+});
+
+async function handleMessage(message) {
+ if (!message || typeof message.type !== "string") {
+ throw new Error("Handler worker received an invalid message.");
+ }
+
+ if (message.type === "validate") {
+ compileHandler(message.source);
+ self.postMessage({ type: "validated" });
+ return;
+ }
+
+ if (message.type === "fetch") {
+ const handler = compileHandler(message.source);
+ const request = deserializeRequest(message.request);
+ const response = await normalizeResponse(await handler(request, handlerContext(message)));
+ const serialized = await serializeResponse(response);
+ self.postMessage({ type: "result", response: serialized.response }, serialized.transfer);
+ return;
+ }
+
+ throw new Error("Handler worker received an unknown message: " + message.type);
+}
+
+function compileHandler(source) {
+ const handlerSource = source ? String(source).trim() : "";
+
+ if (!handlerSource) {
+ throw new Error("Handler source is empty.");
+ }
+
+ const value = Function(
+ '"use strict";\nreturn (\n' +
+ handlerSource +
+ "\n);\n//# sourceURL=captun-browser-demo-handler-source.js",
+ )();
+
+ if (typeof value === "function") return value;
+ if (value && typeof value.fetch === "function") return value.fetch.bind(value);
+
+ throw new Error("Handler source must evaluate to a function or an object with fetch().");
+}
+
+function deserializeRequest(request) {
+ const init = {
+ headers: request.headers,
+ method: request.method,
+ };
+
+ if (request.body !== null && typeof request.body !== "undefined") {
+ init.body = request.body;
+ }
+
+ return new Request(request.url, init);
+}
+
+function handlerContext(message) {
+ const context = message.context;
+
+ return {
+ corsHeaders: context.corsHeaders,
+ log(value) {
+ self.postMessage({
+ type: "handler-log",
+ message: String(value),
+ requestId: context.requestId,
+ });
+ },
+ publicUrl: context.publicUrl,
+ requestId: context.requestId,
+ tunnelName: context.tunnelName,
+ };
+}
+
+async function normalizeResponse(value) {
+ const resolved = await value;
+ const isReadableStream =
+ typeof ReadableStream === "function" && resolved instanceof ReadableStream;
+
+ if (resolved instanceof Response) return resolved;
+ if (
+ typeof resolved === "string" ||
+ resolved instanceof Blob ||
+ isReadableStream ||
+ resolved instanceof Uint8Array
+ ) {
+ return new Response(resolved);
+ }
+
+ throw new Error("Handler must return a Response, string, Blob, ReadableStream, or Uint8Array.");
+}
+
+async function serializeResponse(response) {
+ if (response.type === "error") {
+ return {
+ response: { type: "error" },
+ transfer: [],
+ };
+ }
+
+ const body = response.body ? await response.arrayBuffer() : null;
+
+ return {
+ response: {
+ body,
+ headers: Array.from(response.headers.entries()),
+ status: response.status,
+ statusText: response.statusText,
+ type: "default",
+ },
+ transfer: body ? [body] : [],
+ };
+}
+
+function postError(error) {
+ self.postMessage({
+ type: "error",
+ error: serializeError(error),
+ });
+}
+
+function serializeError(error) {
+ if (error instanceof Error) {
+ return {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ };
+ }
+
+ return {
+ message: String(error),
+ name: "Error",
+ };
+}
diff --git a/docs/browser-demo.html b/docs/browser-demo.html
index 1637822..928943b 100644
--- a/docs/browser-demo.html
+++ b/docs/browser-demo.html
@@ -70,8 +70,8 @@
Fetch Handler
- Handler source runs as first-party JavaScript in this page and is saved locally. Only
- run code you wrote or trust.
+ Handler source runs in an isolated Web Worker and is saved locally. Only run code you
+ wrote or trust.
Handler source
diff --git a/docs/browser-demo.js b/docs/browser-demo.js
index 5dfc67e..dc97fa2 100644
--- a/docs/browser-demo.js
+++ b/docs/browser-demo.js
@@ -1,4 +1,6 @@
const storagePrefix = "captun-browser-demo:";
+const handlerWorkerUrl = new URL("./browser-demo-handler-worker.js", window.location.href);
+const handlerWorkerTimeoutMs = 30000;
const defaultHandler = `async function fetch(request, context) {
const url = new URL(request.url);
const headers = new Headers(context.corsHeaders);
@@ -110,7 +112,7 @@ async function connect() {
}
try {
- compileHandler();
+ await validateHandlerSource();
} catch (error) {
setError("Handler did not compile");
appendLog("error", "Handler did not compile", formatError(error));
@@ -162,18 +164,7 @@ async function handleTunnelRequest(request, tunnelName) {
appendLog("request", "#" + id + " " + request.method + " " + url.pathname + url.search);
try {
- const handler = compileHandler();
- const response = await normalizeResponse(
- await handler(request, {
- corsHeaders: corsHeaders(),
- log(message) {
- appendLog("handler", "#" + id + " " + message);
- },
- publicUrl: activePublicUrl,
- requestId: id,
- tunnelName,
- }),
- );
+ const response = await runHandlerInWorker(request, tunnelName, id);
appendLog("response", "#" + id + " " + response.status + " " + response.statusText);
return response;
} catch (error) {
@@ -182,31 +173,6 @@ async function handleTunnelRequest(request, tunnelName) {
}
}
-function compileHandler() {
- const source = elements.handlerSource.value.trim();
- const value = Function('"use strict"; return (' + source + ");")();
-
- if (typeof value === "function") return value;
- if (value && typeof value.fetch === "function") return value.fetch.bind(value);
-
- throw new Error("Handler source must evaluate to a function or an object with fetch().");
-}
-
-async function normalizeResponse(value) {
- const resolved = await value;
- if (resolved instanceof Response) return resolved;
- if (
- typeof resolved === "string" ||
- resolved instanceof Blob ||
- resolved instanceof ReadableStream ||
- resolved instanceof Uint8Array
- ) {
- return new Response(resolved);
- }
-
- throw new Error("Handler must return a Response, string, Blob, ReadableStream, or Uint8Array.");
-}
-
async function sendProbe() {
if (!activePublicUrl) {
appendLog("error", "Connect before sending a probe request");
@@ -283,6 +249,157 @@ async function loadBrowserClient() {
return createTunnel;
}
+async function validateHandlerSource() {
+ await runHandlerWorker(
+ {
+ type: "validate",
+ source: currentHandlerSource(),
+ },
+ [],
+ );
+}
+
+async function runHandlerInWorker(request, tunnelName, requestId) {
+ const serialized = await serializeRequest(request);
+ const response = await runHandlerWorker(
+ {
+ type: "fetch",
+ source: currentHandlerSource(),
+ request: serialized.request,
+ context: {
+ corsHeaders: corsHeaders(),
+ publicUrl: activePublicUrl,
+ requestId,
+ tunnelName,
+ },
+ },
+ serialized.transfer,
+ );
+
+ return deserializeResponse(response);
+}
+
+function runHandlerWorker(message, transfer) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let worker;
+ let timeout;
+
+ const settle = (callback) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ if (worker) worker.terminate();
+ callback();
+ };
+
+ try {
+ worker = new Worker(handlerWorkerUrl.href, { name: "captun-browser-demo-handler" });
+ } catch (error) {
+ reject(error);
+ return;
+ }
+
+ timeout = window.setTimeout(() => {
+ settle(() => {
+ reject(new Error("Handler worker did not respond within 30 seconds."));
+ });
+ }, handlerWorkerTimeoutMs);
+
+ worker.addEventListener("message", (event) => {
+ const workerMessage = event.data;
+ if (!workerMessage || typeof workerMessage.type !== "string") return;
+
+ if (workerMessage.type === "handler-log") {
+ appendLog("handler", "#" + workerMessage.requestId + " " + workerMessage.message);
+ return;
+ }
+
+ if (workerMessage.type === "validated") {
+ settle(() => resolve(undefined));
+ return;
+ }
+
+ if (workerMessage.type === "result") {
+ settle(() => resolve(workerMessage.response));
+ return;
+ }
+
+ if (workerMessage.type === "error") {
+ settle(() => reject(deserializeWorkerError(workerMessage.error)));
+ return;
+ }
+
+ settle(() => {
+ reject(new Error("Handler worker sent an unknown message: " + workerMessage.type));
+ });
+ });
+
+ worker.addEventListener("error", (event) => {
+ event.preventDefault();
+ settle(() => {
+ reject(new Error(event.message || "Handler worker failed."));
+ });
+ });
+
+ worker.addEventListener("messageerror", () => {
+ settle(() => {
+ reject(new Error("Handler worker sent a response that could not be read."));
+ });
+ });
+
+ try {
+ worker.postMessage(message, transfer);
+ } catch (error) {
+ settle(() => reject(error));
+ }
+ });
+}
+
+async function serializeRequest(request) {
+ const body =
+ request.method === "GET" || request.method === "HEAD" ? null : await request.arrayBuffer();
+
+ return {
+ request: {
+ body,
+ headers: Array.from(request.headers.entries()),
+ method: request.method,
+ url: request.url,
+ },
+ transfer: body ? [body] : [],
+ };
+}
+
+function deserializeResponse(response) {
+ if (!response || typeof response !== "object") {
+ throw new Error("Handler worker returned an invalid response.");
+ }
+
+ if (response.type === "error") return Response.error();
+
+ return new Response(response.body, {
+ headers: response.headers,
+ status: response.status,
+ statusText: response.statusText,
+ });
+}
+
+function deserializeWorkerError(error) {
+ const name = error && error.name ? String(error.name) : "Error";
+ const message = error && error.message ? String(error.message) : "Unknown handler worker error";
+ const formatted = name === "Error" ? message : name + ": " + message;
+ const value = new Error(formatted);
+
+ if (error && error.stack) value.stack = String(error.stack);
+
+ return value;
+}
+
+function currentHandlerSource() {
+ return elements.handlerSource.value.trim();
+}
+
function setConnecting() {
elements.status.textContent = "Connecting";
elements.status.dataset.status = "connecting";
diff --git a/tasks/browser-demo-handler-sandbox.md b/tasks/browser-demo-handler-sandbox.md
deleted file mode 100644
index c47a8a6..0000000
--- a/tasks/browser-demo-handler-sandbox.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-status: in-progress
-size: medium
-base_pr: 7
----
-
-Summary: Stacked follow-up to the browser demo page. Move editable handler execution out of the first-party page context while preserving the no-build static docs demo and request/response logging.
-
-- [ ] Run editable handler source in an isolated execution context instead of `Function(...)` in `docs/browser-demo.js`.
-- [ ] Preserve support for handlers that return `Response`, string, `Blob`, `ReadableStream`, or `Uint8Array`.
-- [ ] Preserve tunnel request logs, handler logs, and the built-in probe behavior.
-- [ ] Surface compile/runtime errors clearly in the existing log and probe output UI.
-- [ ] Keep the demo static and GitHub-Pages-friendly; do not add a bundler or app framework.
-- [ ] Verify with syntax checks, repo checks, tests, and a local browser smoke where practical.
-
-## Assumptions
-
-- A dedicated Web Worker is enough isolation for this follow-up: it prevents handler code from touching the page DOM and local storage, while keeping web-standard `Request`/`Response` available.
-- Stronger sandboxing, such as origin isolation or SES-style policy controls, can remain out of scope unless the static worker approach proves insufficient.
-- This PR should use PR #7 as its base and stay focused on handler execution isolation.
diff --git a/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md b/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md
new file mode 100644
index 0000000..55ee850
--- /dev/null
+++ b/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md
@@ -0,0 +1,34 @@
+---
+status: done
+size: medium
+base_pr: 7
+---
+
+Summary: Stacked follow-up to the browser demo page. Move editable handler execution out of the first-party page context while preserving the no-build static docs demo and request/response logging.
+
+Status summary: Done. Editable handler compilation and execution now happen in a static dedicated Web Worker, while the main page keeps the tunnel/probe/log UI. Response normalization, handler logs, compile errors, and the built-in probe were verified against a local Wrangler Worker.
+
+- [x] Run editable handler source in an isolated execution context instead of `Function(...)` in `docs/browser-demo.js`. _Added `docs/browser-demo-handler-worker.js`; `docs/browser-demo.js` now serializes requests into the worker and reconstructs worker-normalized responses._
+- [x] Preserve support for handlers that return `Response`, string, `Blob`, `ReadableStream`, or `Uint8Array`. _The worker normalizes those return types to `Response`, buffers the body across the worker boundary, and the browser smoke exercised all five supported shapes._
+- [x] Preserve tunnel request logs, handler logs, and the built-in probe behavior. _The main page still records tunnel/probe request and response rows; worker `context.log()` messages post back as handler log rows._
+- [x] Surface compile/runtime errors clearly in the existing log and probe output UI. _Worker errors are serialized with their error name/message and shown through the existing `Handler did not compile` and handler error paths._
+- [x] Keep the demo static and GitHub-Pages-friendly; do not add a bundler or app framework. _The sandbox is a plain static worker script loaded next to the existing HTML/CSS/JS assets._
+- [x] Verify with syntax checks, repo checks, tests, and a local browser smoke where practical. _Ran `node --check`, `pnpm run check`, `pnpm test`, a direct worker browser smoke, and a full local Worker probe smoke._
+
+## Assumptions
+
+- A dedicated Web Worker is enough isolation for this follow-up: it prevents handler code from touching the page DOM and local storage, while keeping web-standard `Request`/`Response` available.
+- Stronger sandboxing, such as origin isolation or SES-style policy controls, can remain out of scope unless the static worker approach proves insufficient.
+- This PR should use PR #7 as its base and stay focused on handler execution isolation.
+
+## Implementation Log
+
+- Added `docs/browser-demo-handler-worker.js` as the static worker that compiles editable handler source, builds a worker-side `Request`, normalizes supported handler return types, and posts serialized responses/errors/logs back to the page.
+- Updated `docs/browser-demo.js` to validate handler source via the worker before connecting and to execute each tunnel request in a fresh worker instance with a 30-second no-response guard.
+- Updated the handler notice in `docs/browser-demo.html` to describe worker execution rather than first-party page execution.
+- `node --check docs/browser-demo.js`, `node --check docs/browser-demo-client.js`, and `node --check docs/browser-demo-handler-worker.js` passed.
+- `pnpm run check` passed after installing dependencies from the existing lockfile.
+- `pnpm test` passed with 29 tests across 3 files.
+- Browser smoke served `docs/` on `http://127.0.0.1:8765/browser-demo.html`; direct worker calls returned expected bodies for `Response`, string, `Blob`, `Uint8Array`, and `ReadableStream`, and confirmed handler code sees `document`/`localStorage` as `undefined`.
+- Browser compile-error smoke set invalid handler source and confirmed the existing status/log UI reported `Handler did not compile` with a `SyntaxError`.
+- Full local probe smoke ran `pnpm exec wrangler dev --ip 127.0.0.1 --port 8788`, connected the demo to `http://127.0.0.1:8788/browser-smoke`, sent the built-in `/health` probe, and received `200 OK` JSON through the browser handler with request/handler/response log rows.
From 0680a72c1c687650b2de87418ba7bbfefb8ec04d Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 20 May 2026 00:39:06 +0100
Subject: [PATCH 3/3] Stream browser demo worker responses
---
README.md | 2 +-
docs/browser-demo-handler-worker.js | 45 +++--
docs/browser-demo.js | 168 ++++++++++++++----
...2026-05-19-browser-demo-handler-sandbox.md | 2 +
4 files changed, 167 insertions(+), 50 deletions(-)
diff --git a/README.md b/README.md
index 859f16d..64faa4b 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ pnpm install
npx wrangler deploy --name captun-browser-demo
```
-Handler source in the demo runs as first-party JavaScript in the page and is saved in local storage. Only paste handler code you wrote or trust.
+Handler source in the demo runs in a dedicated same-origin Web Worker and is saved in local storage. It cannot touch the page DOM directly, but it is still JavaScript you are choosing to run in this origin, so only paste handler code you wrote or trust.
## Advanced usage
diff --git a/docs/browser-demo-handler-worker.js b/docs/browser-demo-handler-worker.js
index 3240e04..a39ecc0 100644
--- a/docs/browser-demo-handler-worker.js
+++ b/docs/browser-demo-handler-worker.js
@@ -19,8 +19,7 @@ async function handleMessage(message) {
const handler = compileHandler(message.source);
const request = deserializeRequest(message.request);
const response = await normalizeResponse(await handler(request, handlerContext(message)));
- const serialized = await serializeResponse(response);
- self.postMessage({ type: "result", response: serialized.response }, serialized.transfer);
+ await postResponse(response);
return;
}
@@ -54,6 +53,7 @@ function deserializeRequest(request) {
if (request.body !== null && typeof request.body !== "undefined") {
init.body = request.body;
+ init.duplex = "half";
}
return new Request(request.url, init);
@@ -95,26 +95,45 @@ async function normalizeResponse(value) {
throw new Error("Handler must return a Response, string, Blob, ReadableStream, or Uint8Array.");
}
-async function serializeResponse(response) {
+async function postResponse(response) {
if (response.type === "error") {
- return {
- response: { type: "error" },
- transfer: [],
- };
+ self.postMessage({ type: "response-start", response: { type: "error" } });
+ return;
}
- const body = response.body ? await response.arrayBuffer() : null;
-
- return {
+ self.postMessage({
+ type: "response-start",
response: {
- body,
+ hasBody: Boolean(response.body),
headers: Array.from(response.headers.entries()),
status: response.status,
statusText: response.statusText,
type: "default",
},
- transfer: body ? [body] : [],
- };
+ });
+
+ if (!response.body) return;
+
+ const reader = response.body.getReader();
+ try {
+ while (true) {
+ const chunk = await reader.read();
+ if (chunk.done) break;
+
+ const bytes = transferableBytes(chunk.value);
+ self.postMessage({ type: "response-chunk", chunk: bytes }, [bytes.buffer]);
+ }
+ self.postMessage({ type: "response-done" });
+ } catch (error) {
+ self.postMessage({ type: "response-error", error: serializeError(error) });
+ }
+}
+
+function transferableBytes(value) {
+ if (value.byteOffset === 0 && value.byteLength === value.buffer.byteLength) {
+ return value;
+ }
+ return value.slice();
}
function postError(error) {
diff --git a/docs/browser-demo.js b/docs/browser-demo.js
index dc97fa2..041b40c 100644
--- a/docs/browser-demo.js
+++ b/docs/browser-demo.js
@@ -54,6 +54,7 @@ let activeTunnel;
let activePublicUrl = "";
let requestCount = 0;
let createTunnel;
+let transferableReadableStreamSupport;
initialize();
@@ -261,7 +262,7 @@ async function validateHandlerSource() {
async function runHandlerInWorker(request, tunnelName, requestId) {
const serialized = await serializeRequest(request);
- const response = await runHandlerWorker(
+ return await runHandlerWorker(
{
type: "fetch",
source: currentHandlerSource(),
@@ -275,22 +276,39 @@ async function runHandlerInWorker(request, tunnelName, requestId) {
},
serialized.transfer,
);
-
- return deserializeResponse(response);
}
function runHandlerWorker(message, transfer) {
return new Promise((resolve, reject) => {
- let settled = false;
+ let responseStarted = false;
+ let settledInitialResponse = false;
+ let streamController;
let worker;
let timeout;
- const settle = (callback) => {
- if (settled) return;
- settled = true;
+ const terminate = () => {
clearTimeout(timeout);
if (worker) worker.terminate();
- callback();
+ };
+
+ const resolveInitialResponse = (value, keepWorker) => {
+ if (settledInitialResponse) return;
+ settledInitialResponse = true;
+ clearTimeout(timeout);
+ if (!keepWorker) terminate();
+ resolve(value);
+ };
+
+ const rejectOrErrorStream = (error) => {
+ if (responseStarted && streamController) {
+ streamController.error(error);
+ terminate();
+ return;
+ }
+ if (settledInitialResponse) return;
+ settledInitialResponse = true;
+ terminate();
+ reject(error);
};
try {
@@ -301,9 +319,7 @@ function runHandlerWorker(message, transfer) {
}
timeout = window.setTimeout(() => {
- settle(() => {
- reject(new Error("Handler worker did not respond within 30 seconds."));
- });
+ rejectOrErrorStream(new Error("Handler worker did not respond within 30 seconds."));
}, handlerWorkerTimeoutMs);
worker.addEventListener("message", (event) => {
@@ -316,49 +332,118 @@ function runHandlerWorker(message, transfer) {
}
if (workerMessage.type === "validated") {
- settle(() => resolve(undefined));
+ resolveInitialResponse(undefined, false);
return;
}
- if (workerMessage.type === "result") {
- settle(() => resolve(workerMessage.response));
+ if (workerMessage.type === "response-start") {
+ responseStarted = true;
+ try {
+ const response = responseFromWorkerStart(workerMessage.response, () => worker);
+ resolveInitialResponse(response, Boolean(workerMessage.response?.hasBody));
+ } catch (error) {
+ rejectOrErrorStream(error);
+ }
+ return;
+ }
+
+ if (workerMessage.type === "response-chunk") {
+ if (!streamController) {
+ rejectOrErrorStream(new Error("Handler worker sent a response chunk before a response."));
+ return;
+ }
+ streamController.enqueue(new Uint8Array(workerMessage.chunk));
+ return;
+ }
+
+ if (workerMessage.type === "response-done") {
+ if (streamController) streamController.close();
+ terminate();
+ return;
+ }
+
+ if (workerMessage.type === "response-error") {
+ rejectOrErrorStream(deserializeWorkerError(workerMessage.error));
return;
}
if (workerMessage.type === "error") {
- settle(() => reject(deserializeWorkerError(workerMessage.error)));
+ rejectOrErrorStream(deserializeWorkerError(workerMessage.error));
return;
}
- settle(() => {
- reject(new Error("Handler worker sent an unknown message: " + workerMessage.type));
- });
+ rejectOrErrorStream(new Error("Handler worker sent an unknown message: " + workerMessage.type));
});
worker.addEventListener("error", (event) => {
event.preventDefault();
- settle(() => {
- reject(new Error(event.message || "Handler worker failed."));
- });
+ rejectOrErrorStream(new Error(event.message || "Handler worker failed."));
});
worker.addEventListener("messageerror", () => {
- settle(() => {
- reject(new Error("Handler worker sent a response that could not be read."));
- });
+ rejectOrErrorStream(new Error("Handler worker sent a response that could not be read."));
});
try {
worker.postMessage(message, transfer);
} catch (error) {
- settle(() => reject(error));
+ rejectOrErrorStream(error);
+ }
+
+ function responseFromWorkerStart(response, currentWorker) {
+ if (!response || typeof response !== "object") {
+ throw new Error("Handler worker returned an invalid response.");
+ }
+
+ if (response.type === "error") return Response.error();
+
+ const body = response.hasBody
+ ? new ReadableStream({
+ start(controller) {
+ streamController = controller;
+ },
+ cancel() {
+ const runningWorker = currentWorker();
+ if (runningWorker) runningWorker.terminate();
+ },
+ })
+ : null;
+
+ return new Response(body, {
+ headers: response.headers,
+ status: response.status,
+ statusText: response.statusText,
+ });
}
});
}
async function serializeRequest(request) {
- const body =
- request.method === "GET" || request.method === "HEAD" ? null : await request.arrayBuffer();
+ if (request.method === "GET" || request.method === "HEAD") {
+ return {
+ request: {
+ body: null,
+ headers: Array.from(request.headers.entries()),
+ method: request.method,
+ url: request.url,
+ },
+ transfer: [],
+ };
+ }
+
+ if (request.body && (await supportsTransferableReadableStreams())) {
+ return {
+ request: {
+ body: request.body,
+ headers: Array.from(request.headers.entries()),
+ method: request.method,
+ url: request.url,
+ },
+ transfer: [request.body],
+ };
+ }
+
+ const body = await request.arrayBuffer();
return {
request: {
@@ -371,18 +456,29 @@ async function serializeRequest(request) {
};
}
-function deserializeResponse(response) {
- if (!response || typeof response !== "object") {
- throw new Error("Handler worker returned an invalid response.");
+async function supportsTransferableReadableStreams() {
+ if (typeof ReadableStream !== "function") return false;
+ if (typeof transferableReadableStreamSupport === "boolean") {
+ return transferableReadableStreamSupport;
}
- if (response.type === "error") return Response.error();
+ const channel = new MessageChannel();
+ try {
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.close();
+ },
+ });
+ channel.port1.postMessage(stream, [stream]);
+ transferableReadableStreamSupport = true;
+ } catch {
+ transferableReadableStreamSupport = false;
+ } finally {
+ channel.port1.close();
+ channel.port2.close();
+ }
- return new Response(response.body, {
- headers: response.headers,
- status: response.status,
- statusText: response.statusText,
- });
+ return transferableReadableStreamSupport;
}
function deserializeWorkerError(error) {
diff --git a/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md b/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md
index 55ee850..2616b1f 100644
--- a/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md
+++ b/tasks/complete/2026-05-19-browser-demo-handler-sandbox.md
@@ -32,3 +32,5 @@ Status summary: Done. Editable handler compilation and execution now happen in a
- Browser smoke served `docs/` on `http://127.0.0.1:8765/browser-demo.html`; direct worker calls returned expected bodies for `Response`, string, `Blob`, `Uint8Array`, and `ReadableStream`, and confirmed handler code sees `document`/`localStorage` as `undefined`.
- Browser compile-error smoke set invalid handler source and confirmed the existing status/log UI reported `Handler did not compile` with a `SyntaxError`.
- Full local probe smoke ran `pnpm exec wrangler dev --ip 127.0.0.1 --port 8788`, connected the demo to `http://127.0.0.1:8788/browser-smoke`, sent the built-in `/health` probe, and received `200 OK` JSON through the browser handler with request/handler/response log rows.
+- Review follow-up streams handler response chunks across the worker boundary, uses transferable request body streams when the browser supports them, and updates README wording for the worker-based trust model.
+- Browser review-fix smoke served `docs/` on `http://127.0.0.1:8098/browser-demo.html` and confirmed a delayed `ReadableStream` response resolves before the first body chunk and delivers later chunks incrementally.