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
16 changes: 16 additions & 0 deletions .changeset/fix-zone-on-unstable-get-miniflare-worker-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"wrangler": patch
"@cloudflare/vite-plugin": patch
"@cloudflare/vitest-pool-workers": patch
---

Fix the outbound `CF-Worker` header reflecting the route pattern hostname instead of the parent zone, and falling back to `<worker-name>.example.com` under `vite dev`, `vitest-pool-workers`, and `getPlatformProxy`

Two related issues affected the `CF-Worker` header on outbound subrequests in local development:

1. Under `@cloudflare/vite-plugin`, `@cloudflare/vitest-pool-workers`, and `getPlatformProxy`, the header fell back to `<worker-name>.example.com` even when `routes` were configured, because `unstable_getMiniflareWorkerOptions` and the equivalent `getPlatformProxy` worker-options path did not propagate a `zone` value to Miniflare. This broke local development against services that reject unknown `CF-Worker` hosts (for example, Apple WeatherKit returns `403 Forbidden`).
2. Across the above paths and `wrangler dev --local`, when a route used the `zone_name` field (for example `{ pattern: "foo.example.com/*", zone_name: "example.com" }`), the header was set to the pattern's hostname (`foo.example.com`) rather than the zone name (`example.com`). Production [sets `CF-Worker` to the zone name that owns the Worker](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker), so this was inconsistent with deployed behaviour.

Both bugs are fixed: the new `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` path now propagates a `zone` derived from the first configured route, and all four local-dev paths now prefer a route's explicit `zone_name` over the pattern hostname when computing that zone. When `zone_name` isn't set, the existing best-effort behaviour is preserved — for `wrangler dev` this means `dev.host` is still honoured as a local override and the pattern hostname is used as a final fallback. Resolving the parent zone for `zone_id`-only, `custom_domain`, or plain-string routes would require an API lookup, so locally we still approximate it with the pattern hostname.

Note: `dev.host` is intentionally not consulted by the `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` paths — the `dev` config block is specific to `wrangler dev`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import http from "node:http";
import { afterAll, beforeAll, test } from "vitest";
import { fetchJson } from "../../__test-utils__";

// Regression test for https://github.com/cloudflare/workers-sdk/issues/13791.
//
// Under `vite dev`, outbound subrequests should carry a `CF-Worker` header
// matching the zone name that owns the Worker (see this fixture's
// `wrangler.jsonc`). Before the fix the plugin dropped the zone before
// handing the Worker to Miniflare and Miniflare fell back to
// `<worker-name>.example.com`. The fixture's route declares
// `zone_name: "example.com"` explicitly, so we expect that — not the
// pattern's hostname (`cf-worker-header-test.example.com`) — to be sent,
// matching production behaviour documented at
// https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker.

let echoServer: http.Server;
let echoUrl: string;

beforeAll(async () => {
echoServer = http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
cfWorker: req.headers["cf-worker"] ?? null,
})
);
});
// Bind to port 0 so the OS assigns a free port — avoids collisions with
// other playground fixtures that use fixed ports (e.g. `stream-binding`).
await new Promise<void>((resolve) =>
echoServer.listen(0, "127.0.0.1", resolve)
);
const address = echoServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected echoServer.address() to be an AddressInfo");
}
echoUrl = `http://127.0.0.1:${address.port}/`;
});

afterAll(async () => {
await new Promise<void>((resolve, reject) =>
echoServer.close((err) => (err ? reject(err) : resolve()))
);
});

test("outbound subrequests use the first configured route's zone name as the `CF-Worker` header", async ({
expect,
}) => {
const response = await fetchJson(
`/cf-worker-header?echoUrl=${encodeURIComponent(echoUrl)}`
);
expect(response).toEqual({ cfWorker: "example.com" });
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { WorkerEntrypoint } from "cloudflare:workers";

export default class extends WorkerEntrypoint {
override async fetch(): Promise<Response> {
override async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);

// Used by the `cf-worker-header.spec.ts` regression test for
// https://github.com/cloudflare/workers-sdk/issues/13791. The request
// URL — including the side server's port — is supplied as a query
// parameter so the test controls the fixture endpoint dynamically.
if (url.pathname === "/cf-worker-header") {
const echoUrl = url.searchParams.get("echoUrl");
if (!echoUrl) {
return new Response("missing echoUrl", { status: 400 });
}
return fetch(echoUrl);
}

return new Response("Hello cron trigger Worker playground!");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,16 @@
"triggers": {
"crons": ["* * * * *"], // every minute
},
// Used by the `cf-worker-header.spec.ts` regression test for
// https://github.com/cloudflare/workers-sdk/issues/13791. The plugin must
// derive the outbound `CF-Worker` header from the first configured route
// — preferring the explicit `zone_name` (per
// https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker)
// — instead of falling back to `<worker-name>.example.com`.
"routes": [
{
"pattern": "cf-worker-header-test.example.com/*",
"zone_name": "example.com",
},
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { writeWranglerConfig } from "@cloudflare/workers-utils/test-helpers";
import { describe, it } from "vitest";
import { unstable_getMiniflareWorkerOptions } from "../api";
import { runInTempDir } from "./helpers/run-in-tmp";

describe("unstable_getMiniflareWorkerOptions", () => {
runInTempDir();

describe("zone derivation (used for the outbound CF-Worker header)", () => {
Comment thread
petebacondarwin marked this conversation as resolved.
it("derives the zone from a single `route` string", ({ expect }) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
route: "https://example.com/api/*",
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
expect(workerOptions.zone).toBe("example.com");
});

it("uses the first entry in `routes`, preferring its `zone_name`", ({
expect,
}) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
routes: [
{ pattern: "foo.example.com/*", zone_name: "example.com" },
"bar.example.com/*",
],
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
// Per https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker
// the `CF-Worker` header is the zone name that owns the Worker, not
// the route pattern's hostname.
expect(workerOptions.zone).toBe("example.com");
});

it("falls back to the pattern hostname when `zone_name` is absent", ({
expect,
}) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
routes: [{ pattern: "foo.example.com/*", zone_id: "abc123" }],
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
// Without `zone_name` or an account-scoped API lookup we can't
// determine the parent zone locally, so the pattern hostname is
// the closest approximation.
expect(workerOptions.zone).toBe("foo.example.com");
});

it("uses `zone_name` for unparseable patterns like `*/*`", ({ expect }) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
routes: [{ pattern: "*/*", zone_name: "example.com" }],
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
expect(workerOptions.zone).toBe("example.com");
});

it("ignores `dev.host` (the `dev` config block is `wrangler dev`-only)", ({
expect,
}) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
dev: {
host: "ignored.example.com",
},
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
expect(workerOptions.zone).toBeUndefined();
});

it("derives the zone from `routes` even when `dev.host` is also set", ({
Comment thread
petebacondarwin marked this conversation as resolved.
expect,
}) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
dev: {
host: "ignored.example.com",
},
routes: [{ pattern: "foo.example.com/*", zone_name: "example.com" }],
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
expect(workerOptions.zone).toBe("example.com");
});

it("returns undefined when no routes are configured", ({ expect }) => {
writeWranglerConfig(
{
name: "test-worker",
main: "./index.js",
compatibility_date: "2024-10-04",
},
"./wrangler.json"
);
const { workerOptions } =
unstable_getMiniflareWorkerOptions("./wrangler.json");
expect(workerOptions.zone).toBeUndefined();
});
});
});
89 changes: 87 additions & 2 deletions packages/wrangler/src/__tests__/zones.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { COMPLIANCE_REGION_CONFIG_UNKNOWN } from "@cloudflare/workers-utils";
import { http, HttpResponse } from "msw";
// eslint-disable-next-line no-restricted-imports
import { describe, expect, test } from "vitest";
import { getHostFromUrl, getZoneForRoute } from "../zones";
import { describe, expect, it, test } from "vitest";
import { getHostFromUrl, getZoneForRoute, getZoneFromRoute } from "../zones";
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
import { msw } from "./helpers/msw";

Expand Down Expand Up @@ -177,6 +177,91 @@ describe("Zones", () => {
id: "example-id",
});
});
// Tests for the new `getZoneFromRoute` helper used to derive the
// `CF-Worker` outbound header value in local development. Per the docs
// (https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker)
// the production header is the *zone name* that owns the Worker — not
// the route pattern's hostname. We honour that when the user has told
// us the zone name in their config; otherwise we approximate it with
// the pattern hostname, since we can't perform an API lookup here.
describe("getZoneFromRoute", () => {
it("returns the URL host for a SimpleRoute (string)", ({ expect }) => {
expect(getZoneFromRoute("https://example.com/api/*")).toBe(
"example.com"
);
expect(getZoneFromRoute("foo.example.com/*")).toBe("foo.example.com");
});

it("returns `zone_name` for a ZoneNameRoute (subdomain pattern)", ({
expect,
}) => {
expect(
getZoneFromRoute({
pattern: "foo.example.com/*",
zone_name: "example.com",
})
).toBe("example.com");
});

it("returns `zone_name` for a ZoneNameRoute (apex pattern)", ({
expect,
}) => {
expect(
getZoneFromRoute({
pattern: "example.com/*",
zone_name: "example.com",
})
).toBe("example.com");
});

it("returns `zone_name` for a ZoneNameRoute with the unparseable `*/*` pattern", ({
expect,
}) => {
expect(
getZoneFromRoute({
pattern: "*/*",
zone_name: "example.com",
})
).toBe("example.com");
});

it("returns `undefined` when the pattern is unparseable and no `zone_name` is available", ({
expect,
}) => {
// With neither a parseable hostname nor a `zone_name` to fall
// back on we can't approximate the zone at all — let Miniflare
// apply its default of `<worker-name>.example.com`.
expect(getZoneFromRoute("*/*")).toBeUndefined();
expect(
getZoneFromRoute({ pattern: "*/*", zone_id: "abc123" })
).toBeUndefined();
});

it("falls back to the pattern hostname for a ZoneIdRoute", ({
expect,
}) => {
// Without an API lookup we can't resolve a zone_id to its name;
// the pattern hostname is the best local approximation.
expect(
getZoneFromRoute({
pattern: "foo.example.com/*",
zone_id: "abc123",
})
).toBe("foo.example.com");
});

it("falls back to the pattern hostname for a CustomDomainRoute", ({
expect,
}) => {
expect(
getZoneFromRoute({
pattern: "custom.example.com",
custom_domain: true,
})
).toBe("custom.example.com");
});
});

test("zone_name route (subdomain, subsequent fetches are cached)", async () => {
mockGetZones("example.com", [{ id: "example-id" }]);
const zoneIdCache = new Map();
Expand Down
Loading
Loading