Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/cf-pages-env-vars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"wrangler": minor
---

Add CF_PAGES environment variables to `wrangler pages dev`

`wrangler pages dev` now automatically injects Pages-specific environment variables (`CF_PAGES`, `CF_PAGES_BRANCH`, `CF_PAGES_COMMIT_SHA`, `CF_PAGES_URL`) for improved dev/prod parity. This enables frameworks like SvelteKit to auto-detect the Pages environment during local development.

- `CF_PAGES` is set to `"1"` to indicate the Pages environment
- `CF_PAGES_BRANCH` defaults to the current git branch (or `"local"` if not in a git repo)
- `CF_PAGES_COMMIT_SHA` defaults to the current git commit SHA (or a placeholder if not in a git repo)
- `CF_PAGES_URL` is set to a simulated commit preview URL (e.g., `https://<sha>.<project-name>.pages.dev`)

These variables are displayed with their actual values in the bindings table during startup, making it easy to verify what branch and commit SHA were detected.

These variables can be overridden by user-defined vars in the Wrangler configuration, `.env`, `.dev.vars`, or via CLI flags.
27 changes: 15 additions & 12 deletions fixtures/pages-functions-app/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,21 @@ describe("Pages Functions", () => {
it("passes environment variables", async ({ expect }) => {
const response = await fetch(`http://${ip}:${port}/variables`);
const env = await response.json();
expect(env).toEqual({
ASSETS: {},
bucket: {},
NAME: "VALUE",
OTHER_NAME: "THING=WITH=EQUALS",
VAR_1: "var #1 value",
VAR_3: "var #3 value",
VAR_MULTI_LINE_1: "A: line 1\nline 2",
VAR_MULTI_LINE_2: "B: line 1\nline 2",
EMPTY: "",
UNQUOTED: "unquoted value", // Note that whitespace is trimmed
});
// Use objectContaining to allow for additional CF_PAGES_* variables
expect(env).toEqual(
expect.objectContaining({
ASSETS: {},
bucket: {},
NAME: "VALUE",
OTHER_NAME: "THING=WITH=EQUALS",
VAR_1: "var #1 value",
VAR_3: "var #3 value",
VAR_MULTI_LINE_1: "A: line 1\nline 2",
VAR_MULTI_LINE_2: "B: line 1\nline 2",
EMPTY: "",
UNQUOTED: "unquoted value", // Note that whitespace is trimmed
})
);
});

it("intercepts static requests with next()", async ({ expect }) => {
Expand Down
139 changes: 111 additions & 28 deletions packages/wrangler/e2e/pages-dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,28 @@ describe.sequential("wrangler pages dev", () => {
);
const bindings = Array.from(
(bindingMessages[1] ?? "").matchAll(/env\.[^\n]+/g)
).flat();
)
.flat()
.map((line) =>
// Normalize the CF_PAGES_URL which contains a generated project name
line
.replace(
/https:\/\/[a-f0-9]+\.wrangler-smoke-[^.]+/,
"https://<HASH>.<PROJECT_NAME>"
)
.replaceAll(/\s+/g, " ")
);
expect(bindings).toMatchInlineSnapshot(`
[
"env.TEST_DO (TestDurableObject, defined in a) Durable Object local [not connected]",
"env.TEST_KV (TEST_KV) KV Namespace local",
"env.TEST_D1 (local-TEST_D1) D1 Database local",
"env.TEST_R2 (TEST_R2) R2 Bucket local",
"env.TEST_SERVICE (test-worker) Worker local [not connected]",
"env.TEST_DO (TestDurableObject, defined in a) Durable Object local [not connected]",
"env.TEST_KV (TEST_KV) KV Namespace local",
"env.TEST_D1 (local-TEST_D1) D1 Database local",
"env.TEST_R2 (TEST_R2) R2 Bucket local",
"env.TEST_SERVICE (test-worker) Worker local [not connected]",
"env.CF_PAGES ("1") Environment Variable local",
"env.CF_PAGES_BRANCH ("local") Environment Variable local",
"env.CF_PAGES_COMMIT_SHA ("0000000000000000000000000000000000000...") Environment Variable local",
"env.CF_PAGES_URL ("https://<HASH>.<PROJECT_NAME>...") Environment Variable local",
]
`);
});
Expand Down Expand Up @@ -325,9 +339,13 @@ describe.sequential("wrangler pages dev", () => {
expect(normalizeOutput(worker.currentOutput)).toMatchInlineSnapshot(`
"✨ Compiled Worker successfully
Your Worker has access to the following bindings:
Binding Resource Mode
env.KV_BINDING_TOML (KV_ID_TOML) KV Namespace local
env.PAGES ("⚡️ Pages ⚡️") Environment Variable local
Binding Resource Mode
env.KV_BINDING_TOML (KV_ID_TOML) KV Namespace local
env.CF_PAGES ("1") Environment Variable local
env.CF_PAGES_BRANCH ("local") Environment Variable local
env.CF_PAGES_COMMIT_SHA ("0000000000000000000000000000000000000...") Environment Variable local
env.CF_PAGES_URL ("https://00000000.pages-project.pages....") Environment Variable local
env.PAGES ("⚡️ Pages ⚡️") Environment Variable local
⎔ Starting local server...
[wrangler:info] Ready on http://<HOST>:<PORT>
[wrangler:info] GET / 200 OK (TIMINGS)"
Expand Down Expand Up @@ -429,25 +447,29 @@ describe.sequential("wrangler pages dev", () => {
expect(prestartOutput).toMatchInlineSnapshot(`
"✨ Compiled Worker successfully
Your Worker has access to the following bindings:
Binding Resource Mode
env.DO_BINDING_1_TOML (NEW_DO_1, defined in NEW_DO_SCRIPT_1) Durable Object local [not connected]
env.DO_BINDING_2_TOML (DO_2_TOML, defined in DO_SCRIPT_2_TOML) Durable Object local [not connected]
env.DO_BINDING_3_ARGS (DO_3_ARGS, defined in DO_SCRIPT_3_ARGS) Durable Object local [not connected]
env.KV_BINDING_1_TOML (NEW_KV_ID_1) KV Namespace local
env.KV_BINDING_2_TOML (KV_ID_2_TOML) KV Namespace local
env.KV_BINDING_3_ARGS (KV_ID_3_ARGS) KV Namespace local
env.D1_BINDING_1_TOML (local-D1_BINDING_1_TOML=NEW_D1_NAME_1) D1 Database local
env.D1_BINDING_2_TOML (D1_NAME_2_TOML) D1 Database local
env.D1_BINDING_3_ARGS (local-D1_BINDING_3_ARGS=D1_NAME_3_ARGS) D1 Database local
env.R2_BINDING_1_TOML (new-r2-bucket-1) R2 Bucket local
env.R2_BINDING_2_TOML (r2-bucket-2-toml) R2 Bucket local
env.R2_BINDING_3_TOML (r2-bucket-3-args) R2 Bucket local
env.SERVICE_BINDING_1_TOML (NEW_SERVICE_NAME_1) Worker local [not connected]
env.SERVICE_BINDING_2_TOML (SERVICE_NAME_2_TOML) Worker local [not connected]
env.SERVICE_BINDING_3_TOML (SERVICE_NAME_3_ARGS) Worker local [not connected]
env.VAR1 ("(hidden)") Environment Variable local
env.VAR2 ("VAR_2_TOML") Environment Variable local
env.VAR3 ("(hidden)") Environment Variable local
Binding Resource Mode
env.DO_BINDING_1_TOML (NEW_DO_1, defined in NEW_DO_SCRIPT_1) Durable Object local [not connected]
env.DO_BINDING_2_TOML (DO_2_TOML, defined in DO_SCRIPT_2_TOML) Durable Object local [not connected]
env.DO_BINDING_3_ARGS (DO_3_ARGS, defined in DO_SCRIPT_3_ARGS) Durable Object local [not connected]
env.KV_BINDING_1_TOML (NEW_KV_ID_1) KV Namespace local
env.KV_BINDING_2_TOML (KV_ID_2_TOML) KV Namespace local
env.KV_BINDING_3_ARGS (KV_ID_3_ARGS) KV Namespace local
env.D1_BINDING_1_TOML (local-D1_BINDING_1_TOML=NEW_D1_NAME_1) D1 Database local
env.D1_BINDING_2_TOML (D1_NAME_2_TOML) D1 Database local
env.D1_BINDING_3_ARGS (local-D1_BINDING_3_ARGS=D1_NAME_3_ARGS) D1 Database local
env.R2_BINDING_1_TOML (new-r2-bucket-1) R2 Bucket local
env.R2_BINDING_2_TOML (r2-bucket-2-toml) R2 Bucket local
env.R2_BINDING_3_TOML (r2-bucket-3-args) R2 Bucket local
env.SERVICE_BINDING_1_TOML (NEW_SERVICE_NAME_1) Worker local [not connected]
env.SERVICE_BINDING_2_TOML (SERVICE_NAME_2_TOML) Worker local [not connected]
env.SERVICE_BINDING_3_TOML (SERVICE_NAME_3_ARGS) Worker local [not connected]
env.CF_PAGES ("1") Environment Variable local
env.CF_PAGES_BRANCH ("local") Environment Variable local
env.CF_PAGES_COMMIT_SHA ("0000000000000000000000000000000000000...") Environment Variable local
env.CF_PAGES_URL ("https://00000000.pages-project.pages....") Environment Variable local
env.VAR1 ("(hidden)") Environment Variable local
env.VAR2 ("VAR_2_TOML") Environment Variable local
env.VAR3 ("(hidden)") Environment Variable local
Service bindings, Durable Object bindings, and Tail consumers connect to other Wrangler or Vite dev processes running locally, with their connection status indicated by [connected] or [not connected]. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development
"
`);
Expand Down Expand Up @@ -481,6 +503,67 @@ describe.sequential("wrangler pages dev", () => {
);
});

it("should inject CF_PAGES environment variables", async () => {
const helper = new WranglerE2ETestHelper();
await helper.seed({
"_worker.js": dedent`
export default {
fetch(request, env) {
return Response.json({
CF_PAGES: env.CF_PAGES,
CF_PAGES_BRANCH: env.CF_PAGES_BRANCH,
CF_PAGES_COMMIT_SHA: env.CF_PAGES_COMMIT_SHA,
CF_PAGES_URL: env.CF_PAGES_URL,
});
}
}`,
});
const worker = helper.runLongLived(
`${cmd} --port ${port} --inspector-port ${inspectorPort} .`
);
const { url } = await worker.waitForReady();

const response = await fetch(url);
const data = (await response.json()) as Record<string, string>;

expect(data).toEqual({
CF_PAGES: "1",
CF_PAGES_BRANCH: expect.any(String),
CF_PAGES_COMMIT_SHA: expect.any(String),
CF_PAGES_URL: expect.stringMatching(
/^https:\/\/[a-f0-9]{8}\..*\.pages\.dev$/
),
});
});

it("should allow user to override CF_PAGES... environment variables", async () => {
const helper = new WranglerE2ETestHelper();
await helper.seed({
"_worker.js": dedent`
export default {
fetch(request, env) {
return new Response(env.CF_PAGES_BRANCH + " " + env.CF_PAGES_COMMIT_SHA);
}
}`,
"wrangler.toml": dedent`
name = "test-pages"
pages_build_output_dir = "."
compatibility_date = "2024-01-01"

[vars]
CF_PAGES_BRANCH = "custom-branch"
CF_PAGES_COMMIT_SHA = "custom-sha"
`,
});
const worker = helper.runLongLived(
`${cmd} --port ${port} --inspector-port ${inspectorPort}`
);
const { url } = await worker.waitForReady();

const text = await fetchText(url);
expect(text).toBe("custom-branch custom-sha");
});

describe("watch mode", () => {
it("should modify worker during dev session (Functions)", async () => {
const helper = new WranglerE2ETestHelper();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ async function resolveBindings(
input.envFiles,
!input.dev?.remote,
{
defaultVars: input.defaultVars,
kv: extractBindingsOfType("kv_namespace", input.bindings),
vars: Object.fromEntries(
extractBindingsOfType("plain_text", input.bindings).map((b) => [
Expand All @@ -215,7 +216,7 @@ async function resolveBindings(

// Create a print function that captures the current bindings context
const printCurrentBindings = (registry: WorkerRegistry | null) => {
const maskedVars = maskVars(bindings, config);
const maskedVars = maskVars(bindings, config, input.defaultVars);

printBindings(
{
Expand Down
5 changes: 5 additions & 0 deletions packages/wrangler/src/api/startDevWorker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export interface StartDevWorkerInput {

/** The bindings available to the worker. The specified bindind type will be exposed to the worker on the `env` object under the same key. */
bindings?: Record<string, Binding>; // Type level constraint for bindings not sharing names
/**
* Default vars that can be overridden by config vars.
* Useful for injecting environment-specific defaults like CF_PAGES variables.
*/
defaultVars?: Record<string, string>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry quick review and I don't have time to dig too much into the details right now - also this is probably something to add the PR desc.

Why would StartDevWorkerInput received defaults?
Shoudn't the values be resolved by the caller instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really good question. The problem is that if we merged them at the caller (say into the vars) they would then take priority over the other possible sources such as the config file and the .env file. These values are fallbacks for when they are not specified in any other case.

migrations?: DurableObjectMigration[];
containers?: ContainerApp[];
/** The triggers which will cause the worker's exported default handlers to be called. */
Expand Down
20 changes: 18 additions & 2 deletions packages/wrangler/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ export const dev = createCommand({
});

export type AdditionalDevProps = {
/**
* Default vars that can be overridden by config vars.
* Useful for injecting environment-specific defaults like CF_PAGES variables.
*/
defaultVars?: Record<string, string>;
vars?: Record<string, string | Json>;
kv?: {
binding: string;
Expand Down Expand Up @@ -375,11 +380,19 @@ export type StartDevOptions = DevArguments &
*/
export function maskVars(
bindings: CfWorkerInit["bindings"],
configParam: Config
configParam: Config,
defaultVars?: Record<string, string>
) {
const maskedVars = { ...bindings.vars };
for (const key of Object.keys(maskedVars)) {
if (maskedVars[key] !== configParam.vars[key]) {
// Don't mask if:
// 1. The value matches what's in config (wrangler.toml), OR
// 2. The value matches a default var (e.g., CF_PAGES vars which are not secrets)
const isFromConfig = maskedVars[key] === configParam.vars[key];
const isUnchangedDefault =
defaultVars?.[key] !== undefined && maskedVars[key] === defaultVars[key];

if (!isFromConfig && !isUnchangedDefault) {
// This means it was overridden in .dev.vars
// so let's mask it
maskedVars[key] = "(hidden)";
Expand Down Expand Up @@ -627,6 +640,9 @@ export function getBindings(

// non-inheritable fields
vars: {
// defaultVars provide baseline values (e.g., CF_PAGES vars for Pages dev)
// that can be overridden by config vars, .env, .dev.vars, and CLI args
...args.defaultVars,
// Use a copy of combinedVars since we're modifying it later
...getVarsForDev(
configParam.userConfigPath,
Expand Down
1 change: 1 addition & 0 deletions packages/wrangler/src/dev/start-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ async function setupDevEnv(
assets: undefined,
}),
},
defaultVars: args.defaultVars,
dev: {
auth,
remote: args.enablePagesAssetsServiceBinding
Expand Down
Loading
Loading