diff --git a/.changeset/nasty-kiwis-talk.md b/.changeset/nasty-kiwis-talk.md new file mode 100644 index 000000000000..f2a3262bd640 --- /dev/null +++ b/.changeset/nasty-kiwis-talk.md @@ -0,0 +1,11 @@ +--- +"wrangler": minor +--- + +Include runtime types in the output of `wrangler types` by default + +`wrangler types` will now produce one file that contains both `Env` types and runtime types based on your compatibility date and flags. This is located at `worker-configuration.d.ts` by default. + +This behaviour was previously gated behind `--experimental-include-runtime`. That flag is no longer necessary and has been removed. It has been replaced by `--include-runtime` and `--include-env`, both of which are set to `true` by default. If you were previously using `--x-include-runtime`, you can drop that flag and remove the separate `runtime.d.ts` file. + +If you were previously using `@cloudflare/workers-types` we recommend you run uninstall (e.g. `npm uninstall @cloudflare/workers-types`) and run `wrangler types` instead. Note that `@cloudflare/workers-types` will continue to be published. diff --git a/.changeset/shiny-donkeys-act.md b/.changeset/shiny-donkeys-act.md new file mode 100644 index 000000000000..099f5f324194 --- /dev/null +++ b/.changeset/shiny-donkeys-act.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +feat: prompt users to rerun `wrangler types` during `wrangler dev` + +If a generated types file is found at the default output location of `wrangler types` (`worker-configuration.d.ts`), remind users to rerun `wrangler types` if it looks like they're out of date. diff --git a/fixtures/type-generation/package.json b/fixtures/type-generation/package.json deleted file mode 100644 index 024cc20872d6..000000000000 --- a/fixtures/type-generation/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "type-generation-fixture", - "private": true, - "description": "A test for the `wrangler types` utility", - "scripts": { - "test:ci": "vitest run", - "test:watch": "vitest" - }, - "devDependencies": { - "vitest": "catalog:default", - "wrangler": "workspace:*" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/fixtures/type-generation/tests/type-generation.file-comment.test.ts b/fixtures/type-generation/tests/type-generation.file-comment.test.ts deleted file mode 100644 index 841feec8491c..000000000000 --- a/fixtures/type-generation/tests/type-generation.file-comment.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { execSync } from "child_process"; -import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import * as path from "path"; -import { join } from "path"; -import { beforeAll, describe, it } from "vitest"; - -describe("`wrangler types` - file comment", () => { - let tempDir: string; - - beforeAll(() => { - tempDir = realpathSync(mkdtempSync(join(tmpdir(), "wrangler-types-test"))); - const tomlFile = join(tempDir, "wrangler.toml"); - const tomlFileA = join(tempDir, "wranglerA.toml"); - writeFileSync(tomlFile, '\n[vars]\nMY_VAR = "my-var-value"\n'); - writeFileSync(tomlFileA, '\n[vars]\nMY_VAR = "my-var-value"\n'); - }); - - function runWranglerTypesCommand( - args = "", - expectedOutputFile = "worker-configuration.d.ts" - ): string { - const wranglerPath = path.resolve( - __dirname, - "..", - "..", - "..", - "packages", - "wrangler" - ); - execSync(`npx ${wranglerPath} types ${args}`, { - cwd: tempDir, - }); - const typesFile = join(tempDir, expectedOutputFile); - return readFileSync(typesFile, "utf-8"); - } - - describe("includes a comment specifying the command run", () => { - it("(base command)", async ({ expect }) => { - const typesCommandOutput = runWranglerTypesCommand(); - expect(typesCommandOutput).toContain("by running `wrangler types`"); - }); - - it("(with types customization)", async ({ expect }) => { - const typesCommandOutput = runWranglerTypesCommand( - "--env-interface MyCloudflareEnv ./cflare-env.d.ts", - "./cflare-env.d.ts" - ); - expect(typesCommandOutput).toContain( - "by running `wrangler types --env-interface MyCloudflareEnv ./cflare-env.d.ts`" - ); - }); - - it("(with wrangler top level options)", async ({ expect }) => { - const typesCommandOutput = runWranglerTypesCommand("-c wranglerA.toml"); - expect(typesCommandOutput).toContain( - "by running `wrangler types -c wranglerA.toml`" - ); - }); - }); -}); diff --git a/fixtures/type-generation/vitest.config.mts b/fixtures/type-generation/vitest.config.mts deleted file mode 100644 index a6fb54404864..000000000000 --- a/fixtures/type-generation/vitest.config.mts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - testTimeout: 60_000, - hookTimeout: 25_000, - teardownTimeout: 25_000, - }, -}); diff --git a/fixtures/type-generation/wrangler.toml b/fixtures/type-generation/wrangler.toml deleted file mode 100644 index 5b48dacf5008..000000000000 --- a/fixtures/type-generation/wrangler.toml +++ /dev/null @@ -1,4 +0,0 @@ -name = "type-generation-fixture" - -[vars] -MY_VAR = "my-var-value" diff --git a/packages/wrangler/e2e/types.test.ts b/packages/wrangler/e2e/types.test.ts index dcd312f03623..09f34165fd3e 100644 --- a/packages/wrangler/e2e/types.test.ts +++ b/packages/wrangler/e2e/types.test.ts @@ -1,5 +1,5 @@ -import { existsSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { dedent } from "../src/utils/dedent"; @@ -11,6 +11,8 @@ const seed = { main = "src/index.ts" compatibility_date = "2023-01-01" compatibility_flags = ["nodejs_compat", "no_global_navigator"] + [vars] + MY_VAR = "my-var-value" `, "src/index.ts": dedent` export default { @@ -29,159 +31,156 @@ const seed = { }; describe("types", () => { - it("should not generate runtime types without flag", async () => { + it("should generate runtime types without a flag", async () => { const helper = new WranglerE2ETestHelper(); await helper.seed(seed); const output = await helper.run(`wrangler types`); - expect(output.stdout).not.toContain(`Generating runtime types...`); + expect(output.stdout).toContain("Generating runtime types..."); + expect(output.stdout).toContain("Runtime types generated."); + expect(output.stdout).toContain( + "✨ Types written to worker-configuration.d.ts" + ); + expect(output.stdout).toContain("📖 Read about runtime types"); }); - it("should generate runtime types at the default path", async () => { + it("should generate runtime types and env types in one file at the default path", async () => { const helper = new WranglerE2ETestHelper(); await helper.seed(seed); - const output = await helper.run(`wrangler types --x-include-runtime`); - - const fileExists = existsSync( - path.join(helper.tmpPath, "./.wrangler/types/runtime.d.ts") - ); - - expect(fileExists).toEqual(true); - expect(output.stdout).toContain(`Generating runtime types...`); - expect(output.stdout).toContain(`Generating project types...`); - expect(output.stdout).toContain( - `✨ Runtime types written to ./.wrangler/types/runtime.d.ts` - ); - expect(output.stdout).toContain( - `"types": ["./.wrangler/types/runtime.d.ts"]` - ); + const output = await helper.run(`wrangler types`); + expect(output.stdout).toContain("Generating project types..."); + expect(output.stdout).toContain("interface Env {"); + expect(output.stdout).toContain("Generating runtime types..."); + expect(output.stdout).toContain("Runtime types generated."); expect(output.stdout).toContain( - `📣 Since you have Node.js compatibility mode enabled, you should consider adding Node.js for TypeScript by running "npm i --save-dev @types/node@20.8.3". Please see the docs for more details: https://developers.cloudflare.com/workers/languages/typescript/#transitive-loading-of-typesnode-overrides-cloudflareworkers-types` + "✨ Types written to worker-configuration.d.ts" ); - expect(output.stdout).toContain( - `Remember to run 'wrangler types --x-include-runtime' again if you change 'compatibility_date' or 'compatibility_flags' in your wrangler.toml file.` + const file = readFileSync( + path.join(helper.tmpPath, "./worker-configuration.d.ts"), + "utf8" ); + expect(file).contains('declare module "cloudflare:workers"'); + expect(file).contains("interface Env"); }); - it("should generate runtime types at the provided path", async () => { + it("should be able to generate an Env type only", async () => { const helper = new WranglerE2ETestHelper(); await helper.seed(seed); - const output = await helper.run( - `wrangler types --x-include-runtime="./types.d.ts"` + const output = await helper.run(`wrangler types --include-runtime=false`); + expect(output.stdout).not.toContain("Generating runtime types..."); + const file = readFileSync( + path.join(helper.tmpPath, "./worker-configuration.d.ts"), + "utf8" ); - - const fileExists = existsSync(path.join(helper.tmpPath, "./types.d.ts")); - - expect(fileExists).toEqual(true); - expect(output.stdout).toContain(`✨ Runtime types written to ./types.d.ts`); - expect(output.stdout).toContain(`"types": ["./types.d.ts"]`); + expect(file).toMatchInlineSnapshot(` + "// Generated by Wrangler by running \`wrangler types --include-runtime=false\` (hash: 7fbca0b39560512499078acfe5f450c0) + interface Env { + MY_VAR: "my-var-value"; + } + " + `); }); - it("should generate types", async () => { + it("should include header with version information in the generated types", async () => { const helper = new WranglerE2ETestHelper(); await helper.seed(seed); - await helper.run(`wrangler types --x-include-runtime="./types.d.ts"`); + await helper.run(`wrangler types "./types.d.ts" `); - const file = ( - await readFile(path.join(helper.tmpPath, "./types.d.ts")) - ).toString(); + const lines = readFileSync( + path.join(helper.tmpPath, "./types.d.ts"), + "utf8" + ).split("\n"); - expect(file).contains('declare module "cloudflare:workers"'); + expect(lines[0]).toMatchInlineSnapshot( + `"// Generated by Wrangler by running \`wrangler types ./types.d.ts\` (hash: 7fbca0b39560512499078acfe5f450c0)"` + ); + expect(lines[1]).match( + /\/\/ Runtime types generated with workerd@1\.\d{8}\.\d \d{4}-\d{2}-\d{2} ([a-z_]+,?)*/ + ); }); - it("should recommend to uninstall @cloudflare/workers-types", async () => { + it("should include header with wrangler command that generated it", async () => { const helper = new WranglerE2ETestHelper(); await helper.seed({ ...seed, - "tsconfig.json": dedent` - { - "compilerOptions": { - "types": ["@cloudflare/workers-types"] - } - } - `, + "wranglerA.toml": dedent` + name = "test-worker" + main = "src/index.ts" + compatibility_date = "2023-01-01" + `, }); - const output = await helper.run( - `wrangler types --x-include-runtime="./types.d.ts"` + await helper.run( + "wrangler types -c wranglerA.toml --env-interface MyCloudflareEnv ./cflare-env.d.ts" ); - expect(output.stdout).toContain( - `📣 You can now uninstall "@cloudflare/workers-types".` + const lines = readFileSync( + path.join(helper.tmpPath, "./cflare-env.d.ts"), + "utf8" + ).split("\n"); + + expect(lines[0]).toMatchInlineSnapshot( + `"// Generated by Wrangler by running \`wrangler types -c wranglerA.toml --env-interface MyCloudflareEnv ./cflare-env.d.ts\` (hash: 8fcf1ed67a52a2d34d6d34c3068e89b8)"` + ); + expect(lines[1]).match( + /\/\/ Runtime types generated with workerd@1\.\d{8}\.\d \d{4}-\d{2}-\d{2} ([a-z_]+,?)*/ ); }); - it("should not recommend to install @types/node if 'node' exists in types array", async () => { + it("should not regenerate runtime types if the header matches, but should regenerate env types", async () => { const helper = new WranglerE2ETestHelper(); - await helper.seed({ - ...seed, - "tsconfig.json": dedent` - { - "compilerOptions": { - "types": ["node"] - } - } - `, - }); - const output = await helper.run( - `wrangler types --x-include-runtime="./types.d.ts"` + await helper.seed(seed); + await helper.run(`wrangler types`); + + const typesPath = path.join(helper.tmpPath, "worker-configuration.d.ts"); + const file = readFileSync(typesPath, "utf8").split("\n"); + + await writeFile( + typesPath, + [ + file[0], + file[1], + "FAKE ENV", + "// Begin runtime types", + "FAKE RUNTIME", + ].join("\n") ); - expect(output.stdout).not.toContain( - `📣 Since you have Node.js compatibility mode enabled, you should consider adding Node.js for TypeScript by running "npm i --save-dev @types/node@20.8.3". Please see the docs for more details: https://developers.cloudflare.com/workers/languages/typescript/#transitive-loading-of-typesnode-overrides-cloudflareworkers-types` - ); + await helper.run(`wrangler types`); + + const file2 = readFileSync(typesPath, "utf8"); + + // regenerates env types + expect(file2).toContain("interface Env {"); + // uses cached runtime types + expect(file2).toContain("// Begin runtime types"); + expect(file2).toContain("FAKE RUNTIME"); }); - it("should not error with nodejs_compat flags", async () => { + it("should prompt you to update types if they've been changed", async () => { const helper = new WranglerE2ETestHelper(); - await helper.seed({ - ...seed, - "wrangler.toml": dedent` + await helper.seed(seed); + await helper.run(`wrangler types`); + seed["wrangler.toml"] = dedent` name = "test-worker" main = "src/index.ts" compatibility_date = "2023-01-01" - compatibility_flags = ["nodejs_compat", "experimental:nodejs_compat_v2"] - `, - }); - - const output = await helper.run( - `wrangler types --x-include-runtime="./types.d.ts"` - ); - - expect(output.stderr).toBe(""); - expect(output.status).toBe(0); - }); - it("should include header with version information in the generated types", async () => { - const helper = new WranglerE2ETestHelper(); + compatibility_flags = ["nodejs_compat", "no_global_navigator"] + [vars] + BEEP = "BOOP" + `; await helper.seed(seed); - await helper.run(`wrangler types --x-include-runtime="./types.d.ts"`); - - const file = ( - await readFile(path.join(helper.tmpPath, "./types.d.ts")) - ).toString(); - - expect(file.split("\n")[0]).match( - /\/\/ Runtime types generated with workerd@1\.\d+\.\d \d\d\d\d-\d\d-\d\d ([a-z_]+,?)*/ - ); - }); - it("should not regenerate types if the header matches", async () => { - const helper = new WranglerE2ETestHelper(); + const worker = helper.runLongLived("wrangler dev"); + await worker.readUntil(/❓ Your types might be out of date./); + seed["wrangler.toml"] = dedent` + name = "test-worker" + main = "src/index.ts" + compatibility_date = "2023-01-01" + compatibility_flags = ["nodejs_compat"] + [vars] + BEEP = "BOOP" + ASDf = "ADSfadsf" + `; await helper.seed(seed); - await helper.run(`wrangler types --x-include-runtime`); - - const runtimeTypesFile = path.join( - helper.tmpPath, - "./.wrangler/types/runtime.d.ts" - ); - const file = (await readFile(runtimeTypesFile)).toString(); - - const header = file.split("\n")[0]; - - await writeFile(runtimeTypesFile, header + "\n" + "SOME_RANDOM_DATA"); - - await helper.run(`wrangler types --x-include-runtime`); - - const file2 = (await readFile(runtimeTypesFile)).toString(); - - expect(file2.split("\n")[1]).toBe("SOME_RANDOM_DATA"); + await worker.readUntil(/❓ Your types might be out of date./); }); }); diff --git a/packages/wrangler/src/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index 15629cab188a..b339d685e379 100644 --- a/packages/wrangler/src/__tests__/index.test.ts +++ b/packages/wrangler/src/__tests__/index.test.ts @@ -48,7 +48,7 @@ describe("wrangler", () => { wrangler delete [script] 🗑 Delete a Worker from Cloudflare wrangler tail [worker] 🦚 Start a log tailing session for a Worker wrangler secret 🤫 Generate a secret that can be referenced in a Worker - wrangler types [path] 📝 Generate types from bindings and module rules in configuration + wrangler types [path] 📝 Generate types from your Worker configuration wrangler kv 🗂️ Manage Workers KV Namespaces wrangler queues 🇶 Manage Workers Queues @@ -106,7 +106,7 @@ describe("wrangler", () => { wrangler delete [script] 🗑 Delete a Worker from Cloudflare wrangler tail [worker] 🦚 Start a log tailing session for a Worker wrangler secret 🤫 Generate a secret that can be referenced in a Worker - wrangler types [path] 📝 Generate types from bindings and module rules in configuration + wrangler types [path] 📝 Generate types from your Worker configuration wrangler kv 🗂️ Manage Workers KV Namespaces wrangler queues 🇶 Manage Workers Queues diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 37848d761787..18d321ad1062 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -6,11 +6,13 @@ import { generateImportSpecifier, isValidIdentifier, } from "../type-generation"; +import * as generateRuntime from "../type-generation/runtime"; import { dedent } from "../utils/dedent"; import { mockConsoleMethods } from "./helpers/mock-console"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; import type { EnvironmentNonInheritable } from "../config/environment"; +import type { MockInstance } from "vitest"; describe("isValidIdentifier", () => { it("should return true for valid identifiers", () => { @@ -208,17 +210,37 @@ const bindingsConfigMock: Omit< }, }; -describe("generateTypes()", () => { +describe("generate types", () => { + let spy: MockInstance; const std = mockConsoleMethods(); + const originalColumns = process.stdout.columns; runInTempDir(); - it("should show a warning when no config file is detected", async () => { - await runWrangler("types"); - expect(std.warn).toMatchInlineSnapshot(` - "▲ [WARNING] No config file detected, aborting + beforeAll(() => { + process.stdout.columns = 60; + }); - " - `); + afterAll(() => { + process.stdout.columns = originalColumns; + }); + beforeEach(() => { + spy = vi + .spyOn(generateRuntime, "generateRuntimeTypes") + .mockImplementation(async () => ({ + runtimeHeader: "// Runtime types generated with workerd@", + runtimeTypes: "", + })); + fs.writeFileSync( + "./tsconfig.json", + JSON.stringify({ + compilerOptions: { types: ["worker-configuration.d.ts"] }, + }) + ); + }); + it("should error when no config file is detected", async () => { + await expect(runWrangler("types")).rejects.toMatchInlineSnapshot( + `[Error: No config file detected. This command requires a Wrangler configuration file.]` + ); }); it("should error when a specified custom config file is missing", async () => { @@ -233,6 +255,8 @@ describe("generateTypes()", () => { fs.writeFileSync( "./wrangler.toml", TOML.stringify({ + compatibility_date: "2022-01-12", + compatibility_flags: ["fake-compat-1"], vars: { var: "from wrangler toml", }, @@ -243,6 +267,8 @@ describe("generateTypes()", () => { fs.writeFileSync( "./my-wrangler-config-a.toml", TOML.stringify({ + compatibility_date: "2023-01-12", + compatibility_flags: ["fake-compat-2"], vars: { var: "from my-wrangler-config-a", }, @@ -253,6 +279,8 @@ describe("generateTypes()", () => { fs.writeFileSync( "./my-wrangler-config-b.toml", TOML.stringify({ + compatibility_date: "2024-01-12", + compatibility_flags: ["fake-compat-3"], vars: { var: "from my-wrangler-config-b", }, @@ -261,29 +289,84 @@ describe("generateTypes()", () => { ); await runWrangler("types"); + expect(spy).toHaveBeenNthCalledWith(1, { + config: expect.objectContaining({ + compatibility_date: "2022-01-12", + compatibility_flags: ["fake-compat-1"], + }), + outFile: "worker-configuration.d.ts", + }); + await runWrangler("types --config ./my-wrangler-config-a.toml"); - await runWrangler("types -c my-wrangler-config-b.toml"); + expect(spy).toHaveBeenNthCalledWith(2, { + config: expect.objectContaining({ + compatibility_date: "2023-01-12", + compatibility_flags: ["fake-compat-2"], + }), + outFile: "worker-configuration.d.ts", + }); + await runWrangler("types -c my-wrangler-config-b.toml"); + expect(spy).toHaveBeenNthCalledWith(3, { + config: expect.objectContaining({ + compatibility_date: "2024-01-12", + compatibility_flags: ["fake-compat-3"], + }), + outFile: "worker-configuration.d.ts", + }); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... + "Generating project types... + + interface Env { + var: \\"from wrangler toml\\"; + } + + Generating runtime types... + + Runtime types generated. + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + + Generating project types... + + interface Env { + var: \\"from my-wrangler-config-a\\"; + } + + Generating runtime types... + + Runtime types generated. + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts - interface Env { - var: \\"from wrangler toml\\"; - } + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + + Generating project types... + + interface Env { + var: \\"from my-wrangler-config-b\\"; + } - Generating project types... + Generating runtime types... - interface Env { - var: \\"from my-wrangler-config-a\\"; - } + Runtime types generated. - Generating project types... + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts - interface Env { - var: \\"from my-wrangler-config-b\\"; - } - " - `); + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should log the interface type generated and declare modules", async () => { @@ -308,55 +391,60 @@ describe("generateTypes()", () => { "utf-8" ); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - interface Env { - TEST_KV_NAMESPACE: KVNamespace; - SOMETHING: \\"asdasdfasdf\\"; - ANOTHER: \\"thing\\"; - \\"some-other-var\\": \\"some-other-value\\"; - OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; - DURABLE_DIRECT_EXPORT: DurableObjectNamespace; - DURABLE_RE_EXPORT: DurableObjectNamespace; - DURABLE_NO_EXPORT: DurableObjectNamespace /* DurableNoexport */; - DURABLE_EXTERNAL: DurableObjectNamespace /* DurableExternal from external-worker */; - R2_BUCKET_BINDING: R2Bucket; - D1_TESTING_SOMETHING: D1Database; - SERVICE_BINDING: Fetcher; - AE_DATASET_BINDING: AnalyticsEngineDataset; - NAMESPACE_BINDING: DispatchNamespace; - LOGFWDR_SCHEMA: any; - SOME_DATA_BLOB1: ArrayBuffer; - SOME_DATA_BLOB2: ArrayBuffer; - SOME_TEXT_BLOB1: string; - SOME_TEXT_BLOB2: string; - testing_unsafe: any; - UNSAFE_RATELIMIT: RateLimit; - TEST_QUEUE_BINDING: Queue; - SEND_EMAIL_BINDING: SendEmail; - VECTORIZE_BINDING: VectorizeIndex; - HYPERDRIVE_BINDING: Hyperdrive; - MTLS_BINDING: Fetcher; - BROWSER_BINDING: Fetcher; - AI_BINDING: Ai; - VERSION_METADATA_BINDING: { id: string; tag: string }; - ASSETS_BINDING: Fetcher; - } - declare module \\"*.txt\\" { - const value: string; - export default value; - } - declare module \\"*.webp\\" { - const value: ArrayBuffer; - export default value; - } - declare module \\"*.wasm\\" { - const value: WebAssembly.Module; - export default value; - }" - `); + "Generating project types... + + interface Env { + TEST_KV_NAMESPACE: KVNamespace; + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + DURABLE_DIRECT_EXPORT: DurableObjectNamespace; + DURABLE_RE_EXPORT: DurableObjectNamespace; + DURABLE_NO_EXPORT: DurableObjectNamespace /* DurableNoexport */; + DURABLE_EXTERNAL: DurableObjectNamespace /* DurableExternal from external-worker */; + R2_BUCKET_BINDING: R2Bucket; + D1_TESTING_SOMETHING: D1Database; + SERVICE_BINDING: Fetcher; + AE_DATASET_BINDING: AnalyticsEngineDataset; + NAMESPACE_BINDING: DispatchNamespace; + LOGFWDR_SCHEMA: any; + SOME_DATA_BLOB1: ArrayBuffer; + SOME_DATA_BLOB2: ArrayBuffer; + SOME_TEXT_BLOB1: string; + SOME_TEXT_BLOB2: string; + testing_unsafe: any; + UNSAFE_RATELIMIT: RateLimit; + TEST_QUEUE_BINDING: Queue; + SEND_EMAIL_BINDING: SendEmail; + VECTORIZE_BINDING: VectorizeIndex; + HYPERDRIVE_BINDING: Hyperdrive; + MTLS_BINDING: Fetcher; + BROWSER_BINDING: Fetcher; + AI_BINDING: Ai; + VERSION_METADATA_BINDING: { id: string; tag: string }; + ASSETS_BINDING: Fetcher; + } + declare module \\"*.txt\\" { + const value: string; + export default value; + } + declare module \\"*.webp\\" { + const value: ArrayBuffer; + export default value; + } + declare module \\"*.wasm\\" { + const value: WebAssembly.Module; + export default value; + } + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should create a DTS file at the location that the command is executed from", async () => { @@ -374,10 +462,24 @@ describe("generateTypes()", () => { ); await runWrangler("types"); expect(fs.existsSync("./worker-configuration.d.ts")).toBe(true); + expect(fs.readFileSync("./worker-configuration.d.ts", "utf-8")) + .toMatchInlineSnapshot(` + "// Generated by Wrangler by running \`wrangler\` (hash: e0442e27e492fd2b5e8bb36627f0213c) + // Runtime types generated with workerd@ + interface Env { + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + } + + // Begin runtime types + " + `); }); describe("when nothing was found", () => { - it("should not create DTS file for service syntax workers", async () => { + it("should not create DTS file for service syntax workers if env only", async () => { fs.writeFileSync( "./index.ts", 'addEventListener("fetch", event => { event.respondWith(() => new Response("")); })' @@ -392,9 +494,58 @@ describe("generateTypes()", () => { "utf-8" ); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(fs.existsSync("./worker-configuration.d.ts")).toBe(false); - expect(std.out).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "Generating project types... + + No project types to add. + + ──────────────────────────────────────────────────────────── + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); + }); + + it("should create DTS file for service syntax workers with runtime types only", async () => { + fs.writeFileSync( + "./index.ts", + 'addEventListener("fetch", event => { event.respondWith(() => new Response("")); })' + ); + fs.writeFileSync( + "./wrangler.toml", + TOML.stringify({ + compatibility_date: "2022-01-12", + name: "test-name", + main: "./index.ts", + }), + "utf-8" + ); + + await runWrangler("types"); + expect(fs.readFileSync("./worker-configuration.d.ts", "utf8")) + .toMatchInlineSnapshot(` + "// Runtime types generated with workerd@ + // Begin runtime types + " + `); + expect(std.out).toMatchInlineSnapshot(` + "Generating project types... + + No project types to add. + + Generating runtime types... + + Runtime types generated. + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should create a DTS file with an empty env interface for module syntax workers", async () => { @@ -409,18 +560,23 @@ describe("generateTypes()", () => { "utf-8" ); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(fs.readFileSync("./worker-configuration.d.ts", "utf-8")).toContain( `// eslint-disable-next-line @typescript-eslint/no-empty-interface,@typescript-eslint/no-empty-object-type\ninterface Env {\n}` ); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... + "Generating project types... - interface Env { - } - " - `); + interface Env { + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); }); @@ -442,7 +598,7 @@ describe("generateTypes()", () => { ); await expect(runWrangler("types")).rejects.toMatchInlineSnapshot( - `[Error: A non-wrangler worker-configuration.d.ts already exists, please rename and try again.]` + `[Error: A non-Wrangler worker-configuration.d.ts already exists, please rename and try again.]` ); expect(fs.existsSync("./worker-configuration.d.ts")).toBe(true); }); @@ -469,23 +625,29 @@ describe("generateTypes()", () => { "utf-8" ); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - export {}; - declare global { - const testing_unsafe: any; - const UNSAFE_RATELIMIT: RateLimit; - } - " - `); + "Generating project types... + + export {}; + declare global { + const testing_unsafe: any; + const UNSAFE_RATELIMIT: RateLimit; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should accept a toml file without an entrypoint and fallback to the standard modules declarations", async () => { fs.writeFileSync( "./wrangler.toml", TOML.stringify({ + compatibility_date: "2022-01-12", vars: bindingsConfigMock.vars, } as unknown as TOML.JsonMap), "utf-8" @@ -493,16 +655,27 @@ describe("generateTypes()", () => { await runWrangler("types"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - interface Env { - SOMETHING: \\"asdasdfasdf\\"; - ANOTHER: \\"thing\\"; - \\"some-other-var\\": \\"some-other-value\\"; - OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; - } - " - `); + "Generating project types... + + interface Env { + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + } + + Generating runtime types... + + Runtime types generated. + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should not error if expected entrypoint is not found and assume module worker", async () => { @@ -516,18 +689,23 @@ describe("generateTypes()", () => { ); expect(fs.existsSync("index.ts")).toEqual(false); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - interface Env { - SOMETHING: \\"asdasdfasdf\\"; - ANOTHER: \\"thing\\"; - \\"some-other-var\\": \\"some-other-value\\"; - OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; - } - " - `); + "Generating project types... + + interface Env { + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should include secret keys from .dev.vars", async () => { @@ -551,20 +729,25 @@ describe("generateTypes()", () => { `; fs.writeFileSync(".dev.vars", localVarsEnvContent, "utf8"); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - interface Env { - myTomlVarA: \\"A from wrangler toml\\"; - myTomlVarB: \\"B from wrangler toml\\"; - SECRET_A: string; - MULTI_LINE_SECRET: string; - UNQUOTED_SECRET: string; - } - " - `); + "Generating project types... + + interface Env { + myTomlVarA: \\"A from wrangler toml\\"; + myTomlVarB: \\"B from wrangler toml\\"; + SECRET_A: string; + MULTI_LINE_SECRET: string; + UNQUOTED_SECRET: string; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should allow opting out of strict-vars", async () => { @@ -581,19 +764,24 @@ describe("generateTypes()", () => { "utf-8" ); - await runWrangler("types --strict-vars=false"); + await runWrangler("types --strict-vars=false --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - interface Env { - varStr: string; - varArrNum: number[]; - varArrMix: (boolean|number|string)[]; - varObj: object; - } - " - `); + "Generating project types... + + interface Env { + varStr: string; + varArrNum: number[]; + varArrMix: (boolean|number|string)[]; + varObj: object; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should override vars with secrets", async () => { @@ -615,17 +803,22 @@ describe("generateTypes()", () => { `; fs.writeFileSync(".dev.vars", localVarsEnvContent, "utf8"); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... - - interface Env { - MY_VARIABLE_A: string; - MY_VARIABLE_B: string; - } - " - `); + "Generating project types... + + interface Env { + MY_VARIABLE_A: string; + MY_VARIABLE_B: string; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("various different types of vars", async () => { @@ -646,7 +839,7 @@ describe("generateTypes()", () => { } as TOML.JsonMap), "utf-8" ); - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` "Generating project types... @@ -664,6 +857,11 @@ describe("generateTypes()", () => { line var\\": \\"this/nis/na/nmulti/nline/nvariable!\\"; } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. " `); }); @@ -700,35 +898,45 @@ describe("generateTypes()", () => { }); it("should produce string and union types for variables (default)", async () => { - await runWrangler("types"); + await runWrangler("types --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... + "Generating project types... - interface Env { - MY_VAR: \\"a var\\"; - MY_VAR_A: \\"A (dev)\\" | \\"A (prod)\\" | \\"A (stag)\\"; - MY_VAR_C: [\\"a\\",\\"b\\",\\"c\\"] | [1,2,3]; - MY_VAR_B: {\\"value\\":\\"B (dev)\\"} | {\\"value\\":\\"B (prod)\\"}; - } - " - `); + interface Env { + MY_VAR: \\"a var\\"; + MY_VAR_A: \\"A (dev)\\" | \\"A (prod)\\" | \\"A (stag)\\"; + MY_VAR_C: [\\"a\\",\\"b\\",\\"c\\"] | [1,2,3]; + MY_VAR_B: {\\"value\\":\\"B (dev)\\"} | {\\"value\\":\\"B (prod)\\"}; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should produce non-strict types for variables (with --strict-vars=false)", async () => { - await runWrangler("types --strict-vars=false"); + await runWrangler("types --strict-vars=false --include-runtime=false"); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... + "Generating project types... - interface Env { - MY_VAR: string; - MY_VAR_A: string; - MY_VAR_C: string[] | number[]; - MY_VAR_B: object; - } - " - `); + interface Env { + MY_VAR: string; + MY_VAR_A: string; + MY_VAR_C: string[] | number[]; + MY_VAR_B: object; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); }); @@ -743,18 +951,25 @@ describe("generateTypes()", () => { "utf-8" ); - await runWrangler("types --env-interface CloudflareEnv"); + await runWrangler( + "types --include-runtime=false --env-interface CloudflareEnv" + ); expect(std.out).toMatchInlineSnapshot(` - "Generating project types... + "Generating project types... - interface CloudflareEnv { - SOMETHING: \\"asdasdfasdf\\"; - ANOTHER: \\"thing\\"; - \\"some-other-var\\": \\"some-other-value\\"; - OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; - } - " - `); + interface CloudflareEnv { + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); it("should error if --env-interface is specified with no argument", async () => { @@ -798,7 +1013,7 @@ describe("generateTypes()", () => { } }); - it("should warn if --env-interface is used with a service-syntax worker", async () => { + it("should error if --env-interface is used with a service-syntax worker", async () => { fs.writeFileSync( "./index.ts", `addEventListener('fetch', event => { event.respondWith(handleRequest(event.request)); @@ -827,6 +1042,7 @@ describe("generateTypes()", () => { fs.writeFileSync( "./wrangler.toml", TOML.stringify({ + compatibility_date: "2022-01-12", vars: bindingsConfigMock.vars, } as TOML.JsonMap), "utf-8" @@ -836,9 +1052,20 @@ describe("generateTypes()", () => { expect(fs.existsSync("./worker-configuration.d.ts")).toBe(false); - expect(fs.readFileSync("./cloudflare-env.d.ts", "utf-8")).toMatch( - /interface Env \{[\s\S]*SOMETHING: "asdasdfasdf";[\s\S]*ANOTHER: "thing";[\s\S]*"some-other-var": "some-other-value";[\s\S]*OBJECT_VAR: \{"enterprise":"1701-D","activeDuty":true,"captian":"Picard"\};[\s\S]*}/ - ); + expect(fs.readFileSync("./cloudflare-env.d.ts", "utf-8")) + .toMatchInlineSnapshot(` + "// Generated by Wrangler by running \`wrangler\` (hash: e0442e27e492fd2b5e8bb36627f0213c) + // Runtime types generated with workerd@ + interface Env { + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + } + + // Begin runtime types + " + `); }); it("should error if the user points to a non-d.ts file", async () => { @@ -860,7 +1087,7 @@ describe("generateTypes()", () => { for (const path of invalidPaths) { await expect(runWrangler(`types ${path}`)).rejects.toThrowError( - /The provided path value .*? does not point to a declaration file/ + /The provided output path '.*?' does not point to a declaration file/ ); } }); @@ -879,11 +1106,103 @@ describe("generateTypes()", () => { "types --env-interface MyCloudflareEnvInterface my-cloudflare-env-interface.d.ts" ); - expect( - fs.readFileSync("./my-cloudflare-env-interface.d.ts", "utf-8") - ).toMatch( - /interface MyCloudflareEnvInterface \{[\s\S]*SOMETHING: "asdasdfasdf";[\s\S]*ANOTHER: "thing";[\s\S]*"some-other-var": "some-other-value";[\s\S]*OBJECT_VAR: \{"enterprise":"1701-D","activeDuty":true,"captian":"Picard"\};[\s\S]*}/ + expect(fs.readFileSync("./my-cloudflare-env-interface.d.ts", "utf-8")) + .toMatchInlineSnapshot(` + "// Generated by Wrangler by running \`wrangler\` (hash: 15fe0821fea3c43df1b7e2b020b0fb7b) + // Runtime types generated with workerd@ + interface MyCloudflareEnvInterface { + SOMETHING: \\"asdasdfasdf\\"; + ANOTHER: \\"thing\\"; + \\"some-other-var\\": \\"some-other-value\\"; + OBJECT_VAR: {\\"enterprise\\":\\"1701-D\\",\\"activeDuty\\":true,\\"captian\\":\\"Picard\\"}; + } + + // Begin runtime types + " + `); + }); + }); + + describe("runtime types output", () => { + beforeEach(() => { + fs.writeFileSync( + "./wrangler.toml", + TOML.stringify({ + compatibility_date: "2022-12-12", + vars: { + "var-a": "a", + }, + } as TOML.JsonMap), + "utf-8" + ); + }); + it("errors helpfully if you use --experimental-include-runtime", async () => { + await expect(runWrangler("types --experimental-include-runtime")).rejects + .toMatchInlineSnapshot(` + [Error: You no longer need to use --experimental-include-runtime. + \`wrangler types\` will now generate runtime types in the same file as the Env types. + You should delete the old runtime types file, and remove it from your tsconfig.json. + Then rerun \`wrangler types\`.] + `); + }); + it("prints something helpful if you have @cloudflare/workers-types", async () => { + fs.writeFileSync( + "./tsconfig.json", + JSON.stringify({ + compilerOptions: { types: ["@cloudflare/workers-types"] }, + }) + ); + await runWrangler("types --include-env=false"); + expect(std.out).toMatchInlineSnapshot(` + "Generating runtime types... + + Runtime types generated. + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + Action required Migrate from @cloudflare/workers-types to generated runtime types + \`wrangler types\` now generates runtime types and supersedes @cloudflare/workers-types. + You should now uninstall @cloudflare/workers-types and remove it from your tsconfig.json. + + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); + }); + it("prints something helpful if you have a runtime file at the old default location", async () => { + fs.writeFileSync( + "./tsconfig.json", + JSON.stringify({ + compilerOptions: { + types: [ + "./wrangler/types/runtime.d.ts", + "worker-configuration.d.ts", + ], + }, + }) ); + fs.mkdirSync("./.wrangler/types", { recursive: true }); + fs.writeFileSync("./.wrangler/types/runtime.d.ts", "blah"); + await runWrangler("types --include-env=false"); + expect(std.out).toMatchInlineSnapshot(` + "Generating runtime types... + + Runtime types generated. + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + Action required Remove the old runtime.d.ts file + \`wrangler types\` now outputs runtime and Env types in one file. + You can now delete the ./.wrangler/types/runtime.d.ts and update your tsconfig.json\` + + 📖 Read about runtime types + https://developers.cloudflare.com/workers/languages/typescript/#generate-types + 📣 Remember to rerun 'wrangler types' after you change your wrangler.toml file. + " + `); }); }); }); diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index ea203a718046..668ed20b9962 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -14,6 +14,7 @@ import { getClassNamesWhichUseSQLite } from "../../dev/class-names-sqlite"; import { getLocalPersistencePath } from "../../dev/get-local-persistence-path"; import { UserError } from "../../errors"; import { logger } from "../../logger"; +import { checkTypesDiff } from "../../type-generation/helpers"; import { requireApiToken, requireAuth } from "../../user"; import { DEFAULT_INSPECTOR_PORT, @@ -369,6 +370,14 @@ async function resolveConfig( logger.warn("SQLite in Durable Objects is only supported in local mode."); } + // prompt user to update their types if we detect that it is out of date + const typesChanged = await checkTypesDiff(config, entry); + if (typesChanged) { + logger.log( + "❓ Your types might be out of date. Re-run `wrangler types` to ensure your types are correct." + ); + } + return resolved; } export class ConfigController extends Controller { diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index 8fca4a91b88b..721e1221c467 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -33,7 +33,7 @@ export function createRegisterYargsCommand( if (def.type === "command") { const args = def.args ?? {}; - yargs.options(args); + yargs.options(args).epilogue(def.metadata?.epilogue ?? ""); // Ensure non-array arguments receive a single value for (const [key, opt] of Object.entries(args)) { diff --git a/packages/wrangler/src/core/types.ts b/packages/wrangler/src/core/types.ts index ff0fc6fce83f..17445e8dbd98 100644 --- a/packages/wrangler/src/core/types.ts +++ b/packages/wrangler/src/core/types.ts @@ -28,6 +28,8 @@ export type Metadata = { deprecatedMessage?: string; hidden?: boolean; owner: Teams; + /** Prints something at the bottom of the help */ + epilogue?: string; }; export type ArgDefinition = PositionalOptions & diff --git a/packages/wrangler/src/type-generation/helpers.ts b/packages/wrangler/src/type-generation/helpers.ts new file mode 100644 index 000000000000..4a3f7357c2d5 --- /dev/null +++ b/packages/wrangler/src/type-generation/helpers.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "fs"; +import { version } from "workerd"; +import { logger } from "../logger"; +import { generateEnvTypes } from "."; +import type { Config } from "../config"; +import type { Entry } from "../deployment-bundle/entry"; + +// Checks the default location for a generated types file and compares if the +// recorded Env hash, workerd version or compat date and flags have changed +// compared to the current values in the config. Prompts user to re-run wrangler +// types if so. +export const checkTypesDiff = async (config: Config, entry: Entry) => { + if (!entry.file.endsWith(".ts")) { + return; + } + let maybeExistingTypesFileLines: string[]; + try { + // Checking the default location only + maybeExistingTypesFileLines = readFileSync( + "./worker-configuration.d.ts", + "utf-8" + ).split("\n"); + } catch { + return; + } + const existingEnvHeader = maybeExistingTypesFileLines.find((line) => + line.startsWith("// Generated by Wrangler by running") + ); + const maybeExistingHash = + existingEnvHeader?.match(/hash: (?.*)\)/)?.groups?.hash; + const previousStrictVars = existingEnvHeader?.match( + /--strict-vars(=|\s)(?true|false)/ + )?.groups?.result; + const previousEnvInterface = existingEnvHeader?.match( + /--env-interface(=|\s)(?[a-zA-Z][a-zA-Z0-9_]*)/ + )?.groups?.result; + + let newEnvHeader: string | undefined; + try { + const { envHeader } = await generateEnvTypes( + config, + { strictVars: previousStrictVars === "false" ? false : true }, + previousEnvInterface ?? "Env", + "worker-configuration.d.ts", + entry, + // don't log anything + false + ); + newEnvHeader = envHeader; + } catch (e) { + logger.error(e); + } + + const newHash = newEnvHeader?.match(/hash: (?.*)\)/)?.groups?.hash; + + const existingRuntimeHeader = maybeExistingTypesFileLines.find((line) => + line.startsWith("// Runtime types generated with") + ); + const newRuntimeHeader = `// Runtime types generated with workerd@${version} ${config.compatibility_date} ${config.compatibility_flags.sort().join(",")}`; + + const envOutOfDate = existingEnvHeader && maybeExistingHash !== newHash; + const runtimeOutOfDate = + existingRuntimeHeader && existingRuntimeHeader !== newRuntimeHeader; + + return envOutOfDate || runtimeOutOfDate; +}; diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 0963b247936e..8bead2cf8025 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto"; import * as fs from "node:fs"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import chalk from "chalk"; import { findUpSync } from "find-up"; import { getNodeCompat } from "miniflare"; -import { experimental_readRawConfig } from "../config"; +import { configFileName, experimental_readRawConfig } from "../config"; import { createCommand } from "../core/create-command"; import { getEntry } from "../deployment-bundle/entry"; import { getVarsForDev } from "../dev/dev-vars"; @@ -13,19 +15,19 @@ import { generateRuntimeTypes } from "./runtime"; import { logRuntimeTypesMessage } from "./runtime/log-runtime-types-message"; import type { Config, RawEnvironment } from "../config"; import type { Entry } from "../deployment-bundle/entry"; -import type { CfScriptFormat } from "../deployment-bundle/worker"; export const typesCommand = createCommand({ metadata: { - description: - "📝 Generate types from bindings and module rules in configuration\n", + description: "📝 Generate types from your Worker configuration\n", status: "stable", owner: "Workers: Authoring and Testing", + epilogue: + "📖 Learn more at https://developers.cloudflare.com/workers/languages/typescript/#generate-types", }, positionalArgs: ["path"], args: { path: { - describe: "The path to the declaration file to generate", + describe: "The path to the declaration file for the generated types", type: "string", default: "worker-configuration.d.ts", demandOption: false, @@ -36,32 +38,72 @@ export const typesCommand = createCommand({ describe: "The name of the generated environment interface", requiresArg: true, }, - "experimental-include-runtime": { - alias: "x-include-runtime", - type: "string", - describe: "The path of the generated runtime types file", - demandOption: false, + "include-runtime": { + type: "boolean", + default: true, + describe: "Include runtime types in the generated types", + }, + "include-env": { + type: "boolean", + default: true, + describe: "Include Env types in the generated types", }, "strict-vars": { type: "boolean", default: true, describe: "Generate literal and union types for variables", }, + "experimental-include-runtime": { + alias: "x-include-runtime", + type: "string", + describe: "The path of the generated runtime types file", + demandOption: false, + hidden: true, + deprecated: true, + }, }, validateArgs(args) { - const { envInterface, path: outputPath } = args; + // args.xRuntime will be a string if the user passes "--x-include-runtime" or "--x-include-runtime=..." + if (typeof args.experimentalIncludeRuntime === "string") { + throw new CommandLineArgsError( + "You no longer need to use --experimental-include-runtime.\n" + + "`wrangler types` will now generate runtime types in the same file as the Env types.\n" + + "You should delete the old runtime types file, and remove it from your tsconfig.json.\n" + + "Then rerun `wrangler types`.", + { telemetryMessage: true } + ); + } const validInterfaceRegex = /^[a-zA-Z][a-zA-Z0-9_]*$/; - if (!validInterfaceRegex.test(envInterface)) { + if (!validInterfaceRegex.test(args.envInterface)) { throw new CommandLineArgsError( - `The provided env-interface value ("${envInterface}") does not satisfy the validation regex: ${validInterfaceRegex}` + `The provided env-interface value ("${args.envInterface}") does not satisfy the validation regex: ${validInterfaceRegex}`, + { + telemetryMessage: + "The provided env-interface value does not satisfy the validation regex", + } ); } - if (!outputPath.endsWith(".d.ts")) { + if (!args.path.endsWith(".d.ts")) { throw new CommandLineArgsError( - `The provided path value ("${outputPath}") does not point to a declaration file (please use the 'd.ts' extension)` + `The provided output path '${args.path}' does not point to a declaration file - please use the '.d.ts' extension`, + { + telemetryMessage: + "The provided path does not point to a declaration file", + } + ); + } + + checkPath(args.path); + + if (!args.includeEnv && !args.includeRuntime) { + throw new CommandLineArgsError( + `You cannot run this command without including either Env or Runtime types`, + { + telemetryMessage: true, + } ); } }, @@ -73,83 +115,79 @@ export const typesCommand = createCommand({ !fs.existsSync(config.configPath) || fs.statSync(config.configPath).isDirectory() ) { - logger.warn( - `No config file detected${ - args.config ? ` (at ${args.config})` : "" - }, aborting` + throw new UserError( + `No config file detected${args.config ? ` (at ${args.config})` : ""}. This command requires a Wrangler configuration file.`, + { telemetryMessage: "No config file detected" } ); - return; } + const configContainsEntrypoint = + config.main !== undefined || !!config.site?.["entry-point"]; + + let entrypoint: Entry | undefined; + if (configContainsEntrypoint) { + // this will throw if an entrypoint is expected, but doesn't exist + // e.g. before building. however someone might still want to generate types + // so we default to module worker + try { + entrypoint = await getEntry({}, config, "types"); + } catch { + entrypoint = undefined; + } + } + const entrypointFormat = entrypoint?.format ?? "modules"; - // args.xRuntime will be a string if the user passes "--x-include-runtime" or "--x-include-runtime=..." - if (typeof args.experimentalIncludeRuntime === "string") { - logger.log(`Generating runtime types...`); + const header = []; + const content = []; + if (args.includeEnv) { + logger.log(`Generating project types...\n`); - const { outFile } = await generateRuntimeTypes({ + const { envHeader, envTypes } = await generateEnvTypes( config, - outFile: args.experimentalIncludeRuntime || undefined, - }); - - const tsconfigPath = - config.tsconfig ?? join(dirname(config.configPath), "tsconfig.json"); - const tsconfigTypes = readTsconfigTypes(tsconfigPath); - const { mode } = getNodeCompat( - config.compatibility_date, - config.compatibility_flags + args, + envInterface, + outputPath, + entrypoint ); + if (envHeader && envTypes) { + header.push(envHeader); + content.push(envTypes); + } + } - logRuntimeTypesMessage( - outFile, - tsconfigTypes, - mode !== null, - config.configPath - ); + if (args.includeRuntime) { + logger.log("Generating runtime types...\n"); + const { runtimeHeader, runtimeTypes } = await generateRuntimeTypes({ + config, + outFile: outputPath || undefined, + }); + header.push(runtimeHeader); + content.push(`// Begin runtime types\n${runtimeTypes}`); + logger.log(chalk.dim("Runtime types generated.\n")); } - const secrets = getVarsForDev( - // We do not want `getVarsForDev()` to merge in the standard vars into the dev vars - // because we want to be able to work with secrets differently to vars. - // So we pass in a fake vars object here. - { ...config, vars: {} }, - args.env, - true - ) as Record; - - const configBindingsWithSecrets = { - kv_namespaces: config.kv_namespaces ?? [], - vars: collectAllVars(args), - wasm_modules: config.wasm_modules, - text_blobs: { - ...config.text_blobs, - }, - data_blobs: config.data_blobs, - durable_objects: config.durable_objects, - r2_buckets: config.r2_buckets, - d1_databases: config.d1_databases, - services: config.services, - analytics_engine_datasets: config.analytics_engine_datasets, - dispatch_namespaces: config.dispatch_namespaces, - logfwdr: config.logfwdr, - unsafe: config.unsafe, - rules: config.rules, - queues: config.queues, - send_email: config.send_email, - vectorize: config.vectorize, - hyperdrive: config.hyperdrive, - mtls_certificates: config.mtls_certificates, - browser: config.browser, - ai: config.ai, - version_metadata: config.version_metadata, - secrets, - assets: config.assets, - workflows: config.workflows, - }; + logHorizontalRule(); - await generateTypes( - configBindingsWithSecrets, - config, - envInterface, - outputPath + // don't write an empty Env type for service worker syntax + if ((header.length && content.length) || entrypointFormat === "modules") { + fs.writeFileSync( + outputPath, + `${header.join("\n")}\n${content.join("\n")}`, + "utf-8" + ); + logger.log(`✨ Types written to ${outputPath}\n`); + } + const tsconfigPath = + config.tsconfig ?? join(dirname(config.configPath), "tsconfig.json"); + const tsconfigTypes = readTsconfigTypes(tsconfigPath); + const { mode } = getNodeCompat( + config.compatibility_date, + config.compatibility_flags + ); + if (args.includeRuntime) { + logRuntimeTypesMessage(tsconfigTypes, mode !== null); + } + logger.log( + `📣 Remember to rerun 'wrangler types' after you change your ${configFileName(config.configPath)} file.\n` ); }, }); @@ -208,26 +246,53 @@ type ConfigToDTS = Partial> & { vars: VarTypes } & { secrets: Secrets; }; -async function generateTypes( - configToDTS: ConfigToDTS, +export async function generateEnvTypes( config: Config, + args: Partial<(typeof typesCommand)["args"]>, envInterface: string, - outputPath: string -) { - const configContainsEntrypoint = - config.main !== undefined || !!config.site?.["entry-point"]; - - let entrypoint: Entry | undefined; - if (configContainsEntrypoint) { - // this will throw if an entrypoint is expected, but doesn't exist - // e.g. before building. however someone might still want to generate types - // so we default to module worker - try { - entrypoint = await getEntry({}, config, "types"); - } catch { - entrypoint = undefined; - } - } + outputPath: string, + entrypoint?: Entry, + log = true +): Promise<{ envHeader?: string; envTypes?: string }> { + const secrets = getVarsForDev( + // We do not want `getVarsForDev()` to merge in the standard vars into the dev vars + // because we want to be able to work with secrets differently to vars. + // So we pass in a fake vars object here. + { ...config, vars: {} }, + args.env, + true + ) as Record; + + const configToDTS: ConfigToDTS = { + kv_namespaces: config.kv_namespaces ?? [], + vars: collectAllVars(args), + wasm_modules: config.wasm_modules, + text_blobs: { + ...config.text_blobs, + }, + data_blobs: config.data_blobs, + durable_objects: config.durable_objects, + r2_buckets: config.r2_buckets, + d1_databases: config.d1_databases, + services: config.services, + analytics_engine_datasets: config.analytics_engine_datasets, + dispatch_namespaces: config.dispatch_namespaces, + logfwdr: config.logfwdr, + unsafe: config.unsafe, + rules: config.rules, + queues: config.queues, + send_email: config.send_email, + vectorize: config.vectorize, + hyperdrive: config.hyperdrive, + mtls_certificates: config.mtls_certificates, + browser: config.browser, + ai: config.ai, + version_metadata: config.version_metadata, + secrets, + assets: config.assets, + workflows: config.workflows, + }; + const entrypointFormat = entrypoint?.format ?? "modules"; const fullOutputPath = resolve(outputPath); @@ -274,7 +339,7 @@ async function generateTypes( } if (configToDTS.durable_objects?.bindings) { - const importPath = entrypoint + const importPath = entrypoint?.file ? generateImportSpecifier(fullOutputPath, entrypoint.file) : undefined; @@ -455,38 +520,54 @@ async function generateTypes( } } - writeDTSFile({ - envTypeStructure, - modulesTypeStructure, - formatType: entrypointFormat, - envInterface, - path: fullOutputPath, - }); + const wranglerCommandUsed = ["wrangler", ...process.argv.slice(2)].join(" "); + + const typesHaveBeenFound = + envTypeStructure.length || modulesTypeStructure.length; + if (entrypointFormat === "modules" || typesHaveBeenFound) { + const { fileContent, consoleOutput } = generateTypeStrings( + entrypointFormat, + envInterface, + envTypeStructure.map(([key, value]) => `${key}: ${value};`), + modulesTypeStructure + ); + const hash = createHash("sha256") + .update(consoleOutput) + .digest("hex") + .slice(0, 32); + + const envHeader = `// Generated by Wrangler by running \`${wranglerCommandUsed}\` (hash: ${hash})`; + + if (log) { + logger.log(chalk.dim(consoleOutput)); + } + + return { envHeader, envTypes: fileContent }; + } else { + if (log) { + logger.log(chalk.dim("No project types to add.\n")); + } + return { + envHeader: undefined, + envTypes: undefined, + }; + } } -function writeDTSFile({ - envTypeStructure, - modulesTypeStructure, - formatType, - envInterface, - path, -}: { - envTypeStructure: [string, string][]; - modulesTypeStructure: string[]; - formatType: CfScriptFormat; - envInterface: string; - path: string; -}) { +const checkPath = (path: string) => { const wranglerOverrideDTSPath = findUpSync(path); + if (wranglerOverrideDTSPath === undefined) { + return; + } try { + const fileContent = fs.readFileSync(wranglerOverrideDTSPath, "utf8"); if ( - wranglerOverrideDTSPath !== undefined && - !fs - .readFileSync(wranglerOverrideDTSPath, "utf8") - .includes("Generated by Wrangler") + !fileContent.includes("Generated by Wrangler") && + !fileContent.includes("Runtime types generated with workerd") ) { throw new UserError( - `A non-wrangler ${basename(path)} already exists, please rename and try again.` + `A non-Wrangler ${basename(path)} already exists, please rename and try again.`, + { telemetryMessage: "A non-Wrangler .d.ts file already exists" } ); } } catch (error) { @@ -494,33 +575,7 @@ function writeDTSFile({ throw error; } } - - const wranglerCommandUsed = ["wrangler", ...process.argv.slice(2)].join(" "); - - const typesHaveBeenFound = - envTypeStructure.length || modulesTypeStructure.length; - - if (formatType === "modules" || typesHaveBeenFound) { - const { fileContent, consoleOutput } = generateTypeStrings( - formatType, - envInterface, - envTypeStructure.map(([key, value]) => `${key}: ${value};`), - modulesTypeStructure - ); - - fs.writeFileSync( - path, - [ - `// Generated by Wrangler by running \`${wranglerCommandUsed}\``, - "", - fileContent, - ].join("\n") - ); - - logger.log(`Generating project types...\n`); - logger.log(consoleOutput); - } -} +}; function generateTypeStrings( formatType: string, @@ -582,7 +637,7 @@ type VarTypes = Record; * @returns an object which keys are the variable names and values are arrays containing all the computed types for such variables */ function collectAllVars( - args: (typeof typesCommand)["args"] + args: Partial<(typeof typesCommand)["args"]> ): Record { const varsInfo: Record> = {}; @@ -644,3 +699,8 @@ function typeofArray(array: unknown[]): string { return `(${typesInArray.join("|")})[]`; } + +const logHorizontalRule = () => { + const screenWidth = process.stdout.columns; + logger.log(chalk.dim("─".repeat(Math.min(screenWidth, 60)))); +}; diff --git a/packages/wrangler/src/type-generation/runtime/index.ts b/packages/wrangler/src/type-generation/runtime/index.ts index e9c90e701b8e..47b62dd86d39 100644 --- a/packages/wrangler/src/type-generation/runtime/index.ts +++ b/packages/wrangler/src/type-generation/runtime/index.ts @@ -1,12 +1,11 @@ import { readFileSync } from "fs"; -import { readFile, writeFile } from "fs/promises"; +import { readFile } from "fs/promises"; import { Miniflare } from "miniflare"; import { version } from "workerd"; import { logger } from "../../logger"; -import { ensureDirectoryExists } from "../../utils/filesystem"; import type { Config } from "../../config/config"; -const DEFAULT_OUTFILE_RELATIVE_PATH = "./.wrangler/types/runtime.d.ts"; +const DEFAULT_OUTFILE_RELATIVE_PATH = "worker-configuration.d.ts"; /** * Generates runtime types for a Workers project based on the provided project configuration. @@ -42,20 +41,28 @@ export async function generateRuntimeTypes({ }: { config: Pick; outFile?: string; -}) { +}): Promise<{ runtimeHeader: string; runtimeTypes: string }> { if (!compatibility_date) { throw new Error("Config must have a compatibility date."); } - await ensureDirectoryExists(outFile); - - const header = `// Runtime types generated with workerd@${version} ${compatibility_date} ${compatibility_flags.join(",")}`; + const header = `// Runtime types generated with workerd@${version} ${compatibility_date} ${compatibility_flags.sort().join(",")}`; try { - const existingTypes = await readFile(outFile, "utf8"); - if (existingTypes.split("\n")[0] === header) { + const lines = (await readFile(outFile, "utf8")).split("\n"); + const existingHeader = lines.find((line) => + line.startsWith("// Runtime types generated with workerd@") + ); + const existingTypesStart = lines.findIndex( + (line) => line === "// Begin runtime types" + ); + if (existingHeader === header && existingTypesStart !== -1) { logger.debug("Using cached runtime types: ", header); - return { outFile }; + + return { + runtimeHeader: header, + runtimeTypes: lines.slice(existingTypesStart + 1).join("\n"), + }; } } catch (e) { if ((e as { code: string }).code !== "ENOENT") { @@ -71,11 +78,7 @@ export async function generateRuntimeTypes({ ), }); - await writeFile(outFile, `${header}\n${types}`, "utf8"); - - return { - outFile, - }; + return { runtimeHeader: header, runtimeTypes: types }; } /** diff --git a/packages/wrangler/src/type-generation/runtime/log-runtime-types-message.ts b/packages/wrangler/src/type-generation/runtime/log-runtime-types-message.ts index ac3b5586219b..09b143292b60 100644 --- a/packages/wrangler/src/type-generation/runtime/log-runtime-types-message.ts +++ b/packages/wrangler/src/type-generation/runtime/log-runtime-types-message.ts @@ -1,78 +1,73 @@ -import { dedent } from "ts-dedent"; -import { configFileName } from "../../config"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import chalk from "chalk"; +import { findUpMultipleSync } from "find-up"; import { logger } from "../../logger"; /** * Constructs a comprehensive log message for the user after generating runtime types. */ export function logRuntimeTypesMessage( - outFile: string, tsconfigTypes: string[], - isNodeCompat = false, - configPath: string | undefined + isNodeCompat = false ) { const isWorkersTypesInstalled = tsconfigTypes.find((type) => type.startsWith("@cloudflare/workers-types") ); - const isNodeTypesInstalled = tsconfigTypes.find((type) => type === "node"); - const updatedTypesString = buildUpdatedTypesString(tsconfigTypes, outFile); - logger.info(`✨ Runtime types written to ${outFile}`); - - if (updatedTypesString) { - logger.info(dedent` - 📣 Add the generated types to the types array in your tsconfig.json: - - { - "compilerOptions": { - ... - "types": ${updatedTypesString} - ... - } - } - - `); - } else if (isWorkersTypesInstalled) { - logger.info(dedent` - 📣 Replace the existing "@cloudflare/workers-types" entry with the generated types path: - { - "compilerOptions": { - ... - "types": ${updatedTypesString} - ... - } - } - - `); + const maybeHasOldRuntimeFile = existsSync("./.wrangler/types/runtime.d.ts"); + if (maybeHasOldRuntimeFile) { + logAction("Remove the old runtime.d.ts file"); + logger.log( + chalk.dim( + "`wrangler types` now outputs runtime and Env types in one file.\nYou can now delete the ./.wrangler/types/runtime.d.ts and update your tsconfig.json`" + ) + ); + logger.log(""); } if (isWorkersTypesInstalled) { - logger.info('📣 You can now uninstall "@cloudflare/workers-types".'); - } - if (isNodeCompat && !isNodeTypesInstalled) { - logger.info( - '📣 Since you have Node.js compatibility mode enabled, you should consider adding Node.js for TypeScript by running "npm i --save-dev @types/node@20.8.3". Please see the docs for more details: https://developers.cloudflare.com/workers/languages/typescript/#transitive-loading-of-typesnode-overrides-cloudflareworkers-types' + logAction( + "Migrate from @cloudflare/workers-types to generated runtime types" ); + logger.log( + chalk.dim( + "`wrangler types` now generates runtime types and supersedes @cloudflare/workers-types.\nYou should now uninstall @cloudflare/workers-types and remove it from your tsconfig.json." + ) + ); + logger.log(""); } - logger.info( - `📣 Remember to run 'wrangler types --x-include-runtime' again if you change 'compatibility_date' or 'compatibility_flags' in your ${configFileName(configPath)} file.\n` + + let isNodeTypesInstalled = Boolean( + tsconfigTypes.find((type) => type === "node") ); -} -/** - * Constructs a string representation of the existing types array with the new types path appended to. - * It removes any existing types that are no longer relevant. - */ -function buildUpdatedTypesString( - types: string[], - newTypesPath: string -): string | null { - if (types.some((type) => type.includes(".wrangler/types/runtime"))) { - return null; + if (!isNodeTypesInstalled && isNodeCompat) { + const nodeModules = findUpMultipleSync("node_modules", { + type: "directory", + }); + for (const folder of nodeModules) { + if (nodeModules && existsSync(join(folder, "@types/node"))) { + isNodeTypesInstalled = true; + break; + } + } + } + if (isNodeCompat && !isNodeTypesInstalled) { + logAction("Install @types/node"); + logger.log( + chalk.dim( + `Since you have the \`nodejs_compat\` flag, you should install Node.js types by running "npm i --save-dev @types/node".` + ) + ); + logger.log(""); } - const updatedTypesArray = types - .filter((type) => !type.startsWith("@cloudflare/workers-types")) - .concat([newTypesPath]); - - return JSON.stringify(updatedTypesArray); + logger.log( + `📖 Read about runtime types\n` + + `${chalk.dim("https://developers.cloudflare.com/workers/languages/typescript/#generate-types")}` + ); } + +const logAction = (msg: string) => { + logger.log(chalk.hex("#BD5B08").bold("Action required"), msg); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dfc2a2620f2..ac1816d0beaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -863,15 +863,6 @@ importers: specifier: workspace:* version: link:../../packages/kv-asset-handler - fixtures/type-generation: - devDependencies: - vitest: - specifier: catalog:default - version: 3.0.5(@types/node@18.19.76)(@vitest/ui@3.0.5)(jiti@2.4.2)(msw@2.4.3(typescript@5.7.3))(supports-color@9.2.2) - wrangler: - specifier: workspace:* - version: link:../../packages/wrangler - fixtures/unstable_dev: devDependencies: wrangler: