diff --git a/.changeset/cf-pages-env-vars.md b/.changeset/cf-pages-env-vars.md new file mode 100644 index 000000000000..243cd88a6086 --- /dev/null +++ b/.changeset/cf-pages-env-vars.md @@ -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://..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. diff --git a/fixtures/pages-functions-app/tests/index.test.ts b/fixtures/pages-functions-app/tests/index.test.ts index dda713002f0d..c722b3e12bb1 100644 --- a/fixtures/pages-functions-app/tests/index.test.ts +++ b/fixtures/pages-functions-app/tests/index.test.ts @@ -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 }) => { diff --git a/packages/wrangler/e2e/pages-dev.test.ts b/packages/wrangler/e2e/pages-dev.test.ts index c19e0d0e40c4..83bbce079449 100644 --- a/packages/wrangler/e2e/pages-dev.test.ts +++ b/packages/wrangler/e2e/pages-dev.test.ts @@ -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://." + ) + .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://....") Environment Variable local", ] `); }); @@ -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://: [wrangler:info] GET / 200 OK (TIMINGS)" @@ -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 " `); @@ -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; + + 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(); diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 91c5db24d9e1..04dd84c9fae2 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -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) => [ @@ -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( { diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index 79a89b70fa14..3dfedd782ab1 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -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; // 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; migrations?: DurableObjectMigration[]; containers?: ContainerApp[]; /** The triggers which will cause the worker's exported default handlers to be called. */ diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 14c4d7241e6f..2109b39e1b5f 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -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; vars?: Record; kv?: { binding: string; @@ -375,11 +380,19 @@ export type StartDevOptions = DevArguments & */ export function maskVars( bindings: CfWorkerInit["bindings"], - configParam: Config + configParam: Config, + defaultVars?: Record ) { 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)"; @@ -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, diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 6d1954e7c433..80f3ff3429bc 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -253,6 +253,7 @@ async function setupDevEnv( assets: undefined, }), }, + defaultVars: args.defaultVars, dev: { auth, remote: args.enablePagesAssetsServiceBinding diff --git a/packages/wrangler/src/pages/dev.ts b/packages/wrangler/src/pages/dev.ts index 2d65decbcb75..31e9df790b31 100644 --- a/packages/wrangler/src/pages/dev.ts +++ b/packages/wrangler/src/pages/dev.ts @@ -12,6 +12,7 @@ import { import { watch } from "chokidar"; import * as esbuild from "esbuild"; import { readConfig } from "../config"; +import { getConfigCache } from "../config-cache"; import { createCommand } from "../core/create-command"; import { isBuildFailure } from "../deployment-bundle/build-failures"; import { shouldCheckFetch } from "../deployment-bundle/bundle"; @@ -26,7 +27,11 @@ import { getBasePath } from "../paths"; import { debounce } from "../utils/debounce"; import * as shellquote from "../utils/shell-quote"; import { buildFunctions } from "./buildFunctions"; -import { ROUTES_SPEC_VERSION, SECONDS_TO_WAIT_FOR_PROXY } from "./constants"; +import { + PAGES_CONFIG_CACHE_FILENAME, + ROUTES_SPEC_VERSION, + SECONDS_TO_WAIT_FOR_PROXY, +} from "./constants"; import { FunctionsNoRoutesError, getFunctionsNoRoutesWarning } from "./errors"; import { buildRawWorker, @@ -37,6 +42,7 @@ import { validateRoutes } from "./functions/routes-validation"; import { CLEANUP, CLEANUP_CALLBACKS, getPagesTmpDir } from "./utils"; import type { AdditionalDevProps } from "../dev"; import type { RoutesJSONSpec } from "./functions/routes-transformation"; +import type { PagesConfigCache } from "./types"; import type { CfModule, Config, @@ -82,6 +88,55 @@ const DEFAULT_IP = process.platform === "win32" ? "127.0.0.1" : "localhost"; const DEFAULT_PAGES_LOCAL_PORT = 8788; const DEFAULT_SCRIPT_PATH = "_worker.js"; +/** + * Generates Pages-specific environment variables for local development. + * These variables are normally injected by Cloudflare Pages CI during builds/deployments. + * @see https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables + */ +function getPagesEnvironmentVariables( + projectName: string +): Record { + let branch = "local"; + + // Attempt to get actual git info to match what happens in Pages CI as accurately as possible. + try { + branch = execSync("git rev-parse --abbrev-ref HEAD", { + encoding: "utf-8", + stdio: "pipe", + }).trim(); + } catch { + // Not a git repo or git not available, use default + } + + let commitSha = "0000000000000000000000000000000000000000"; + try { + commitSha = execSync("git rev-parse HEAD", { + encoding: "utf-8", + stdio: "pipe", + }).trim(); + } catch { + // Not a git repo or git not available, use default + } + + // Use short SHA (fallback to 8 characters of main commit) for preview URL format + let shortSha = commitSha.substring(0, 8); + try { + shortSha = execSync("git rev-parse --short HEAD", { + encoding: "utf-8", + stdio: "pipe", + }).trim(); + } catch { + // Not a git repo or git not available, use default + } + + return { + CF_PAGES: "1", + CF_PAGES_BRANCH: branch, + CF_PAGES_COMMIT_SHA: commitSha, + CF_PAGES_URL: `https://${shortSha}.${projectName}.pages.dev`, + }; +} + export const pagesDevCommand = createCommand({ metadata: { description: "Develop your full-stack Pages application locally", @@ -873,6 +928,15 @@ export const pagesDevCommand = createCommand({ } } + // Determine project name from config, cache, or directory name (matching deploy behavior) + const configCache = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const projectName = + config.name ?? configCache.project_name ?? path.basename(process.cwd()); + + const pagesEnvVars = getPagesEnvironmentVariables(projectName); + const devServer = await run( { MULTIWORKER: Array.isArray(args.config), @@ -927,6 +991,9 @@ export const pagesDevCommand = createCommand({ compatibilityDate, compatibilityFlags, nodeCompat: undefined, + // CF_PAGES vars as defaults (can be overridden by config vars) + defaultVars: pagesEnvVars, + // CLI vars override everything vars, kv: kv_namespaces, durableObjects: do_bindings,