diff --git a/.changeset/slow-dolphins-decide.md b/.changeset/slow-dolphins-decide.md new file mode 100644 index 000000000000..d4d8d1b38928 --- /dev/null +++ b/.changeset/slow-dolphins-decide.md @@ -0,0 +1,15 @@ +--- +"wrangler": patch +--- + +feat: support extending the user configuration + +The main goal here is to enable tools to generate a partial configuration file that is merged into the user configuration when Wrangler commands are run. + +The file must be written to `./.wrangler/config/extra.json`, where the path is relative to the project path, which is the directory containing the wrangler.toml or the current working directory if there is no wrangler.toml. + +The format of the file is a JSON object whose properties are the inheritable and non-inheritable options described in the Wrangler configuration documentation. Notably it cannot contain the "top level" configuration properties. + +The contents of the file will be merged into the configuration of the currently selected environment before being used in all Wrangler commands. + +The user does not need to manually specify that this merging should happen. It is done automatically when the file is found. diff --git a/.vscode/settings.json b/.vscode/settings.json index 749a67f2520b..022e89e123ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "cloudflareaccess", "cloudflared", "Codespaces", + "defu", "esbuild", "eslintcache", "execa", diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 6f665dcae218..920eeae7bd21 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -61,7 +61,7 @@ "generate-json-schema": "pnpm exec ts-json-schema-generator --no-type-check --path src/config/config.ts --type RawConfig --out config-schema.json", "prepublishOnly": "SOURCEMAPS=false pnpm run -w build", "start": "pnpm run bundle && cross-env NODE_OPTIONS=--enable-source-maps ./bin/wrangler.js", - "test": "pnpm run assert-git-version && vitest", + "test": "pnpm run assert-git-version && cross-env LC_ALL=C vitest", "test:ci": "pnpm run test run", "test:debug": "pnpm run test --silent=false --verbose=true", "test:e2e": "vitest -c ./e2e/vitest.config.mts", @@ -125,6 +125,7 @@ "cmd-shim": "^4.1.0", "command-exists": "^1.2.9", "concurrently": "^8.2.2", + "defu": "^6.1.4", "devtools-protocol": "^0.0.1182435", "dotenv": "^16.0.0", "execa": "^6.1.0", diff --git a/packages/wrangler/src/__tests__/configuration.test.ts b/packages/wrangler/src/__tests__/configuration.test.ts index 7490024f4761..fd99ef62392d 100644 --- a/packages/wrangler/src/__tests__/configuration.test.ts +++ b/packages/wrangler/src/__tests__/configuration.test.ts @@ -1,9 +1,13 @@ import path from "node:path"; import { readConfig } from "../config"; import { normalizeAndValidateConfig } from "../config/validation"; +import { mockConsoleMethods } from "./helpers/mock-console"; import { normalizeString } from "./helpers/normalize"; import { runInTempDir } from "./helpers/run-in-tmp"; -import { writeWranglerToml } from "./helpers/write-wrangler-toml"; +import { + writeExtraJson, + writeWranglerToml, +} from "./helpers/write-wrangler-toml"; import type { ConfigFields, RawConfig, @@ -12,6 +16,7 @@ import type { } from "../config"; describe("readConfig()", () => { + const std = mockConsoleMethods(); runInTempDir(); it("should not error if a python entrypoint is used with the right compatibility_flag", () => { writeWranglerToml({ @@ -43,6 +48,231 @@ describe("readConfig()", () => { ); } }); + + describe("extended configuration", () => { + it("should extend the user config with config from .wrangler/config/extra.json", () => { + const main = "src/index.ts"; + const resolvedMain = path.resolve(process.cwd(), main); + + writeWranglerToml({ + main, + }); + writeExtraJson({ + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + }); + + const config = readConfig("wrangler.toml", {}); + expect(config).toEqual( + expect.objectContaining({ + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + configPath: "wrangler.toml", + main: resolvedMain, + }) + ); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "Loading additional configuration from .wrangler/config/extra.json.", + "out": "", + "warn": "", + } + `); + }); + + it("should overwrite config with matching properties from .wrangler/config/extra.json", () => { + writeWranglerToml({ + main: "src/index.js", + compatibility_date: "2021-01-01", + }); + + // Note that paths are relative ot the extra.json file. + const main = "../../dist/index.ts"; + const resolvedMain = path.resolve( + process.cwd(), + ".wrangler/config", + main + ); + + writeExtraJson({ + main, + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + }); + + const config = readConfig("wrangler.toml", {}); + expect(config).toEqual( + expect.objectContaining({ + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + main: resolvedMain, + }) + ); + }); + + it("should concatenate array-based config with matching properties from .wrangler/config/extra.json", () => { + writeWranglerToml({ + main: "src/index.js", + compatibility_flags: ["allow_custom_ports"], + }); + + writeExtraJson({ + compatibility_flags: ["nodejs_compat"], + }); + + const config = readConfig("wrangler.toml", {}); + expect(config).toEqual( + expect.objectContaining({ + compatibility_flags: ["nodejs_compat", "allow_custom_ports"], + }) + ); + }); + + it("should merge object-based config with matching properties from .wrangler/config/extra.json", () => { + writeWranglerToml({ + main: "src/index.js", + assets: { + directory: "./public", + not_found_handling: "404-page", + }, + }); + + writeExtraJson({ + assets: { + // Note that Environment validation and typings require that directory exists, + // so the extra.json would always have to provide this property + // even if it just wanted to augment the rest of the object with extra properties. + directory: "./public", + binding: "ASSETS", + }, + }); + + const config = readConfig("wrangler.toml", {}); + expect(config).toEqual( + expect.objectContaining({ + assets: { + binding: "ASSETS", + directory: "./public", + not_found_handling: "404-page", + }, + }) + ); + }); + + it("should error and warn if the extra config is not valid Environment config", () => { + writeWranglerToml({}); + writeExtraJson({ + compatibility_date: 2021, + unexpected_property: true, + } as unknown as RawEnvironment); + + let error = new Error("Missing expected error"); + try { + readConfig("wrangler.toml", {}); + } catch (e) { + error = e as Error; + } + + expect(error.toString().replaceAll("\\", "/")).toMatchInlineSnapshot(` + "Error: Processing wrangler.toml configuration: + + - Processing extra configuration found in .wrangler/config/extra.json. + - Expected \\"compatibility_date\\" to be of type string but got 2021." + `); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "Loading additional configuration from .wrangler/config/extra.json.", + "out": "", + "warn": "▲ [WARNING] Processing wrangler.toml configuration: + + + - Processing extra configuration found in .wrangler/config/extra.json. + - Unexpected fields found in extended config field: \\"unexpected_property\\" + + ", + } + `); + }); + + it("should override the selected named environment", () => { + const main = "src/index.ts"; + const resolvedMain = path.resolve(process.cwd(), main); + + writeWranglerToml({ + main, + env: { + prod: { + compatibility_date: "2021-01-01", + logpush: true, + }, + dev: { + compatibility_date: "2022-02-02", + no_bundle: true, + }, + }, + }); + writeExtraJson({ + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + no_bundle: true, + }); + + const prodConfig = readConfig("wrangler.toml", { env: "prod" }); + expect(prodConfig).toEqual( + expect.objectContaining({ + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + configPath: "wrangler.toml", + main: resolvedMain, + logpush: true, + no_bundle: true, + }) + ); + + const devConfig = readConfig("wrangler.toml", { env: "dev" }); + expect(devConfig).toEqual( + expect.objectContaining({ + compatibility_date: "2024-11-01", + compatibility_flags: ["nodejs_compat"], + configPath: "wrangler.toml", + main: resolvedMain, + no_bundle: true, + }) + ); + }); + + it("should support extending with a Pages output directory property", () => { + const pages_build_output_dir = "./public"; + + writeWranglerToml({}); + writeExtraJson({ + pages_build_output_dir, + }); + + const config = readConfig("wrangler.toml", {}); + expect(config).toEqual( + expect.objectContaining({ + pages_build_output_dir: "./public", + }) + ); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "Loading additional configuration from .wrangler/config/extra.json.", + "out": "", + "warn": "", + } + `); + }); + }); }); describe("normalizeAndValidateConfig()", () => { diff --git a/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts b/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts index 8f22904dd46c..f2730148df6c 100644 --- a/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts +++ b/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts @@ -1,6 +1,8 @@ import * as fs from "fs"; import TOML from "@iarna/toml"; -import type { RawConfig } from "../../config"; +import { PagesConfigFields } from "../../config/config"; +import { ensureDirectoryExistsSync } from "../../utils/filesystem"; +import type { RawConfig, RawEnvironment } from "../../config"; /** Write a mock wrangler.toml file to disk. */ export function writeWranglerToml( @@ -33,3 +35,11 @@ export function writeWranglerJson( "utf-8" ); } + +export function writeExtraJson( + config: RawEnvironment & Partial = {}, + path = "./.wrangler/config/extra.json" +) { + ensureDirectoryExistsSync(path); + fs.writeFileSync(path, JSON.stringify(config), "utf-8"); +} diff --git a/packages/wrangler/src/__tests__/vitest.setup.ts b/packages/wrangler/src/__tests__/vitest.setup.ts index 499013348dcb..501d8850e033 100644 --- a/packages/wrangler/src/__tests__/vitest.setup.ts +++ b/packages/wrangler/src/__tests__/vitest.setup.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ +import assert from "node:assert"; import { resolve } from "path"; import { PassThrough } from "stream"; import chalk from "chalk"; @@ -22,8 +23,10 @@ chalk.level = 0; global as unknown as { __RELATIVE_PACKAGE_PATH__: string } ).__RELATIVE_PACKAGE_PATH__ = ".."; -// Set `LC_ALL` to fix the language as English for the messages thrown by Yargs. -process.env.LC_ALL = "en"; +assert( + process.env.LC_ALL === "C", + "Expected `LC_ALL` env var to be 'C' in order get deterministic localized messages in tests" +); vi.mock("ansi-escapes", () => { return { diff --git a/packages/wrangler/src/config/config.ts b/packages/wrangler/src/config/config.ts index a09d278a4faa..26e0dbc67e58 100644 --- a/packages/wrangler/src/config/config.ts +++ b/packages/wrangler/src/config/config.ts @@ -30,18 +30,6 @@ export type RawConfig = Partial> & DeprecatedConfigFields & EnvironmentMap & { $schema?: string }; -// Pages-specific configuration fields -interface PagesConfigFields { - /** - * The directory of static assets to serve. - * - * The presence of this field in `wrangler.toml` indicates a Pages project, - * and will prompt the handling of the configuration file according to the - * Pages-specific validation rules. - */ - pages_build_output_dir?: string; -} - export interface ConfigFields { configPath: string | undefined; @@ -185,7 +173,7 @@ export interface ConfigFields { } // Pages-specific configuration fields -interface PagesConfigFields { +export interface PagesConfigFields { /** * The directory of static assets to serve. * diff --git a/packages/wrangler/src/config/extra.ts b/packages/wrangler/src/config/extra.ts new file mode 100644 index 000000000000..2b462201a078 --- /dev/null +++ b/packages/wrangler/src/config/extra.ts @@ -0,0 +1,87 @@ +import { existsSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { defu } from "defu"; +import { logger } from "../logger"; +import { parseJSONC, readFileSync } from "../parse"; +import { Diagnostics } from "./diagnostics"; +import { normalizeAndValidateEnvironment } from "./validation"; +import { validateAdditionalProperties } from "./validation-helpers"; +import type { Config, PagesConfigFields } from "./config"; +import type { Environment } from "./environment"; + +/** + * Merge additional configuration loaded from `.wrangler/config/extra.json`, + * if it exists, into the user provided configuration. + */ +export function extendConfiguration( + configPath: string | undefined, + userConfig: Config, + hideMessages: boolean +): { config: Config; diagnostics: Diagnostics } { + // Handle extending the user configuration + const extraPath = getExtraConfigPath(configPath && dirname(configPath)); + const extra = loadExtraConfig(extraPath); + if (extra === undefined) { + return { config: userConfig, diagnostics: new Diagnostics("") }; + } + + if (!hideMessages) { + logger.info( + `Loading additional configuration from ${relative(process.cwd(), extraPath)}.` + ); + } + + return { + config: defu(extra.config, userConfig), + diagnostics: extra.diagnostics, + }; +} + +/** + * Get the path to a file that might contain additional configuration to be merged into the user's configuration. + * + * This supports the case where a custom build tool wants to extend the user's configuration as well as pre-bundled files. + */ +function getExtraConfigPath(projectRoot: string | undefined): string { + return resolve(projectRoot ?? ".", ".wrangler/config/extra.json"); +} + +/** + * Attempt to load and validate extra config from the `.wrangler/config/extra.json` file if it exists. + */ +function loadExtraConfig(configPath: string): + | { + config: Environment & PagesConfigFields; + diagnostics: Diagnostics; + } + | undefined { + if (!existsSync(configPath)) { + return undefined; + } + + const diagnostics = new Diagnostics( + `Processing extra configuration found in ${relative(process.cwd(), configPath)}.` + ); + const raw = parseJSONC( + readFileSync(configPath), + configPath + ); + const config = normalizeAndValidateEnvironment( + diagnostics, + configPath, + raw, + /* isDispatchNamespace */ false + ); + + validateAdditionalProperties( + diagnostics, + "extended config", + Object.keys(raw), + [...Object.keys(config), "pages_build_output_dir"] + ); + + return { + config: { ...config, pages_build_output_dir: raw.pages_build_output_dir }, + diagnostics, + }; +} diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index b1973e748b63..42039d425ba4 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -7,6 +7,7 @@ import { getFlag } from "../experimental-flags"; import { logger } from "../logger"; import { EXIT_CODE_INVALID_PAGES_CONFIG } from "../pages/errors"; import { parseJSONC, parseTOML, readFileSync } from "../parse"; +import { extendConfiguration } from "./extra"; import { isPagesConfig, normalizeAndValidateConfig } from "./validation"; import { validatePagesConfig } from "./validation-pages"; import type { CfWorkerInit } from "../deployment-bundle/worker"; @@ -85,6 +86,18 @@ export function readConfig( } } + // Process the top-level configuration. This is common for both + // Workers and Pages + let { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + configPath, + args + ); + + const extra = extendConfiguration(configPath, config, hideWarnings); + config = extra.config; + diagnostics.addChild(extra.diagnostics); + /** * Check if configuration file belongs to a Pages project. * @@ -115,14 +128,6 @@ export function readConfig( ); } - // Process the top-level configuration. This is common for both - // Workers and Pages - const { config, diagnostics } = normalizeAndValidateConfig( - rawConfig, - configPath, - args - ); - if (diagnostics.hasWarnings() && !hideWarnings) { logger.warn(diagnostics.renderWarnings()); } diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index ebd47750cbce..d329b53726d4 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -1028,7 +1028,7 @@ const validateTailConsumers: ValidatorFn = (diagnostics, field, value) => { /** * Validate top-level environment configuration and return the normalized values. */ -function normalizeAndValidateEnvironment( +export function normalizeAndValidateEnvironment( diagnostics: Diagnostics, configPath: string | undefined, topLevelEnv: RawEnvironment, @@ -1037,7 +1037,7 @@ function normalizeAndValidateEnvironment( /** * Validate the named environment configuration and return the normalized values. */ -function normalizeAndValidateEnvironment( +export function normalizeAndValidateEnvironment( diagnostics: Diagnostics, configPath: string | undefined, rawEnv: RawEnvironment, @@ -1050,7 +1050,7 @@ function normalizeAndValidateEnvironment( /** * Validate the named environment configuration and return the normalized values. */ -function normalizeAndValidateEnvironment( +export function normalizeAndValidateEnvironment( diagnostics: Diagnostics, configPath: string | undefined, rawEnv: RawEnvironment, @@ -1060,7 +1060,7 @@ function normalizeAndValidateEnvironment( isLegacyEnv?: boolean, rawConfig?: RawConfig ): Environment; -function normalizeAndValidateEnvironment( +export function normalizeAndValidateEnvironment( diagnostics: Diagnostics, configPath: string | undefined, rawEnv: RawEnvironment, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 715d69468bc7..70da6e031a05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,12 @@ settings: catalogs: default: + '@vitest/runner': + specifier: ~2.1.3 + version: 2.1.3 + '@vitest/snapshot': + specifier: ~2.1.3 + version: 2.1.3 '@vitest/ui': specifier: ~2.1.3 version: 2.1.3 @@ -1824,6 +1830,9 @@ importers: concurrently: specifier: ^8.2.2 version: 8.2.2 + defu: + specifier: ^6.1.4 + version: 6.1.4 devtools-protocol: specifier: ^0.0.1182435 version: 0.0.1182435