diff --git a/.changeset/swift-bobcats-joke.md b/.changeset/swift-bobcats-joke.md new file mode 100644 index 000000000000..e83857e9c57d --- /dev/null +++ b/.changeset/swift-bobcats-joke.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +feat: implement `config.define` + +This implements `config.define`. This lets the user define a map of keys to strings that will be substituted in the worker's source. This is particularly useful when combined with environments. A common usecase is for values that are sent along with metrics events; environment name, public keys, version numbers, etc. It's also sometimes a workaround for the usability of module env vars, which otherwise have to be threaded through request function stacks. diff --git a/packages/wrangler/src/__tests__/configuration.test.ts b/packages/wrangler/src/__tests__/configuration.test.ts index 96879d4eb1ca..5c3e8842cd33 100644 --- a/packages/wrangler/src/__tests__/configuration.test.ts +++ b/packages/wrangler/src/__tests__/configuration.test.ts @@ -57,6 +57,7 @@ describe("normalizeAndValidateConfig()", () => { }, usage_model: undefined, vars: {}, + define: {}, wasm_modules: undefined, data_blobs: undefined, workers_dev: undefined, @@ -165,7 +166,7 @@ describe("normalizeAndValidateConfig()", () => { `); }); - describe("migrations", () => { + describe("[migrations]", () => { it("should override `migrations` config defaults with provided values", () => { const expectedConfig: RawConfig = { migrations: [ @@ -271,7 +272,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("site", () => { + describe("[site]", () => { it("should override `site` config defaults with provided values", () => { const expectedConfig: RawConfig = { site: { @@ -412,7 +413,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("assets", () => { + describe("[assets]", () => { it("should error if `assets` config is missing `bucket`", () => { const expectedConfig: RawConfig = { // @ts-expect-error we're intentionally passing an invalid configuration here @@ -748,6 +749,10 @@ describe("normalizeAndValidateConfig()", () => { cwd: "CWD", watch_dir: "WATCH_DIR", }, + define: { + DEF1: "DEFINE_1", + DEF2: "DEFINE_2", + }, vars: { VAR1: "VALUE_1", VAR2: "VALUE_2", @@ -870,6 +875,9 @@ describe("normalizeAndValidateConfig()", () => { cwd: 1555, watch_dir: 1666, }, + define: { + DEF1: 1777, + }, minify: "INVALID", node_compat: "INVALID", } as unknown as RawEnvironment; @@ -935,6 +943,7 @@ describe("normalizeAndValidateConfig()", () => { - Expected \\"name\\" to be of type string, alphanumeric and lowercase with dashes only but got 111. - Expected \\"main\\" to be of type string but got 1333. - Expected \\"usage_model\\" field to be one of [\\"bundled\\",\\"unbound\\"] but got \\"INVALID\\". + - The field \\"define.DEF1\\" should be a string but got 1777. - Expected \\"minify\\" to be of type boolean but got \\"INVALID\\". - Expected \\"node_compat\\" to be of type boolean but got \\"INVALID\\"." `); @@ -1015,7 +1024,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("build", () => { + describe("[build]", () => { it("should override build.upload config defaults with provided values and warn about deprecations", () => { const expectedConfig: RawEnvironment = { build: { @@ -1186,7 +1195,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("durable_objects field", () => { + describe("[durable_objects]", () => { it("should error if durable_objects is an array", () => { const { config, diagnostics } = normalizeAndValidateConfig( { durable_objects: [] } as unknown as RawConfig, @@ -1429,7 +1438,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("kv_namespaces field", () => { + describe("[kv_namespaces]", () => { it("should error if kv_namespaces is an object", () => { const { config, diagnostics } = normalizeAndValidateConfig( { kv_namespaces: {} } as unknown as RawConfig, @@ -1534,7 +1543,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("r2_buckets field", () => { + describe("[r2_buckets]", () => { it("should error if r2_buckets is an object", () => { const { config, diagnostics } = normalizeAndValidateConfig( { r2_buckets: {} } as unknown as RawConfig, @@ -1639,7 +1648,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("services field", () => { + describe("[services]", () => { it("should error if services is an object", () => { const { config, diagnostics } = normalizeAndValidateConfig( { services: {} } as unknown as RawConfig, @@ -1784,7 +1793,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("unsafe field", () => { + describe("[unsafe.bindings]", () => { it("should error if unsafe is an array", () => { const { config, diagnostics } = normalizeAndValidateConfig( { unsafe: [] } as unknown as RawConfig, @@ -2378,6 +2387,9 @@ describe("normalizeAndValidateConfig()", () => { }); it("should warn for non-inherited fields that are missing in environments", () => { + const define: RawConfig["define"] = { + abc: "123", + }; const vars: RawConfig["vars"] = { FOO: "foo", }; @@ -2388,6 +2400,7 @@ describe("normalizeAndValidateConfig()", () => { const r2_buckets: RawConfig["r2_buckets"] = []; const unsafe: RawConfig["unsafe"] = { bindings: [] }; const rawConfig: RawConfig = { + define, vars, durable_objects, kv_namespaces, @@ -2406,6 +2419,7 @@ describe("normalizeAndValidateConfig()", () => { expect(config).toEqual( expect.not.objectContaining({ + define, vars, durable_objects, kv_namespaces, @@ -2421,6 +2435,9 @@ describe("normalizeAndValidateConfig()", () => { - \\"vars\\" exists at the top level, but not on \\"env.ENV1\\". This is not what you probably want, since \\"vars\\" is not inherited by environments. Please add \\"vars\\" to \\"env.ENV1\\". + - \\"define\\" exists at the top level, but not on \\"env.ENV1\\". + This is not what you probably want, since \\"define\\" is not inherited by environments. + Please add \\"define\\" to \\"env.ENV1\\". - \\"durable_objects\\" exists at the top level, but not on \\"env.ENV1\\". This is not what you probably want, since \\"durable_objects\\" is not inherited by environments. Please add \\"durable_objects\\" to \\"env.ENV1\\". @@ -2496,7 +2513,229 @@ describe("normalizeAndValidateConfig()", () => { `); }); - describe("durable_objects field", () => { + describe("[define]", () => { + it("should accept valid values for config.define", () => { + const rawConfig: RawConfig = { + define: { + abc: "def", + ghi: "123", + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: undefined } + ); + + expect(config).toEqual(expect.objectContaining(rawConfig)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("should error if config.define is not an object", () => { + const rawConfig: RawConfig = { + // @ts-expect-error purposely using an invalid value + define: 123, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: undefined } + ); + + expect(config).toEqual(expect.objectContaining(rawConfig)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field \\"define\\" should be an object but got 123. + " + `); + }); + + it("should error if the values on config.define are not strings", () => { + const rawConfig: RawConfig = { + define: { + // @ts-expect-error purposely using an invalid value + abc: 123, + // This one's valid + def: "xyz", + // @ts-expect-error purposely using an invalid value + ghi: true, + // @ts-expect-error purposely using an invalid value + jkl: { + nested: "value", + }, + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: undefined } + ); + + expect(config).toEqual(expect.objectContaining(rawConfig)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field \\"define.abc\\" should be a string but got 123. + - The field \\"define.ghi\\" should be a string but got true. + - The field \\"define.jkl\\" should be a string but got {\\"nested\\":\\"value\\"}." + `); + }); + + describe("named environments", () => { + it("should accept valid values for config.define inside an environment", () => { + const rawConfig: RawConfig = { + define: { + abc: "def", + ghi: "123", + }, + env: { + ENV1: { + define: { + abc: "xyz", + ghi: "456", + }, + }, + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: "ENV1" } + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(config).toEqual(expect.objectContaining(rawConfig.env!.ENV1)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("should error if config.define is not an object inside an environment", () => { + const rawConfig: RawConfig = { + define: { + abc: "def", + ghi: "123", + }, + env: { + ENV1: { + // @ts-expect-error purposely using an invalid value + define: 123, + }, + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: "ENV1" } + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(config).toEqual(expect.objectContaining(rawConfig.env!.ENV1)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + + - \\"env.ENV1\\" environment configuration + - The field \\"env.ENV1.define\\" should be an object but got 123. + " + `); + }); + + it("should warn if if the shape of .define inside an environment doesn't match the shape of the top level .define", () => { + const rawConfig: RawConfig = { + define: { + abc: "def", + ghi: "123", + }, + env: { + ENV1: { + define: { + abc: "def", + xyz: "123", + }, + }, + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: "ENV1" } + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(config).toEqual(expect.objectContaining(rawConfig.env!.ENV1)); + expect(diagnostics.hasWarnings()).toBe(true); + expect(diagnostics.hasErrors()).toBe(false); + + expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + + - \\"env.ENV1\\" environment configuration + - \\"define.ghi\\" exists at the top level, but not on \\"env.ENV1.define\\". + This is not what you probably want, since \\"define\\" configuration is not inherited by environments. + Please add \\"define.ghi\\" to \\"env.ENV1\\". + - \\"xyz\\" exists on \\"env.ENV1\\", but not on the top level. + This is not what you probably want, since \\"define\\" configuration within environments can only override existing top level \\"define\\" configuration + Please remove \\"env.ENV1.define.xyz\\", or add \\"define.xyz\\"." + `); + }); + + it("should error if the values on config.define in an environment are not strings", () => { + const rawConfig: RawConfig = { + define: { + abc: "123", + def: "xyz", + ghi: "true", + jkl: "some value", + }, + env: { + ENV1: { + define: { + // @ts-expect-error purposely using an invalid value + abc: 123, + // This one's valid + def: "xyz", + // @ts-expect-error purposely using an invalid value + ghi: true, + // @ts-expect-error purposely using an invalid value + jkl: { + nested: "value", + }, + }, + }, + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + { env: "ENV1" } + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(config).toEqual(expect.objectContaining(rawConfig.env!.ENV1)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + + - \\"env.ENV1\\" environment configuration + - The field \\"env.ENV1.define.abc\\" should be a string but got 123. + - The field \\"env.ENV1.define.ghi\\" should be a string but got true. + - The field \\"env.ENV1.define.jkl\\" should be a string but got {\\"nested\\":\\"value\\"}." + `); + }); + }); + }); + + describe("[durable_objects]", () => { it("should error if durable_objects is an array", () => { const { config, diagnostics } = normalizeAndValidateConfig( { env: { ENV1: { durable_objects: [] } } } as unknown as RawConfig, @@ -2739,7 +2978,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("kv_namespaces field", () => { + describe("[kv_namespaces]", () => { it("should error if kv_namespaces is an object", () => { const { config, diagnostics } = normalizeAndValidateConfig( { env: { ENV1: { kv_namespaces: {} } } } as unknown as RawConfig, @@ -2858,7 +3097,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("r2_buckets field", () => { + describe("[r2_buckets]", () => { it("should error if r2_buckets is an object", () => { const { config, diagnostics } = normalizeAndValidateConfig( { env: { ENV1: { r2_buckets: {} } } } as unknown as RawConfig, @@ -2977,7 +3216,7 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("unsafe field", () => { + describe("[unsafe.bindings]", () => { it("should error if unsafe is an array", () => { const { config, diagnostics } = normalizeAndValidateConfig( { env: { ENV1: { unsafe: [] } } } as unknown as RawConfig, diff --git a/packages/wrangler/src/__tests__/publish.test.ts b/packages/wrangler/src/__tests__/publish.test.ts index f70d30ab281e..7d57a2dc0188 100644 --- a/packages/wrangler/src/__tests__/publish.test.ts +++ b/packages/wrangler/src/__tests__/publish.test.ts @@ -3404,6 +3404,84 @@ addEventListener('fetch', event => {});` }); }); + describe("[define]", () => { + it("should be able to define values that will be substituted into top-level identifiers", async () => { + writeWranglerToml({ + main: "index.js", + define: { + abc: "123", + }, + }); + fs.writeFileSync( + "index.js", + ` + // this should get replaced + console.log(abc); + // this should not get replaced + console.log(globalThis.abc); + + function foo(){ + const abc = "a string"; + // this should not get replaced + console.log(abc); + } + + console.log(foo); + ` + ); + mockSubDomainRequest(); + mockUploadWorkerRequest(); + await runWrangler("build"); + expect(fs.readFileSync("dist/index.js", "utf-8")).toMatchInlineSnapshot(` + "(() => { + // index.js + console.log(123); + console.log(globalThis.abc); + function foo() { + const abc2 = \\"a string\\"; + console.log(abc2); + } + console.log(foo); + })(); + //# sourceMappingURL=index.js.map + " + `); + }); + + it("can be overriden in environments", async () => { + writeWranglerToml({ + main: "index.js", + define: { + abc: "123", + }, + env: { + staging: { + define: { + abc: "456", + }, + }, + }, + }); + fs.writeFileSync( + "index.js", + ` + console.log(abc); + ` + ); + mockSubDomainRequest(); + mockUploadWorkerRequest(); + await runWrangler("build --env staging"); + expect(fs.readFileSync("dist/index.js", "utf-8")).toMatchInlineSnapshot(` + "(() => { + // index.js + console.log(456); + })(); + //# sourceMappingURL=index.js.map + " + `); + }); + }); + describe("custom builds", () => { beforeEach(() => { // @ts-expect-error disable the mock we'd setup earlier diff --git a/packages/wrangler/src/bundle.ts b/packages/wrangler/src/bundle.ts index e824c8bbf7b5..dd8655ae69a8 100644 --- a/packages/wrangler/src/bundle.ts +++ b/packages/wrangler/src/bundle.ts @@ -59,6 +59,7 @@ export async function bundleWorker( tsconfig: string | undefined; minify: boolean | undefined; nodeCompat: boolean | undefined; + define: Config["define"]; } ): Promise { const { @@ -105,6 +106,7 @@ export async function bundleWorker( define: { "process.env.NODE_ENV": `"${process.env.NODE_ENV}"`, ...(nodeCompat ? { global: "globalThis" } : {}), + ...options.define, }, }), loader: { diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index bcb1358d7f86..f32d5acb135a 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -211,6 +211,16 @@ interface EnvironmentInheritable { * for each named environment. */ interface EnvironmentNonInheritable { + /** + * A map of values to substitute when deploying your worker. + * + * NOTE: This field is not automatically inherited from the top level environment, + * and so must be specified in every named environment. + * + * @default `{}` + * @nonInheritable + */ + define: Record; /** * A map of environment variables to set when deploying your worker. * diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index c6ac8df94ab0..18cea26b0b5a 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -944,6 +944,16 @@ function normalizeAndValidateEnvironment( validateVars(envName), {} ), + define: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "define", + validateDefines(envName), + {} + ), durable_objects: notInheritable( diagnostics, topLevelEnv, @@ -1142,6 +1152,68 @@ const validateRule: ValidatorFn = (diagnostics, field, value) => { return isValid; }; +const validateDefines = + (envName: string): ValidatorFn => + (diagnostics, field, value, config) => { + let isValid = true; + const fieldPath = + config === undefined ? `${field}` : `env.${envName}.${field}`; + + if (typeof value === "object" && value !== null) { + for (const varName in value) { + // some casting here to appease typescript + // even though the value might not match the type + if (typeof (value as Record)[varName] !== "string") { + diagnostics.errors.push( + `The field "${fieldPath}.${varName}" should be a string but got ${JSON.stringify( + (value as Record)[varName] + )}.` + ); + isValid = false; + } + } + } else { + if (value !== undefined) { + diagnostics.errors.push( + `The field "${fieldPath}" should be an object but got ${JSON.stringify( + value + )}.\n` + ); + isValid = false; + } + } + + const configDefines = Object.keys(config?.define ?? {}); + + // If there are no top level vars then there is nothing to do here. + if (configDefines.length > 0) { + if (typeof value === "object" && value !== null) { + const configEnvDefines = config === undefined ? [] : Object.keys(value); + + for (const varName of configDefines) { + if (!(varName in value)) { + diagnostics.warnings.push( + `"define.${varName}" exists at the top level, but not on "${fieldPath}".\n` + + `This is not what you probably want, since "define" configuration is not inherited by environments.\n` + + `Please add "define.${varName}" to "env.${envName}".` + ); + } + } + for (const varName of configEnvDefines) { + if (!configDefines.includes(varName)) { + diagnostics.warnings.push( + `"${varName}" exists on "env.${envName}", but not on the top level.\n` + + `This is not what you probably want, since "define" configuration within environments can only override existing top level "define" configuration\n` + + `Please remove "${fieldPath}.${varName}", or add "define.${varName}".` + ); + } + } + } + } + + return isValid; + }; + const validateVars = (envName: string): ValidatorFn => (diagnostics, field, value, config) => { @@ -1481,6 +1553,7 @@ const validateBindingsHaveUniqueNames = ( text_blobs, unsafe, vars, + define, wasm_modules, data_blobs, }: Partial @@ -1494,6 +1567,7 @@ const validateBindingsHaveUniqueNames = ( "Text Blob": getBindingNames(text_blobs), Unsafe: getBindingNames(unsafe), "Environment Variable": getBindingNames(vars), + Definition: getBindingNames(define), "WASM Module": getBindingNames(wasm_modules), "Data Blob": getBindingNames(data_blobs), } as Record; diff --git a/packages/wrangler/src/dev.tsx b/packages/wrangler/src/dev.tsx index 8fdf3f5f1d36..d3109440aff5 100644 --- a/packages/wrangler/src/dev.tsx +++ b/packages/wrangler/src/dev.tsx @@ -420,6 +420,7 @@ export async function devHandler(args: ArgumentsCamelCase) { minify={args.minify ?? config.minify} nodeCompat={nodeCompat} build={config.build || {}} + define={config.define} initialMode={args.local ? "local" : "remote"} jsxFactory={args["jsx-factory"] || config.jsx_factory} jsxFragment={args["jsx-fragment"] || config.jsx_fragment} diff --git a/packages/wrangler/src/dev/dev.tsx b/packages/wrangler/src/dev/dev.tsx index d14649e0a944..038daa373ed0 100644 --- a/packages/wrangler/src/dev/dev.tsx +++ b/packages/wrangler/src/dev/dev.tsx @@ -38,6 +38,7 @@ export type DevProps = { localUpstream: string | undefined; enableLocalPersistence: boolean; bindings: CfWorkerInit["bindings"]; + define: Config["define"]; crons: Config["triggers"]["crons"]; isWorkersSite: boolean; assetPaths: AssetPaths | undefined; @@ -145,6 +146,7 @@ function DevSession(props: DevSessionProps) { tsconfig: props.tsconfig, minify: props.minify, nodeCompat: props.nodeCompat, + define: props.define, }); return props.local ? ( diff --git a/packages/wrangler/src/dev/use-esbuild.ts b/packages/wrangler/src/dev/use-esbuild.ts index a2ff7152af5e..fa715cf87749 100644 --- a/packages/wrangler/src/dev/use-esbuild.ts +++ b/packages/wrangler/src/dev/use-esbuild.ts @@ -26,12 +26,14 @@ export function useEsbuild({ tsconfig, minify, nodeCompat, + define, }: { entry: Entry; destination: string | undefined; jsxFactory: string | undefined; jsxFragment: string | undefined; rules: Config["rules"]; + define: Config["define"]; serveAssetsFromWorker: boolean; tsconfig: string | undefined; minify: boolean | undefined; @@ -72,6 +74,7 @@ export function useEsbuild({ tsconfig, minify, nodeCompat, + define, }); // Capture the `stop()` method to use as the `useEffect()` destructor. @@ -107,6 +110,7 @@ export function useEsbuild({ exit, minify, nodeCompat, + define, ]); return bundle; } diff --git a/packages/wrangler/src/preview.tsx b/packages/wrangler/src/preview.tsx index 3b2464111ab3..cffcbc8bd2b9 100644 --- a/packages/wrangler/src/preview.tsx +++ b/packages/wrangler/src/preview.tsx @@ -78,6 +78,7 @@ export async function previewHandler(args: ArgumentsCamelCase) { host={undefined} legacyEnv={isLegacyEnv(config)} build={config.build || {}} + define={config.define} minify={undefined} nodeCompat={config.node_compat} initialMode={args.local ? "local" : "remote"} diff --git a/packages/wrangler/src/publish.ts b/packages/wrangler/src/publish.ts index fc7c093ca89b..5066d3f2413e 100644 --- a/packages/wrangler/src/publish.ts +++ b/packages/wrangler/src/publish.ts @@ -352,6 +352,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m tsconfig: props.tsconfig ?? config.tsconfig, minify, nodeCompat, + define: config.define, } );