Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/bright-apples-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"miniflare": patch
---

Added the following improvements to local Browser Rendering binding in Miniflare:

- Local Chrome version upgraded to 126.0.6478.182
- Reciprocate browser websocket close events
9 changes: 9 additions & 0 deletions .changeset/miniflare-disable-pool-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniflare": patch
---

fix: disable undici Pool request timeouts for local dev

Miniflare's undici `Pool` instances were using the default `headersTimeout` and `bodyTimeout` of 300 seconds (5 minutes). Any request taking longer than that — streaming responses, large uploads, long-polling, or compute-heavy Workers — would be silently killed with a "request failed" error.

Setting both timeouts to `0` disables them entirely, which is the correct behaviour for a local development tool where there is no reason to enforce request timeouts.
5 changes: 5 additions & 0 deletions .changeset/restore-exchange-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/edge-preview-authenticated-proxy": patch
---

Restore the `/exchange` preview session endpoint.
21 changes: 13 additions & 8 deletions .github/workflows/test-and-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,20 @@ jobs:
test:
timeout-minutes: 60
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-test
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.suite }}-test
cancel-in-progress: true

name: ${{ format('Tests ({0})', matrix.description) }}
name: ${{ format('Tests ({0}, {1})', matrix.description, matrix.suite) }}
strategy:
fail-fast: false
matrix:
os:
- macos-latest
- ubuntu-latest
- windows-latest
suite:
- packages-and-tools
- fixtures
include:
- os: macos-latest
description: macOS
Expand Down Expand Up @@ -122,17 +129,15 @@ jobs:

- name: Run tests (tools only)
# tools _only_ needs to be tested on Linux because they're only intended to run in CI
if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.os == 'ubuntu-latest'
if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'packages-and-tools' && matrix.os == 'ubuntu-latest'
run: pnpm run test:ci --log-order=stream --filter="@cloudflare/tools"
env:
NODE_OPTIONS: "--max_old_space_size=8192"
TEST_REPORT_PATH: ${{ runner.temp }}/test-report/tools/index.html
CI_OS: ${{ matrix.description }}

- name: Run tests (packages)
# We are running the package tests first be able to get early feedback on changes.
# There is no point in running the fixtures if a package is broken.
if: steps.changes.outputs.everything_but_markdown == 'true'
if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'packages-and-tools'
# We skip @cloudflare/vitest-pool-workers tests in CI on Windows because they're very flaky. We still run the vitest-pool-workers-examples fixture, which is a comprehensive set of example tests and gives us a lot of confidence.
# The @cloudflare/vitest-pool-workers tests skipped are things like watch mode, which constantly times out probably due to the github runners in use.
run: pnpm run test:ci --log-order=stream --concurrency=1 --filter="./packages/*" ${{ matrix.os == 'windows-latest' && '--filter="!./packages/vitest-pool-workers"' || '' }}
Expand All @@ -143,7 +148,7 @@ jobs:
CI_OS: ${{ matrix.description }}

- name: Run tests (fixtures)
if: steps.changes.outputs.everything_but_markdown == 'true'
if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'fixtures'
# Browser rendering is disabled on Ubuntu because of https://pptr.dev/troubleshooting#issues-with-apparmor-on-ubuntu
run: pnpm run test:ci --concurrency 1 --log-order=stream --filter="./fixtures/*" ${{ matrix.os == 'ubuntu-latest' && '--filter="!./fixtures/browser-rendering"' || '' }}
env:
Expand All @@ -156,5 +161,5 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: turbo-runs-${{ matrix.os }}
name: turbo-runs-${{ matrix.os }}-${{ matrix.suite }}
path: .turbo/runs
4 changes: 2 additions & 2 deletions fixtures/browser-rendering/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"test:ci": "vitest run"
},
"devDependencies": {
"@cloudflare/playwright": "^0.0.10",
"@cloudflare/puppeteer": "^1.0.2",
"@cloudflare/playwright": "^1.0.0",
"@cloudflare/puppeteer": "^1.0.4",
"@cloudflare/vitest-pool-workers": "workspace:*",
"@types/node": "catalog:default",
"typescript": "catalog:default",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/browser-rendering/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default {
const { sessionId } = await playwright.acquire(env.MYBROWSER);
const browser = await playwright.connect(env.MYBROWSER, sessionId);
// closing a browser obtained with playwright.connect actually disconnects
// (it doesn's close the porcess)
// (it doesn't close the process)
await browser.close();
const sessionInfo = await playwright
.sessions(env.MYBROWSER)
Expand Down
2 changes: 1 addition & 1 deletion fixtures/browser-rendering/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"$schema": "node_modules/wrangler/config-schema.json",
"name": "browser-rendering",
"main": "src/index.ts",
"compatibility_date": "2025-02-24",
"compatibility_date": "2025-12-01",
"observability": {
"enabled": true,
},
Expand Down
79 changes: 79 additions & 0 deletions packages/edge-preview-authenticated-proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ class HttpError extends Error {
}
}

class NoExchangeUrl extends HttpError {
constructor() {
super("No exchange_url provided", 400, false);
}
}

class ExchangeFailed extends HttpError {
constructor(
readonly url: string,
readonly exchangeStatus: number,
readonly body: string
) {
super("Exchange failed", 400, true);
}

get data(): { url: string; status: number; body: string } {
return { url: this.url, status: this.exchangeStatus, body: this.body };
}
}

class TokenUpdateFailed extends HttpError {
constructor() {
super("Provide token and remote", 400, false);
Expand Down Expand Up @@ -81,6 +101,14 @@ function switchRemote(url: URL, remote: string) {
return workerUrl;
}

function isTokenExchangeRequest(request: Request, url: URL, env: Env) {
return (
request.method === "POST" &&
url.hostname === env.PREVIEW &&
url.pathname === "/exchange"
);
}

function isPreviewUpdateRequest(request: Request, url: URL, env: Env) {
return (
request.method === "GET" &&
Expand All @@ -96,6 +124,10 @@ function isRawHttpRequest(url: URL, env: Env) {
async function handleRequest(request: Request, env: Env) {
const url = new URL(request.url);

if (isTokenExchangeRequest(request, url, env)) {
return await handleTokenExchange(url);
}

if (isPreviewUpdateRequest(request, url, env)) {
return await updatePreviewToken(url, env);
}
Expand Down Expand Up @@ -294,6 +326,53 @@ async function updatePreviewToken(url: URL, env: Env) {
});
}

/**
* Request the preview session associated with a given exchange_url
* exchange_url comes from an authenticated core API call made in the client
*/
async function handleTokenExchange(url: URL) {
const exchangeUrl = url.searchParams.get("exchange_url");
if (!exchangeUrl) {
throw new NoExchangeUrl();
}
assertValidURL(exchangeUrl);
const exchangeRes = await fetch(exchangeUrl);
if (exchangeRes.status !== 200) {
const exchange = new URL(exchangeUrl);
// Clear sensitive token
exchange.search = "";

throw new ExchangeFailed(
exchange.href,
exchangeRes.status,
await exchangeRes.text()
);
}
const session = await exchangeRes.json<{
prewarm: string;
token: string;
}>();
if (
typeof session.token !== "string" ||
typeof session.prewarm !== "string"
) {
const exchange = new URL(exchangeUrl);
// Clear sensitive token
exchange.search = "";
throw new ExchangeFailed(
exchange.href,
exchangeRes.status,
JSON.stringify(session)
);
}
return Response.json(session, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
},
});
}

// No ecosystem routers support hostname matching 😥
export default {
async fetch(
Expand Down
36 changes: 36 additions & 0 deletions packages/edge-preview-authenticated-proxy/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ function createMockFetchImplementation() {
return new Response("BAD", { status: 500 });
}

if (url.pathname === "/exchange") {
return Response.json({
token: "TEST_TOKEN",
prewarm: "TEST_PREWARM",
});
}
if (url.pathname === "/redirect") {
// Use manual redirect to avoid trailing slash being added
return new Response(null, {
Expand Down Expand Up @@ -76,6 +82,36 @@ afterEach(() => {
});

describe("Preview Worker", () => {
it("should obtain token from exchange_url", async ({ expect }) => {
const resp = await SELF.fetch(
`https://preview.devprod.cloudflare.dev/exchange?exchange_url=${encodeURIComponent(
`${MOCK_REMOTE_URL}/exchange`
)}`,
{
method: "POST",
}
);
const text = await resp.json();
expect(text).toMatchInlineSnapshot(
`
{
"prewarm": "TEST_PREWARM",
"token": "TEST_TOKEN",
}
`
);
});
it("should reject invalid exchange_url", async ({ expect }) => {
vi.spyOn(console, "error").mockImplementation(() => {});
const resp = await SELF.fetch(
`https://preview.devprod.cloudflare.dev/exchange?exchange_url=not_an_exchange_url`,
{ method: "POST" }
);
expect(resp.status).toBe(400);
expect(await resp.text()).toMatchInlineSnapshot(
`"{"error":"Error","message":"Invalid URL"}"`
);
});
it("should allow tokens > 4096 bytes", async ({ expect }) => {
// 4096 is the size limit for cookies
const token = randomBytes(4096).toString("hex");
Expand Down
10 changes: 7 additions & 3 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,13 +1312,13 @@ export class Miniflare {
} else if (url.pathname === "/browser/launch") {
const { sessionId, browserProcess, startTime, wsEndpoint } =
await launchBrowser({
// Puppeteer v22.8.2 supported chrome version:
// Puppeteer v22.13.1 supported chrome version:
// https://pptr.dev/supported-browsers#supported-browser-version-list
//
// It should match the supported chrome version for the upstream puppeteer
// version from which @cloudflare/puppeteer branched off, which is specified in:
// https://github.com/cloudflare/puppeteer/tree/v1.0.2?tab=readme-ov-file#workers-version-of-puppeteer-core
browserVersion: "124.0.6367.207",
// https://github.com/cloudflare/puppeteer/?tab=readme-ov-file#workers-version-of-puppeteer-core
browserVersion: "126.0.6478.182",
log: this.#log,
tmpPath: this.#tmpPath,
});
Expand Down Expand Up @@ -2143,6 +2143,10 @@ export class Miniflare {
if (previousEntryURL?.toString() !== this.#runtimeEntryURL.toString()) {
this.#runtimeDispatcher = new Pool(this.#runtimeEntryURL, {
connect: { rejectUnauthorized: false },
// Disable timeouts for local dev — long-running responses (streaming,
// slow uploads, long-polling) should not be killed by undici defaults.
headersTimeout: 0,
bodyTimeout: 0,
});
}
if (this.#proxyClient === undefined) {
Expand Down
4 changes: 4 additions & 0 deletions packages/miniflare/src/plugins/core/proxy/fetch-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ port.addEventListener("message", async (event) => {
dispatcherUrl = url;
dispatcher = new Pool(url, {
connect: { rejectUnauthorized: false },
// Disable timeouts for local dev — long-running responses (streaming,
// slow uploads, long-polling) should not be killed by undici defaults.
headersTimeout: 0,
bodyTimeout: 0,
});
}
headers["${CoreHeaders.OP_SYNC}"] = "true";
Expand Down
24 changes: 16 additions & 8 deletions packages/miniflare/src/workers/browser-rendering/binding.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,33 @@ export class BrowserSession extends DurableObject<Env> {
});

server.addEventListener("message", (m) => {
// both @cloudflare/puppeteer and @cloudflare/playwright send ping messges each second,
// both @cloudflare/puppeteer and @cloudflare/playwright send ping messages each second,
// so we use them to check the status of the browser
if (m.data === "ping") {
this.#checkStatus().catch((err) => {
console.error("Error checking browser status:", err);
});
return;
}

// HACK: TODO: Figure out what the chunking mechanism is in @cloudflare/puppeteer and unchunk the messages here, rather than just naively slicing off the header. This Worker should probably have the increase_websocket_message_size compat flag added
ws.send(new TextDecoder().decode((m.data as ArrayBuffer).slice(4)));
});
server.addEventListener("close", ({ code, reason }) => {
ws.close(code, reason);
const forwardClose = (ws: WebSocket, e: CloseEvent) => {
// Reserved codes 1005 (No Status Received) and 1006 (Abnormal Closure) are
// valid in CloseEvent but throw InvalidAccessError when passed to .close().
if (e.code === 1005 || e.code === 1006) {
ws.close();
} else {
ws.close(e.code, e.reason);
}
};
server.addEventListener("close", (e) => {
forwardClose(ws, e);
this.ws = undefined;
});
ws.addEventListener("close", ({ code, reason }) => {
server.close(code, reason);
ws.addEventListener("close", (e) => {
forwardClose(server, e);
this.server = undefined;
});
this.ws = ws;
Expand Down Expand Up @@ -117,10 +127,8 @@ export class BrowserSession extends DurableObject<Env> {
const resp = await this.env[CoreBindings.SERVICE_LOOPBACK].fetch(url);

if (!resp.ok) {
// Browser process has exited, we should close the WebSocket
// TODO should we send a error code?
// Browser process has exited, close the WebSockets
this.closeWebSockets();
return;
}
}
}
Expand Down
Loading
Loading