diff --git a/.changeset/chatty-comics-dream.md b/.changeset/chatty-comics-dream.md new file mode 100644 index 0000000000..b8e4f5c104 --- /dev/null +++ b/.changeset/chatty-comics-dream.md @@ -0,0 +1,10 @@ +--- +"wrangler": patch +--- + +Add minimum and maximum version checks for frameworks during auto-configuration + +When Wrangler automatically configures a project, it now validates the installed version of the detected framework before proceeding: + +- If the version is below the minimum known-good version, the command exits with an error asking the user to upgrade the framework. +- If the version is above the maximum known major version, a warning is emitted to let the user know the framework version has not been officially tested with this feature, and the command continues. diff --git a/packages/wrangler/src/__tests__/autoconfig/details/confirm-auto-config-details.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/confirm-auto-config-details.test.ts index 573f9ce756..2e12e249ef 100644 --- a/packages/wrangler/src/__tests__/autoconfig/details/confirm-auto-config-details.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/details/confirm-auto-config-details.test.ts @@ -46,7 +46,6 @@ describe("autoconfig details - confirmAutoConfigDetails()", () => { "buildCommand": "npm run build", "configured": false, "framework": Static { - "autoConfigSupported": true, "configurationDescription": undefined, "id": "static", "name": "Static", @@ -108,7 +107,6 @@ describe("autoconfig details - confirmAutoConfigDetails()", () => { "buildCommand": "npm run app:build", "configured": false, "framework": Static { - "autoConfigSupported": true, "configurationDescription": undefined, "id": "static", "name": "Static", @@ -170,7 +168,6 @@ describe("autoconfig details - confirmAutoConfigDetails()", () => { "buildCommand": "npm run build", "configured": false, "framework": Astro { - "autoConfigSupported": true, "configurationDescription": "Configuring project for Astro with "astro add cloudflare"", "id": "astro", "name": "Astro", @@ -254,7 +251,6 @@ describe("autoconfig details - confirmAutoConfigDetails()", () => { "buildCommand": "npm run build", "configured": false, "framework": Static { - "autoConfigSupported": true, "configurationDescription": undefined, "id": "static", "name": "Static", diff --git a/packages/wrangler/src/__tests__/autoconfig/details/display-auto-config-details.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/display-auto-config-details.test.ts index 406eda2d2c..f1ef733110 100644 --- a/packages/wrangler/src/__tests__/autoconfig/details/display-auto-config-details.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/details/display-auto-config-details.test.ts @@ -56,8 +56,7 @@ describe("autoconfig details - displayAutoConfigDetails()", () => { ({ wranglerConfig: {}, }) satisfies ReturnType, - autoConfigSupported: true, - }, + } as unknown as Framework, buildCommand: "astro build", outputDir: "dist", packageManager: NpmPackageManager, diff --git a/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework-class.test.ts b/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework-class.test.ts new file mode 100644 index 0000000000..ee7edeeb17 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework-class.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from "vitest"; +import { getFrameworkClass } from "../../../autoconfig/frameworks"; +import { NextJs } from "../../../autoconfig/frameworks/next"; +import { Static } from "../../../autoconfig/frameworks/static"; + +describe("getFrameworkClass()", () => { + it("should return a Static framework when frameworkId is unknown", ({ + expect, + }) => { + const framework = getFrameworkClass("unknown-framework"); + + expect(framework).toBeInstanceOf(Static); + expect(framework.id).toBe("static"); + expect(framework.name).toBe("Static"); + }); + + it("should return a target framework when frameworkId is known", ({ + expect, + }) => { + const framework = getFrameworkClass("next"); + + expect(framework).toBeInstanceOf(NextJs); + expect(framework.id).toBe("next"); + expect(framework.name).toBe("Next.js"); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts b/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts deleted file mode 100644 index 730e3fa063..0000000000 --- a/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it } from "vitest"; -import { getFramework } from "../../../autoconfig/frameworks/get-framework"; - -describe("getFramework()", () => { - it("should return a Static framework when frameworkId is unknown", ({ - expect, - }) => { - const framework = getFramework("unknown-framework"); - - expect(framework.id).toBe("static"); - expect(framework.name).toBe("Static"); - }); - - it("should return a target framework when frameworkId is known", ({ - expect, - }) => { - const framework = getFramework("next"); - - expect(framework.id).toBe("next"); - expect(framework.name).toBe("Next.js"); - }); -}); diff --git a/packages/wrangler/src/__tests__/autoconfig/frameworks/is-framework-supported.test.ts b/packages/wrangler/src/__tests__/autoconfig/frameworks/is-framework-supported.test.ts new file mode 100644 index 0000000000..2c92aef751 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/frameworks/is-framework-supported.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from "vitest"; +import { isFrameworkSupported } from "../../../autoconfig/frameworks"; + +describe("isFrameworkSupported()", () => { + it("should return true for a supported framework id", ({ expect }) => { + expect(isFrameworkSupported("astro")).toBe(true); + }); + + it("should return false for a known but unsupported framework id", ({ + expect, + }) => { + expect(isFrameworkSupported("hono")).toBe(false); + }); + + it("should throw for an unknown framework id", ({ expect }) => { + expect(() => isFrameworkSupported("unknown-framework")).toThrow( + 'Unexpected unknown framework id: "unknown-framework"' + ); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/frameworks/is-known-framework.test.ts b/packages/wrangler/src/__tests__/autoconfig/frameworks/is-known-framework.test.ts new file mode 100644 index 0000000000..e2ffab4c40 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/frameworks/is-known-framework.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from "vitest"; +import { isKnownFramework } from "../../../autoconfig/frameworks"; + +describe("isKnownFramework()", () => { + it("should return true for a known supported framework id", ({ expect }) => { + expect(isKnownFramework("astro")).toBe(true); + }); + + it("should return true for a known but unsupported framework id", ({ + expect, + }) => { + expect(isKnownFramework("hono")).toBe(true); + }); + + it('should return true for the "static" framework id', ({ expect }) => { + expect(isKnownFramework("static")).toBe(true); + }); + + it("should return false for an unknown framework id", ({ expect }) => { + expect(isKnownFramework("unknown-framework")).toBe(false); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/frameworks/validate-framework-version.test.ts b/packages/wrangler/src/__tests__/autoconfig/frameworks/validate-framework-version.test.ts new file mode 100644 index 0000000000..51a2703d1a --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/frameworks/validate-framework-version.test.ts @@ -0,0 +1,147 @@ +import { describe, it, vi } from "vitest"; +import { AutoConfigFrameworkConfigurationError } from "../../../autoconfig/errors"; +import { Framework } from "../../../autoconfig/frameworks/framework-class"; +import { getInstalledPackageVersion } from "../../../autoconfig/frameworks/utils/packages"; +import { mockConsoleMethods } from "../../helpers/mock-console"; +import type { AutoConfigFrameworkPackageInfo } from "../../../autoconfig/frameworks"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "../../../autoconfig/frameworks/framework-class"; + +vi.mock("../../../autoconfig/frameworks/utils/packages"); + +/** Minimal concrete subclass so we can instantiate the abstract Framework */ +class TestFramework extends Framework { + configure(_options: ConfigurationOptions): ConfigurationResults { + return { wranglerConfig: null }; + } +} + +const PACKAGE_INFO: AutoConfigFrameworkPackageInfo = { + name: "some-pkg", + minimumVersion: "2.0.0", + maximumKnownMajorVersion: "4", +}; + +describe("Framework.validateFrameworkVersion()", () => { + const std = mockConsoleMethods(); + + it("throws an AssertionError when the package version cannot be determined", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).toThrow("Unable to detect the version of the `some-pkg` package"); + }); + + it("throws AutoConfigFrameworkConfigurationError when installed version is below minimum", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("1.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).toThrow(AutoConfigFrameworkConfigurationError); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).toThrowErrorMatchingInlineSnapshot( + `[Error: The version of Test used in the project ("1.0.0") cannot be automatically configured. Please update the Test version to at least "2.0.0" and try again.]` + ); + }); + + it("does not throw and sets frameworkVersion when installed version equals minimumVersion", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("2.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("2.0.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw and sets frameworkVersion when installed version is within known range", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("3.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("3.0.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw and does not warn when installed version equals maximumKnownMajorVersion", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("4.0.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw and does not warn when installed version is a minor/patch update within the known major", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.5.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("4.5.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw nor warn when the installed version is an update within the known major", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.3.2"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("4.3.2"); + expect(std.warn).toBe(""); + }); + + it("does not throw but warns when installed version exceeds maximumKnownMajorVersion", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("5.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("5.0.0"); + expect(std.warn).toContain('"5.0.0"'); + expect(std.warn).toContain("Test"); + expect(std.warn).toContain("is not officially supported"); + }); + + it("throws an AssertionError when frameworkVersion getter is accessed before validateFrameworkVersion is called", ({ + expect, + }) => { + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => framework.frameworkVersion).toThrow( + 'The version for "Test" is unexpectedly missing' + ); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/run.test.ts b/packages/wrangler/src/__tests__/autoconfig/run.test.ts index 2bdab7bd0e..233b46da1a 100644 --- a/packages/wrangler/src/__tests__/autoconfig/run.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/run.test.ts @@ -6,7 +6,9 @@ import { writeWranglerConfig } from "@cloudflare/workers-utils/test-helpers"; // eslint-disable-next-line no-restricted-imports import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as details from "../../autoconfig/details"; +import { Astro } from "../../autoconfig/frameworks/astro"; import { Static } from "../../autoconfig/frameworks/static"; +import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import * as run from "../../autoconfig/run"; import * as format from "../../deployment-bundle/guess-worker-format"; import { clearOutputFilePath } from "../../output"; @@ -54,6 +56,8 @@ vi.mock("../../package-manager", () => ({ }, })); +vi.mock("../../autoconfig/frameworks/utils/packages"); + vi.mock("../deploy/deploy", async (importOriginal) => ({ ...(await importOriginal()), default: () => { @@ -159,10 +163,9 @@ describe("autoconfig (deploy)", () => { framework: { id: "cloudflare-pages", name: "Cloudflare Pages", - autoConfigSupported: false, configure: async () => ({ wranglerConfig: {} }), isConfigured: () => false, - }, + } as unknown as Framework, outputDir: "public", packageManager: NpmPackageManager, }) @@ -226,12 +229,14 @@ describe("autoconfig (deploy)", () => { configured: false, workerName: "my-worker", framework: { - id: "fake", - name: "Fake", + // "static" is used here because this test exercises the overall runAutoConfig + // flow, not framework-specific logic. Note: Using "static" avoids hitting the + // getFrameworkPackageInfo assert for unknown framework ids. + id: "static", + name: "Static", configure: configureSpy, isConfigured: () => false, - autoConfigSupported: true, - }, + } as unknown as Framework, outputDir: "dist", packageJson: { dependencies: { @@ -247,7 +252,7 @@ describe("autoconfig (deploy)", () => { " Detected Project Settings: - Worker Name: my-worker - - Framework: Fake + - Framework: Static - Build Command: echo 'built' > build.txt - Output Directory: dist @@ -275,7 +280,7 @@ describe("autoconfig (deploy)", () => { ] } - 🛠️ Configuring project for Fake + 🛠️ Configuring project for Static [build] Running: echo 'built' > build.txt" `); @@ -568,16 +573,15 @@ describe("autoconfig (deploy)", () => { framework: { id: "cloudflare-pages", name: "Cloudflare Pages", - autoConfigSupported: false, configure: async () => ({ wranglerConfig: {} }), isConfigured: () => false, - }, + } as unknown as Framework, workerName: "my-worker", outputDir: "dist", packageManager: NpmPackageManager, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to a Workers one is not yet supported.]` + `[Error: The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to Workers is not yet supported.]` ); }); @@ -592,18 +596,17 @@ describe("autoconfig (deploy)", () => { projectPath: process.cwd(), configured: false, framework: { - id: "some-unsupported", - name: "Some Unsupported Framework", - autoConfigSupported: false, + id: "hono", + name: "Hono", configure: async () => ({ wranglerConfig: {} }), isConfigured: () => false, - }, + } as unknown as Framework, workerName: "my-worker", outputDir: "dist", packageManager: NpmPackageManager, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: The detected framework ("Some Unsupported Framework") cannot be automatically configured.]` + `[Error: The detected framework ("Hono") cannot be automatically configured.]` ); }); @@ -624,9 +627,11 @@ describe("autoconfig (deploy)", () => { configured: false, outputDir: "dist", framework: { - id: "no-flags-framework", - name: "No Flags Framework", - autoConfigSupported: true, + // "static" is used here because this test only exercises compatibility flag + // merging behaviour. Note: Using "static" avoids the getFrameworkPackageInfo assert + // for unknown framework ids while keeping the test focused on its intent. + id: "static", + name: "Static", configure: async () => ({ wranglerConfig: { // No compatibility_flags specified @@ -634,7 +639,7 @@ describe("autoconfig (deploy)", () => { }, }), isConfigured: () => false, - }, + } as unknown as Framework, packageManager: NpmPackageManager, }); @@ -658,9 +663,11 @@ describe("autoconfig (deploy)", () => { configured: false, outputDir: "dist", framework: { - id: "other-flags-framework", - name: "Other Flags Framework", - autoConfigSupported: true, + // "static" is used here because this test only exercises compatibility flag + // merging behaviour. Using "static" avoids the getFrameworkPackageInfo assert + // for unknown framework ids while keeping the test focused on its intent. + id: "static", + name: "Static", configure: async () => ({ wranglerConfig: { compatibility_flags: ["global_fetch_strictly_public"], @@ -668,7 +675,7 @@ describe("autoconfig (deploy)", () => { }, }), isConfigured: () => false, - }, + } as unknown as Framework, packageManager: NpmPackageManager, }); @@ -695,9 +702,11 @@ describe("autoconfig (deploy)", () => { configured: false, outputDir: "dist", framework: { - id: "nodejs-compat-framework", - name: "Nodejs Compat Framework", - autoConfigSupported: true, + // "static" is used here because this test only exercises compatibility flag + // merging behaviour. Using "static" avoids the getFrameworkPackageInfo assert + // for unknown framework ids while keeping the test focused on its intent. + id: "static", + name: "Static", configure: async () => ({ wranglerConfig: { compatibility_flags: ["nodejs_compat"], @@ -705,7 +714,7 @@ describe("autoconfig (deploy)", () => { }, }), isConfigured: () => false, - }, + } as unknown as Framework, packageManager: NpmPackageManager, }); @@ -729,9 +738,8 @@ describe("autoconfig (deploy)", () => { configured: false, outputDir: "dist", framework: { - id: "nodejs-als-framework", + id: "static", name: "Nodejs Als Framework", - autoConfigSupported: true, configure: async () => ({ wranglerConfig: { compatibility_flags: ["nodejs_als", "some_other_flag"], @@ -739,7 +747,7 @@ describe("autoconfig (deploy)", () => { }, }), isConfigured: () => false, - }, + } as unknown as Framework, packageManager: NpmPackageManager, }); @@ -752,5 +760,48 @@ describe("autoconfig (deploy)", () => { expect(wranglerConfig.compatibility_flags).not.toContain("nodejs_als"); }); }); + + it("validateFrameworkVersion is called before configure for a supported framework", async () => { + mockConfirm({ + text: "Do you want to modify these settings?", + result: false, + }); + mockConfirm({ + text: "Proceed with setup?", + result: true, + }); + + // Mock getInstalledPackageVersion to return a valid version so that + // validateFrameworkVersion does not throw + vi.mocked(getInstalledPackageVersion).mockReturnValue("5.0.0"); + + const framework = new Astro({ id: "astro", name: "Astro" }); + + const callOrder: string[] = []; + vi.spyOn(framework, "validateFrameworkVersion").mockImplementation(() => { + callOrder.push("validateFrameworkVersion"); + }); + vi.spyOn(framework, "configure").mockImplementation(async () => { + callOrder.push("configure"); + return { wranglerConfig: { assets: { directory: "dist" } } }; + }); + + await run.runAutoConfig({ + projectPath: process.cwd(), + workerName: "my-worker", + configured: false, + outputDir: "dist", + framework, + packageManager: NpmPackageManager, + }); + + // configure is called twice: once as a dry-run (to build the summary) and + // once for real. validateFrameworkVersion must precede both. + expect(callOrder).toEqual([ + "validateFrameworkVersion", + "configure", + "configure", + ]); + }); }); }); diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index 84d331df8c..a23e4cf713 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -49,6 +49,7 @@ import { mockPublishRoutesRequest, mockServiceScriptData, } from "./helpers"; +import type { Framework } from "../../autoconfig/frameworks"; import type { OutputEntry } from "../../output"; vi.mock("command-exists"); @@ -587,10 +588,9 @@ describe("deploy", () => { framework: { id: "cloudflare-pages", name: "Cloudflare Pages", - autoConfigSupported: false, configure: async () => ({ wranglerConfig: {} }), isConfigured: () => false, - }, + } as unknown as Framework, outputDir: "public", packageManager: NpmPackageManager, }); @@ -631,10 +631,9 @@ describe("deploy", () => { framework: { id: "cloudflare-pages", name: "Cloudflare Pages", - autoConfigSupported: false, configure: async () => ({ wranglerConfig: {} }), isConfigured: () => false, - }, + } as unknown as Framework, outputDir: "public", packageManager: NpmPackageManager, }); diff --git a/packages/wrangler/src/autoconfig/details/framework-detection.ts b/packages/wrangler/src/autoconfig/details/framework-detection.ts index 9f0c57385b..9d7193b4b3 100644 --- a/packages/wrangler/src/autoconfig/details/framework-detection.ts +++ b/packages/wrangler/src/autoconfig/details/framework-detection.ts @@ -18,12 +18,9 @@ import { YarnPackageManager, } from "../../package-manager"; import { PAGES_CONFIG_CACHE_FILENAME } from "../../pages/constants"; -import { - allKnownFrameworksIds, - staticFramework, -} from "../frameworks/get-framework"; +import { isKnownFramework } from "../frameworks"; +import { staticFramework } from "../frameworks/all-frameworks"; import type { PackageManager } from "../../package-manager"; -import type { KnownFrameworkId } from "../frameworks/get-framework"; import type { Config } from "@cloudflare/workers-utils"; import type { Settings } from "@netlify/build-info"; @@ -268,7 +265,7 @@ function maybeFindDetectedFramework( } const settingsForOnlyKnownFrameworks = settings.filter(({ framework }) => - allKnownFrameworksIds.has(framework.id as KnownFrameworkId) + isKnownFramework(framework.id) ); if (settingsForOnlyKnownFrameworks.length === 0) { @@ -288,8 +285,8 @@ function maybeFindDetectedFramework( } if (settingsForOnlyKnownFrameworks.length === 2) { - const frameworkIdsFound = new Set( - settings.map(({ framework }) => framework.id as KnownFrameworkId) + const frameworkIdsFound = new Set( + settings.map(({ framework }) => framework.id) ); const viteId = "vite"; diff --git a/packages/wrangler/src/autoconfig/details/index.ts b/packages/wrangler/src/autoconfig/details/index.ts index 155ca62836..2af7f792dc 100644 --- a/packages/wrangler/src/autoconfig/details/index.ts +++ b/packages/wrangler/src/autoconfig/details/index.ts @@ -14,7 +14,11 @@ import { confirm, prompt, select } from "../../dialogs"; import { logger } from "../../logger"; import { sendMetricsEvent } from "../../metrics"; import { NpmPackageManager } from "../../package-manager"; -import { allKnownFrameworks, getFramework } from "../frameworks/get-framework"; +import { getFrameworkClass } from "../frameworks"; +import { + allFrameworksInfos, + staticFramework, +} from "../frameworks/all-frameworks"; import { getAutoConfigId, getAutoConfigTriggerCommand, @@ -154,7 +158,7 @@ export async function getDetailsForAutoConfig({ const { detectedFramework, packageManager, isWorkspaceRoot } = await detectFramework(projectPath, wranglerConfig); - const framework = getFramework(detectedFramework?.framework?.id); + const framework = getFrameworkClass(detectedFramework.framework.id); const packageJsonPath = resolve(projectPath, "package.json"); let packageJson: PackageJSON | undefined; @@ -422,26 +426,26 @@ export async function confirmAutoConfigDetails( const frameworkId = await select( "What framework is your application using?", { - choices: allKnownFrameworks.map((f) => ({ + choices: allFrameworksInfos.map((f) => ({ title: f.name, value: f.id, description: - f.id === "static" + f.id === staticFramework.id ? "No framework at all, or a static framework such as Vite, React or Gatsby." : `The ${f.name} JavaScript framework`, })), - defaultOption: allKnownFrameworks.findIndex((framework) => { + defaultOption: allFrameworksInfos.findIndex((framework) => { if (!autoConfigDetails?.framework) { // If there is no framework already detected let's default to the static one // (note: there should always be a framework at this point) - return framework.id === "static"; + return framework.id === staticFramework.id; } return autoConfigDetails.framework.id === framework.id; }), } ); - updatedAutoConfigDetails.framework = getFramework(frameworkId); + updatedAutoConfigDetails.framework = getFrameworkClass(frameworkId); const outputDir = await prompt( "What directory contains your applications' output/asset files?", diff --git a/packages/wrangler/src/autoconfig/frameworks/all-frameworks.ts b/packages/wrangler/src/autoconfig/frameworks/all-frameworks.ts new file mode 100644 index 0000000000..a46b5eb816 --- /dev/null +++ b/packages/wrangler/src/autoconfig/frameworks/all-frameworks.ts @@ -0,0 +1,269 @@ +import assert from "node:assert"; +import { Analog } from "./analog"; +import { Angular } from "./angular"; +import { Astro } from "./astro"; +import { Hono } from "./hono"; +import { NextJs } from "./next"; +import { Nuxt } from "./nuxt"; +import { CloudflarePages } from "./pages"; +import { Qwik } from "./qwik"; +import { ReactRouter } from "./react-router"; +import { SolidStart } from "./solid-start"; +import { Static } from "./static"; +import { SvelteKit } from "./sveltekit"; +import { TanstackStart } from "./tanstack"; +import { Vike } from "./vike"; +import { Vite } from "./vite"; +import { Waku } from "./waku"; +import type { AutoConfigFrameworkPackageInfo, FrameworkInfo } from "."; + +/** + * Information about all the known frameworks, including frameworks that we know about but we don't support. + * + * The "static" framework is not included in this list. + */ +export const allKnownFrameworks = [ + { + id: "analog", + name: "Analog", + class: Analog, + frameworkPackageInfo: { + name: "@analogjs/platform", + // Analog didn't work well before 2.0.0 with Cloudflare + // See: https://github.com/cloudflare/workers-sdk/issues/11470 + minimumVersion: "2.0.0", + maximumKnownMajorVersion: "2", + }, + supported: true, + }, + { + id: "angular", + name: "Angular", + class: Angular, + frameworkPackageInfo: { + name: "@angular/core", + // Angular 19 introduced ssr.experimentalPlatform and AngularAppEngine + // which are required for Cloudflare Workers support + // See: https://github.com/angular/angular-cli/releases/tag/19.0.0 + minimumVersion: "19.0.0", + maximumKnownMajorVersion: "21", + }, + supported: true, + }, + { + id: "astro", + name: "Astro", + class: Astro, + frameworkPackageInfo: { + name: "astro", + // Version 4 was the earliest version that we manually tested + // in https://github.com/cloudflare/workers-sdk/pull/12938 + // earlier versions might also be supported but we haven't checked them + minimumVersion: "4.0.0", + maximumKnownMajorVersion: "6", + }, + supported: true, + }, + { + id: "hono", + name: "Hono", + class: Hono, + supported: false, + }, + { + id: "next", + name: "Next.js", + class: NextJs, + frameworkPackageInfo: { + name: "next", + // 14.2.35 is the earliest version of Next.js officially supported by open-next + // see: https://github.com/cloudflare/workers-sdk/pull/11704#discussion_r2634519440 + minimumVersion: "14.2.35", + maximumKnownMajorVersion: "16", + }, + supported: true, + }, + { + id: "nuxt", + name: "Nuxt", + class: Nuxt, + frameworkPackageInfo: { + name: "nuxt", + // 3.21.0 is the first Nuxt version with Nitro 2.11+ which supports + // cloudflare.deployConfig and cloudflare.nodeCompat options + // See: https://github.com/nuxt/nuxt/releases/tag/v3.21.0 + // See: https://github.com/nitrojs/nitro/releases/tag/v2.11.0 + minimumVersion: "3.21.0", + maximumKnownMajorVersion: "4", + }, + supported: true, + }, + { + id: "qwik", + name: "Qwik", + class: Qwik, + frameworkPackageInfo: { + name: "@builder.io/qwik", + // 1.1.0 added the `platform` option in the qwikCity() Vite plugin + // which is required for getPlatformProxy integration + // See: https://github.com/QwikDev/qwik/pull/3604 + minimumVersion: "1.1.0", + maximumKnownMajorVersion: "1", + }, + supported: true, + }, + { + id: "react-router", + name: "React Router", + class: ReactRouter, + frameworkPackageInfo: { + name: "react-router", + // React Router v7 introduced framework mode with Vite integration and + // react-router.config.ts which are required for Cloudflare Workers support + // See: https://remix.run/blog/react-router-v7 + minimumVersion: "7.0.0", + maximumKnownMajorVersion: "7", + }, + supported: true, + }, + { + id: "solid-start", + name: "Solid Start", + class: SolidStart, + frameworkPackageInfo: { + name: "@solidjs/start", + // 1.0.0 is the first stable release with Nitro/Cloudflare Workers support + // See: https://github.com/solidjs/solid-start/releases/tag/v1.0.0 + minimumVersion: "1.0.0", + maximumKnownMajorVersion: "2", + }, + supported: true, + }, + { + id: "svelte-kit", + name: "SvelteKit", + class: SvelteKit, + frameworkPackageInfo: { + name: "@sveltejs/kit", + // 2.20.3 is required by @sveltejs/adapter-cloudflare@7.0.0 which first + // added Workers Static Assets support (cfTarget:workers option) + // See: https://github.com/sveltejs/kit/releases/tag/%40sveltejs%2Fadapter-cloudflare%407.0.0 + minimumVersion: "2.20.3", + maximumKnownMajorVersion: "2", + }, + supported: true, + }, + { + id: "tanstack-start", + name: "TanStack Start", + class: TanstackStart, + frameworkPackageInfo: { + name: "@tanstack/react-start", + // 1.132.0 is the first Release Candidate for TanStack Start that supports Cloudflare + // See: https://github.com/TanStack/router/releases/tag/v1.132.0 + minimumVersion: "1.132.0", + maximumKnownMajorVersion: "1", + }, + supported: true, + }, + { + id: "vite", + name: "Vite", + class: Vite, + frameworkPackageInfo: { + name: "vite", + // Vite 6 introduced the Environment API which @cloudflare/vite-plugin requires + // See: https://vite.dev/blog/announcing-vite6#experimental-environment-api + minimumVersion: "6.0.0", + maximumKnownMajorVersion: "8", + }, + supported: true, + }, + { + id: "vike", + name: "Vike", + class: Vike, + frameworkPackageInfo: { + name: "vike", + minimumVersion: "0.0.0", + maximumKnownMajorVersion: "0", + }, + supported: true, + }, + { + id: "waku", + name: "Waku", + class: Waku, + frameworkPackageInfo: { + name: "waku", + // Autoconfig could support Waku before 1.0.0-alpha.4, but different autoconfig logic + // would need to be implemented for such versions, so we just decided to only support + // version 1.0.0-alpha.4 and up + // See: https://github.com/cloudflare/workers-sdk/pull/12657 + minimumVersion: "1.0.0-alpha.4", + maximumKnownMajorVersion: "1", + }, + supported: true, + }, + { + id: "cloudflare-pages", + name: "Cloudflare Pages", + class: CloudflarePages, + // Autoconfiguring a Pages project into a Workers one is not yet supported + supported: false, + }, +] as const satisfies FrameworkInfo[]; + +/** + * Type specific for the "static" framework. + * + * It is supported by autoconfig but, unlike all other frameworks, it doesn't have a package associated to it + */ +type StaticFrameworkInfo = Omit & { + supported: true; +}; + +export const staticFramework = { + id: "static", + name: "Static", + class: Static, + supported: true, +} as const satisfies StaticFrameworkInfo; + +/** Information for all the possible frameworks. This includes the "static" framework */ +export const allFrameworksInfos = [ + staticFramework, + ...allKnownFrameworks, +] as const satisfies (FrameworkInfo | StaticFrameworkInfo)[]; + +/** + * Gets the package information for a given framework, erroring if the framework + * could not be determined or is not supported. + * + * Returns `undefined` for the "static" framework, which has no associated package. + * + * @param frameworkId The id of the target framework + * @returns The framework's target package info, or undefined if the framework is "static" + */ +export function getFrameworkPackageInfo( + frameworkId: FrameworkInfo["id"] +): AutoConfigFrameworkPackageInfo | undefined { + if (frameworkId === staticFramework.id) { + // The "static" framework does not have an associated package + return undefined; + } + const targetedFramework = allKnownFrameworks.find( + (framework) => framework.id === frameworkId + ); + assert( + targetedFramework, + `Could not determine framework package info for ${JSON.stringify( + frameworkId + )}` + ); + assert( + targetedFramework.supported, + `Framework unexpectedly not supported ${JSON.stringify(frameworkId)}` + ); + return targetedFramework.frameworkPackageInfo; +} diff --git a/packages/wrangler/src/autoconfig/frameworks/analog.ts b/packages/wrangler/src/autoconfig/frameworks/analog.ts index 536616d574..f1cf0ad7ba 100644 --- a/packages/wrangler/src/autoconfig/frameworks/analog.ts +++ b/packages/wrangler/src/autoconfig/frameworks/analog.ts @@ -1,4 +1,3 @@ -import assert from "node:assert"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { updateStatus } from "@cloudflare/cli"; @@ -6,18 +5,17 @@ import { blue } from "@cloudflare/cli/colors"; import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; import { getLocalWorkerdCompatibilityDate } from "@cloudflare/workers-utils"; import * as recast from "recast"; -import semiver from "semiver"; -import { getInstalledPackageVersion } from "./utils/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class Analog extends Framework { async configure({ dryRun, projectPath, }: ConfigurationOptions): Promise { - checkMinimumAnalogVersion(projectPath); - if (!dryRun) { await updateViteConfig(projectPath); } @@ -92,32 +90,3 @@ async function updateViteConfig(projectPath: string) { }, }); } - -/** - * Checks that the project's analog version to ensure that it is greater than 2.0.0, an error is thrown if it isn't. - * - * We preform this check because, prior to v2 Analog had a different implementation, so the autoconfig configuration steps - * would be significantly different for such versions, also some of those versions had some incompatibility with what was - * at the time our integration solution with Analog (and we didn't get to the bottom of those issues), for these two reasons - * we just say what analog pre-v2 is not supported (of course we can always revisit this in the future is needed). - * - * @param projectPath The path of the project - */ -function checkMinimumAnalogVersion(projectPath: string): void { - const analogJsVersion = getInstalledPackageVersion( - "@analogjs/platform", - projectPath - ); - - assert( - analogJsVersion, - "Unable to discern the version of the `@analogjs/platform` package" - ); - - if (semiver(analogJsVersion, "2.0.0") < 0) { - // Note: analog, prior to v2 had a different implementation so the configuration steps here would be significantly different, - // also some of those analog versions had some incompatibility with what was at the time our integration solution with analog, - // for these two reasons we just say what analog pre-v2 is not supported - throw new Error("Analog versions earlier than 2.0.0 are not supported"); - } -} diff --git a/packages/wrangler/src/autoconfig/frameworks/angular.ts b/packages/wrangler/src/autoconfig/frameworks/angular.ts index b102d64e45..d079ac4241 100644 --- a/packages/wrangler/src/autoconfig/frameworks/angular.ts +++ b/packages/wrangler/src/autoconfig/frameworks/angular.ts @@ -6,9 +6,12 @@ import { spinner } from "@cloudflare/cli/interactive"; import { installPackages } from "@cloudflare/cli/packages"; import { parseJSONC } from "@cloudflare/workers-utils"; import { dedent } from "../../utils/dedent"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; import type { PackageManager } from "../../package-manager"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class Angular extends Framework { async configure({ diff --git a/packages/wrangler/src/autoconfig/frameworks/astro.ts b/packages/wrangler/src/autoconfig/frameworks/astro.ts index c00a428598..d5a1b58ec2 100644 --- a/packages/wrangler/src/autoconfig/frameworks/astro.ts +++ b/packages/wrangler/src/autoconfig/frameworks/astro.ts @@ -1,4 +1,3 @@ -import assert from "node:assert"; import { existsSync, readFileSync as fsReadFileSync, @@ -14,11 +13,12 @@ import { parseJSONC } from "@cloudflare/workers-utils"; import * as recast from "recast"; import semiver from "semiver"; import { logger } from "../../logger"; -import { AutoConfigFrameworkConfigurationError } from "../errors"; -import { getInstalledPackageVersion } from "./utils/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; import type { PackageManager } from "../../package-manager"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class Astro extends Framework { async configure({ @@ -28,8 +28,7 @@ export class Astro extends Framework { projectPath, isWorkspaceRoot, }: ConfigurationOptions): Promise { - const astroVersion = getAstroVersion(projectPath); - validateMinimumAstroVersion(astroVersion); + const astroVersion = this.frameworkVersion; const { npx } = packageManager; if (!dryRun) { @@ -83,42 +82,6 @@ export class Astro extends Framework { 'Configuring project for Astro with "astro add cloudflare"'; } -/** - * Gets the installed version of the "astro" package - * @param projectPath The path of the project - */ -function getAstroVersion(projectPath: string): string { - const packageName = "astro"; - const astroVersion = getInstalledPackageVersion(packageName, projectPath); - - assert( - astroVersion, - `Unable to discern the version of the \`${packageName}\` package` - ); - - return astroVersion; -} - -/** - * Checks whether the version of the Astro package is less than the minimum one we support, if not an error is thrown. - * - * TODO: We should standardize and define a better approach for this type of check and apply it to all the frameworks we support. - * - * @param astroVersion The version of the astro package used in the project - */ -function validateMinimumAstroVersion(astroVersion: string) { - const minumumAstroVersion = "4.0.0"; - if (astroVersion && semiver(astroVersion, minumumAstroVersion) < 0) { - throw new AutoConfigFrameworkConfigurationError( - `The version of Astro used in the project (${JSON.stringify( - astroVersion - )}) is not supported by the Wrangler automatic configuration. Please update the Astro version to at least ${JSON.stringify( - minumumAstroVersion - )} and try again.` - ); - } -} - /** * Finds the Astro config file in the project directory. * Checks for astro.config.mjs, astro.config.ts, and astro.config.js in that order. diff --git a/packages/wrangler/src/autoconfig/frameworks/framework-class.ts b/packages/wrangler/src/autoconfig/frameworks/framework-class.ts new file mode 100644 index 0000000000..d7efe619f7 --- /dev/null +++ b/packages/wrangler/src/autoconfig/frameworks/framework-class.ts @@ -0,0 +1,115 @@ +import assert from "node:assert"; +import semiver from "semiver"; +import { logger } from "../../logger"; +import { AutoConfigFrameworkConfigurationError } from "../errors"; +import { getInstalledPackageVersion } from "./utils/packages"; +import type { AutoConfigFrameworkPackageInfo, FrameworkInfo } from "."; +import type { PackageManager } from "../../package-manager"; +import type { RawConfig } from "@cloudflare/workers-utils"; + +export abstract class Framework { + readonly id: FrameworkInfo["id"]; + readonly name: FrameworkInfo["name"]; + + #frameworkVersion: string | undefined; + get frameworkVersion(): string { + assert( + this.#frameworkVersion, + `The version for ${JSON.stringify(this.name)} is unexpectedly missing` + ); + return this.#frameworkVersion; + } + + constructor(frameworkInfo: Pick) { + this.id = frameworkInfo.id; + this.name = frameworkInfo.name; + } + + isConfigured(_projectPath: string): boolean { + return false; + } + + abstract configure( + options: ConfigurationOptions + ): Promise | ConfigurationResults; + + configurationDescription?: string; + + /** + * Validates the installed framework version against the supported range and + * stores it for later access via the `frameworkVersion` getter. + * Warns via `logger` if the version exceeds `maximumKnownMajorVersion`. + * + * @param projectPath - Path to the project root used to resolve the installed version. + * @param frameworkPackageInfo - Package metadata including name and version bounds. + * @throws {AssertionError} If the installed version cannot be determined. + * @throws {AutoConfigFrameworkConfigurationError} If the version is below `minimumVersion`. + */ + validateFrameworkVersion( + projectPath: string, + frameworkPackageInfo: AutoConfigFrameworkPackageInfo + ) { + const frameworkVersion = getInstalledPackageVersion( + frameworkPackageInfo.name, + projectPath + ); + + assert( + frameworkVersion, + `Unable to detect the version of the \`${frameworkPackageInfo.name}\` package` + ); + + if (semiver(frameworkVersion, frameworkPackageInfo.minimumVersion) < 0) { + throw new AutoConfigFrameworkConfigurationError( + `The version of ${this.name} used in the project (${JSON.stringify( + frameworkVersion + )}) cannot be automatically configured. Please update the ${ + this.name + } version to at least ${JSON.stringify( + frameworkPackageInfo.minimumVersion + )} and try again.` + ); + } + + if ( + semiver(frameworkVersion, frameworkPackageInfo.maximumKnownMajorVersion) > + 0 + ) { + logger.warn( + `The version of ${this.name} used in the project (${JSON.stringify( + frameworkVersion + )}) is not officially supported, and may fail to correctly configure. Please report any issues to https://github.com/cloudflare/workers-sdk/issues` + ); + } + + this.#frameworkVersion = frameworkVersion; + } +} + +export type ConfigurationOptions = { + outputDir: string; + projectPath: string; + workerName: string; + dryRun: boolean; + packageManager: PackageManager; + isWorkspaceRoot: boolean; +}; + +export type PackageJsonScriptsOverrides = { + preview?: string; // default is `npm run build && wrangler dev` + deploy?: string; // default is `npm run build && wrangler deploy` + typegen?: string; // default is `wrangler types` +}; + +export type ConfigurationResults = { + /** The wrangler configuration that the framework's `configure()` hook should generate. `null` if autoconfig should not create the wrangler file (in case an external tool already does that) */ + wranglerConfig: RawConfig | null; + // Scripts to override in the package.json. Most frameworks should not need to do this, as their default detected build command will be sufficient + packageJsonScriptsOverrides?: PackageJsonScriptsOverrides; + // Build command to override the standard one (`npm run build` or framework's build command) + buildCommandOverride?: string; + // Deploy command to override the standard one (`npx wrangler deploy`) + deployCommandOverride?: string; + // Version command to override the standard one (`npx wrangler versions upload`) + versionCommandOverride?: string; +}; diff --git a/packages/wrangler/src/autoconfig/frameworks/get-framework.ts b/packages/wrangler/src/autoconfig/frameworks/get-framework.ts deleted file mode 100644 index 5a4c7da879..0000000000 --- a/packages/wrangler/src/autoconfig/frameworks/get-framework.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Analog } from "./analog"; -import { Angular } from "./angular"; -import { Astro } from "./astro"; -import { Hono } from "./hono"; -import { NextJs } from "./next"; -import { Nuxt } from "./nuxt"; -import { CloudflarePages } from "./pages"; -import { Qwik } from "./qwik"; -import { ReactRouter } from "./react-router"; -import { SolidStart } from "./solid-start"; -import { Static } from "./static"; -import { SvelteKit } from "./sveltekit"; -import { TanstackStart } from "./tanstack"; -import { Vike } from "./vike"; -import { Vite } from "./vite"; -import { Waku } from "./waku"; -import type { Framework } from "."; - -export type FrameworkInfo = { - id: string; - name: string; - class: typeof Framework; -}; - -export const staticFramework = { - id: "static", - name: "Static", - class: Static, -} as const satisfies FrameworkInfo; - -export const allKnownFrameworks = [ - staticFramework, - { id: "analog", name: "Analog", class: Analog }, - { id: "angular", name: "Angular", class: Angular }, - { id: "astro", name: "Astro", class: Astro }, - { id: "hono", name: "Hono", class: Hono }, - { id: "next", name: "Next.js", class: NextJs }, - { id: "nuxt", name: "Nuxt", class: Nuxt }, - { id: "qwik", name: "Qwik", class: Qwik }, - { id: "react-router", name: "React Router", class: ReactRouter }, - { id: "solid-start", name: "Solid Start", class: SolidStart }, - { id: "svelte-kit", name: "SvelteKit", class: SvelteKit }, - { id: "tanstack-start", name: "TanStack Start", class: TanstackStart }, - { id: "vite", name: "Vite", class: Vite }, - { id: "vike", name: "Vike", class: Vike }, - { id: "waku", name: "Waku", class: Waku }, - { id: "cloudflare-pages", name: "Cloudflare Pages", class: CloudflarePages }, -] as const satisfies FrameworkInfo[]; - -export type KnownFrameworkId = (typeof allKnownFrameworks)[number]["id"]; - -export const allKnownFrameworksIds = new Set( - allKnownFrameworks.map(({ id }) => id) -); - -export function getFramework(frameworkId: FrameworkInfo["id"]): Framework { - const targetedFramework = allKnownFrameworks.find( - (framework) => framework.id === frameworkId - ); - const framework = targetedFramework ?? staticFramework; - return new framework.class({ id: framework.id, name: framework.name }); -} diff --git a/packages/wrangler/src/autoconfig/frameworks/hono.ts b/packages/wrangler/src/autoconfig/frameworks/hono.ts index c4d3af82ee..0a85404eb6 100644 --- a/packages/wrangler/src/autoconfig/frameworks/hono.ts +++ b/packages/wrangler/src/autoconfig/frameworks/hono.ts @@ -1,5 +1,5 @@ -import { Framework } from "."; -import type { ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { ConfigurationResults } from "./framework-class"; export class Hono extends Framework { async configure(): Promise { @@ -7,6 +7,4 @@ export class Hono extends Framework { wranglerConfig: {}, }; } - - autoConfigSupported = false; } diff --git a/packages/wrangler/src/autoconfig/frameworks/index.ts b/packages/wrangler/src/autoconfig/frameworks/index.ts index b0388496d7..c81e5f5bb7 100644 --- a/packages/wrangler/src/autoconfig/frameworks/index.ts +++ b/packages/wrangler/src/autoconfig/frameworks/index.ts @@ -1,53 +1,77 @@ -import type { PackageManager } from "../../package-manager"; -import type { FrameworkInfo } from "./get-framework"; -import type { RawConfig } from "@cloudflare/workers-utils"; +import assert from "node:assert"; +import { allFrameworksInfos, staticFramework } from "./all-frameworks"; +import type { Framework } from "./framework-class"; -export type ConfigurationOptions = { - outputDir: string; - projectPath: string; - workerName: string; - dryRun: boolean; - packageManager: PackageManager; - isWorkspaceRoot: boolean; -}; - -export type PackageJsonScriptsOverrides = { - preview?: string; // default is `npm run build && wrangler dev` - deploy?: string; // default is `npm run build && wrangler deploy` - typegen?: string; // default is `wrangler types` -}; - -export type ConfigurationResults = { - /** The wrangler configuration that the framework's `configure()` hook should generate. `null` if autoconfig should not create the wrangler file (in case an external tool already does that) */ - wranglerConfig: RawConfig | null; - // Scripts to override in the package.json. Most frameworks should not need to do this, as their default detected build command will be sufficient - packageJsonScriptsOverrides?: PackageJsonScriptsOverrides; - // Build command to override the standard one (`npm run build` or framework's build command) - buildCommandOverride?: string; - // Deploy command to override the standard one (`npx wrangler deploy`) - deployCommandOverride?: string; - // Version command to override the standard one (`npx wrangler versions upload`) - versionCommandOverride?: string; -}; +export type { Framework, PackageJsonScriptsOverrides } from "./framework-class"; -export abstract class Framework { - readonly id: string; - readonly name: string; +/** Set of the ids of all the possible frameworks, including the "static" framework */ +const allKnownFrameworksIds = new Set( + allFrameworksInfos.map(({ id }) => id) +); - constructor(frameworkInfo: Pick) { - this.id = frameworkInfo.id; - this.name = frameworkInfo.name; - } +/** + * Identifies whether a given id maps to a known framework's id + * + * @param frameworkId The target id to check + * @returns true if the id is that of a known framework, false otherwise + */ +export function isKnownFramework(frameworkId: string): boolean { + return allKnownFrameworksIds.has(frameworkId); +} - isConfigured(_projectPath: string): boolean { - return false; - } +/** + * Gets the class for a framework based on its id + * + * @param frameworkId The target framework's id + * @returns The class for the framework, defaulting to the static framework is the id is not recognized + */ +export function getFrameworkClass(frameworkId: FrameworkInfo["id"]): Framework { + const targetedFramework = allFrameworksInfos.find( + (framework) => framework.id === frameworkId + ); + const framework = targetedFramework ?? staticFramework; + return new framework.class({ id: framework.id, name: framework.name }); +} - abstract configure( - options: ConfigurationOptions - ): Promise | ConfigurationResults; +/** + * Checks whether a framework is supported by autoconfig. + * + * @param frameworkId The target framework's id + * @returns a boolean indicating wether the framework is supported + */ +export function isFrameworkSupported( + frameworkId: FrameworkInfo["id"] +): boolean { + const targetedFramework = allFrameworksInfos.find( + (framework) => framework.id === frameworkId + ); + assert( + targetedFramework, + `Unexpected unknown framework id: ${JSON.stringify(frameworkId)}` + ); + return targetedFramework.supported; +} - configurationDescription?: string; +export type FrameworkInfo = { + id: string; + name: string; + class: typeof Framework; +} & ( + | { supported: false } + | { + supported: true; + frameworkPackageInfo: AutoConfigFrameworkPackageInfo; + } +); - autoConfigSupported = true; -} +/** + * AutoConfig information for a package that defines a framework. + */ +export type AutoConfigFrameworkPackageInfo = { + /** The package name (e.g. "astro" for the Astro framework and "@solidjs/start" for the SolidStart framework) */ + name: string; + /** The minimum version (if any) of the package/framework that autoconfig supports */ + minimumVersion: string; + /** The latest major version of the package/framework that autoconfig supports */ + maximumKnownMajorVersion: string; +}; diff --git a/packages/wrangler/src/autoconfig/frameworks/next.ts b/packages/wrangler/src/autoconfig/frameworks/next.ts index 9efc9fb755..4ca828f520 100644 --- a/packages/wrangler/src/autoconfig/frameworks/next.ts +++ b/packages/wrangler/src/autoconfig/frameworks/next.ts @@ -1,9 +1,9 @@ import { runCommand } from "@cloudflare/cli/command"; -import semiver from "semiver"; -import { AutoConfigFrameworkConfigurationError } from "../errors"; -import { getInstalledPackageVersion } from "./utils/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class NextJs extends Framework { async configure({ @@ -11,21 +11,6 @@ export class NextJs extends Framework { projectPath, packageManager, }: ConfigurationOptions): Promise { - const firstNextVersionSupportedByOpenNext = "14.2.35"; - const installedNextVersion = getInstalledPackageVersion( - "next", - projectPath - ); - - if ( - installedNextVersion && - semiver(installedNextVersion, firstNextVersionSupportedByOpenNext) < 0 - ) { - throw new AutoConfigFrameworkConfigurationError( - `The detected Next.js version (${installedNextVersion}) is too old, please update the \`next\` dependency to at least ${firstNextVersionSupportedByOpenNext} and try again.` - ); - } - const { npx, dlx } = packageManager; if (!dryRun) { diff --git a/packages/wrangler/src/autoconfig/frameworks/nuxt.ts b/packages/wrangler/src/autoconfig/frameworks/nuxt.ts index 1181c6d73f..3a4f8bce87 100644 --- a/packages/wrangler/src/autoconfig/frameworks/nuxt.ts +++ b/packages/wrangler/src/autoconfig/frameworks/nuxt.ts @@ -3,8 +3,11 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { installPackages } from "@cloudflare/cli/packages"; import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; import * as recast from "recast"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; const updateNuxtConfig = (projectPath: string) => { const configFile = path.join(projectPath, "nuxt.config.ts"); diff --git a/packages/wrangler/src/autoconfig/frameworks/pages.ts b/packages/wrangler/src/autoconfig/frameworks/pages.ts index e22b04d43a..80f0ff6bcd 100644 --- a/packages/wrangler/src/autoconfig/frameworks/pages.ts +++ b/packages/wrangler/src/autoconfig/frameworks/pages.ts @@ -1,5 +1,5 @@ -import { Framework } from "."; -import type { ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { ConfigurationResults } from "./framework-class"; export class CloudflarePages extends Framework { async configure(): Promise { @@ -7,7 +7,4 @@ export class CloudflarePages extends Framework { wranglerConfig: {}, }; } - - // Autoconfiguring a Pages project into a Workers one is not yet supported - autoConfigSupported = false; } diff --git a/packages/wrangler/src/autoconfig/frameworks/qwik.ts b/packages/wrangler/src/autoconfig/frameworks/qwik.ts index c037e7d545..4ed05a582b 100644 --- a/packages/wrangler/src/autoconfig/frameworks/qwik.ts +++ b/packages/wrangler/src/autoconfig/frameworks/qwik.ts @@ -6,8 +6,11 @@ import { transformFile } from "@cloudflare/codemod"; import * as recast from "recast"; import * as typescriptParser from "recast/parsers/typescript"; import { usesTypescript } from "../uses-typescript"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; import type { Program } from "esprima"; export class Qwik extends Framework { diff --git a/packages/wrangler/src/autoconfig/frameworks/react-router.ts b/packages/wrangler/src/autoconfig/frameworks/react-router.ts index 3a5f56df43..4f891154f5 100644 --- a/packages/wrangler/src/autoconfig/frameworks/react-router.ts +++ b/packages/wrangler/src/autoconfig/frameworks/react-router.ts @@ -8,10 +8,12 @@ import * as recast from "recast"; import semiver from "semiver"; import dedent from "ts-dedent"; import { logger } from "../../logger"; -import { getInstalledPackageVersion } from "./utils/packages"; +import { Framework } from "./framework-class"; import { transformViteConfig } from "./utils/vite-config"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; const b = recast.types.builders; @@ -124,12 +126,7 @@ function transformReactRouterConfig( }); } -function configPropertyName(projectPath: string) { - const reactRouterVersion = getInstalledPackageVersion( - "react-router", - projectPath - ); - +function configPropertyName(reactRouterVersion: string) { if (!reactRouterVersion) { return "v8_viteEnvironmentApi"; } @@ -149,7 +146,7 @@ export class ReactRouter extends Framework { packageManager, isWorkspaceRoot, }: ConfigurationOptions): Promise { - const viteEnvironmentKey = configPropertyName(projectPath); + const viteEnvironmentKey = configPropertyName(this.frameworkVersion); if (!dryRun) { await installPackages(packageManager.type, ["@cloudflare/vite-plugin"], { dev: true, diff --git a/packages/wrangler/src/autoconfig/frameworks/solid-start.ts b/packages/wrangler/src/autoconfig/frameworks/solid-start.ts index 6dcdae4395..41efb711f8 100644 --- a/packages/wrangler/src/autoconfig/frameworks/solid-start.ts +++ b/packages/wrangler/src/autoconfig/frameworks/solid-start.ts @@ -1,4 +1,3 @@ -import assert from "node:assert"; import { updateStatus } from "@cloudflare/cli"; import { blue } from "@cloudflare/cli/colors"; import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; @@ -6,9 +5,11 @@ import { getLocalWorkerdCompatibilityDate } from "@cloudflare/workers-utils"; import * as recast from "recast"; import semiver from "semiver"; import { usesTypescript } from "../uses-typescript"; -import { getInstalledPackageVersion } from "./utils/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class SolidStart extends Framework { async configure({ @@ -16,7 +17,7 @@ export class SolidStart extends Framework { dryRun, }: ConfigurationOptions): Promise { if (!dryRun) { - const solidStartVersion = getSolidStartVersion(projectPath); + const solidStartVersion = this.frameworkVersion; if (semiver(solidStartVersion, "2.0.0-alpha") < 0) { updateAppConfigFile(projectPath); @@ -126,23 +127,3 @@ function updateAppConfigFile(projectPath: string): void { }, }); } - -/** - * Gets the installed version of the "@solidjs/start" package - * - * @param projectPath The path of the project - */ -function getSolidStartVersion(projectPath: string): string { - const packageName = "@solidjs/start"; - const solidStartVersion = getInstalledPackageVersion( - packageName, - projectPath - ); - - assert( - solidStartVersion, - `Unable to discern the version of the \`${packageName}\` package` - ); - - return solidStartVersion; -} diff --git a/packages/wrangler/src/autoconfig/frameworks/static.ts b/packages/wrangler/src/autoconfig/frameworks/static.ts index 468238fc5b..0d1d796379 100644 --- a/packages/wrangler/src/autoconfig/frameworks/static.ts +++ b/packages/wrangler/src/autoconfig/frameworks/static.ts @@ -1,5 +1,8 @@ -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class Static extends Framework { configure({ outputDir }: ConfigurationOptions): ConfigurationResults { diff --git a/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts b/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts index ec7126a072..bd630a269e 100644 --- a/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts +++ b/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts @@ -2,8 +2,11 @@ import { writeFileSync } from "node:fs"; import { brandColor, dim } from "@cloudflare/cli/colors"; import { runCommand } from "@cloudflare/cli/command"; import { installPackages } from "@cloudflare/cli/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class SvelteKit extends Framework { async configure({ @@ -26,7 +29,9 @@ export class SvelteKit extends Framework { silent: true, startText: "Installing adapter", doneText: `${brandColor("installed")} ${dim( - `via \`${dlx.join(" ")} sv add sveltekit-adapter=adapter:cloudflare+cfTarget:workers\`` + `via \`${dlx.join( + " " + )} sv add sveltekit-adapter=adapter:cloudflare+cfTarget:workers\`` )}`, } ); diff --git a/packages/wrangler/src/autoconfig/frameworks/tanstack.ts b/packages/wrangler/src/autoconfig/frameworks/tanstack.ts index b2f52e32c3..dd93b99865 100644 --- a/packages/wrangler/src/autoconfig/frameworks/tanstack.ts +++ b/packages/wrangler/src/autoconfig/frameworks/tanstack.ts @@ -1,8 +1,11 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { installPackages } from "@cloudflare/cli/packages"; +import { Framework } from "./framework-class"; import { transformViteConfig } from "./utils/vite-config"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class TanstackStart extends Framework { async configure({ @@ -15,7 +18,9 @@ export class TanstackStart extends Framework { await installPackages(packageManager.type, ["@cloudflare/vite-plugin"], { dev: true, startText: "Installing the Cloudflare Vite plugin", - doneText: `${brandColor(`installed`)} ${dim("@cloudflare/vite-plugin")}`, + doneText: `${brandColor(`installed`)} ${dim( + "@cloudflare/vite-plugin" + )}`, isWorkspaceRoot, }); diff --git a/packages/wrangler/src/autoconfig/frameworks/utils/packages.ts b/packages/wrangler/src/autoconfig/frameworks/utils/packages.ts index 5e721b02cf..98862460bb 100644 --- a/packages/wrangler/src/autoconfig/frameworks/utils/packages.ts +++ b/packages/wrangler/src/autoconfig/frameworks/utils/packages.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { parsePackageJSON, readFileSync } from "@cloudflare/workers-utils"; import * as find from "empathic/find"; @@ -52,7 +53,7 @@ export function getInstalledPackageVersion( /** * Gets the path for a package installed by a project (or undefined if the package is not installed) - + * * @param packageName the name of the target package * @param projectPath the path of the project * @returns the path for the package if the package is installed, undefined otherwise @@ -62,10 +63,23 @@ function getPackagePath( projectPath: string ): string | undefined { try { + // Note: we first try to `require.resolve` using the package.json this will succeed + // if the package.json is exported by the package + return path.dirname( + require.resolve(`${packageName}/package.json`, { + paths: [projectPath], + }) + ); + } catch {} + + try { + // Note: if `require.resolve` using the package.json failed (the package.json is not + // exported by the package) then let's try to `require.resolve` on the package + // name directly return require.resolve(packageName, { paths: [projectPath], }); - } catch { - return undefined; - } + } catch {} + + return undefined; } diff --git a/packages/wrangler/src/autoconfig/frameworks/vike.ts b/packages/wrangler/src/autoconfig/frameworks/vike.ts index 9465bccc14..29d0ee34e1 100644 --- a/packages/wrangler/src/autoconfig/frameworks/vike.ts +++ b/packages/wrangler/src/autoconfig/frameworks/vike.ts @@ -5,9 +5,12 @@ import { brandColor } from "@cloudflare/cli/colors"; import { installPackages } from "@cloudflare/cli/packages"; import { transformFile } from "@cloudflare/codemod"; import * as recast from "recast"; +import { Framework } from "./framework-class"; import { isPackageInstalled } from "./utils/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; import type { types } from "recast"; const b = recast.types.builders; diff --git a/packages/wrangler/src/autoconfig/frameworks/vite.ts b/packages/wrangler/src/autoconfig/frameworks/vite.ts index 3546c0a267..7613a33162 100644 --- a/packages/wrangler/src/autoconfig/frameworks/vite.ts +++ b/packages/wrangler/src/autoconfig/frameworks/vite.ts @@ -1,11 +1,14 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { installPackages } from "@cloudflare/cli/packages"; +import { Framework } from "./framework-class"; import { checkIfViteConfigUsesCloudflarePlugin, transformViteConfig, } from "./utils/vite-config"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; export class Vite extends Framework { isConfigured(projectPath: string): boolean { @@ -22,7 +25,9 @@ export class Vite extends Framework { await installPackages(packageManager.type, ["@cloudflare/vite-plugin"], { dev: true, startText: "Installing the Cloudflare Vite plugin", - doneText: `${brandColor(`installed`)} ${dim("@cloudflare/vite-plugin")}`, + doneText: `${brandColor(`installed`)} ${dim( + "@cloudflare/vite-plugin" + )}`, isWorkspaceRoot, }); diff --git a/packages/wrangler/src/autoconfig/frameworks/waku.ts b/packages/wrangler/src/autoconfig/frameworks/waku.ts index f68b1fe5b4..a99f1cb711 100644 --- a/packages/wrangler/src/autoconfig/frameworks/waku.ts +++ b/packages/wrangler/src/autoconfig/frameworks/waku.ts @@ -7,12 +7,12 @@ import { blue, brandColor } from "@cloudflare/cli/colors"; import { installPackages } from "@cloudflare/cli/packages"; import { transformFile } from "@cloudflare/codemod"; import * as recast from "recast"; -import semiver from "semiver"; import dedent from "ts-dedent"; -import { AutoConfigFrameworkConfigurationError } from "../errors"; -import { getInstalledPackageVersion } from "./utils/packages"; -import { Framework } from "."; -import type { ConfigurationOptions, ConfigurationResults } from "."; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; import type { types } from "recast"; const b = recast.types.builders; @@ -25,8 +25,6 @@ export class Waku extends Framework { packageManager, isWorkspaceRoot, }: ConfigurationOptions): Promise { - validateMinimumWakuVersion(projectPath); - if (!dryRun) { await installPackages( packageManager.type, @@ -56,28 +54,6 @@ export class Waku extends Framework { } } -/** - * Checks whether the version of the Waku package is less than the minimum one we support, in that case a warning is presented - * to the user without blocking them. - * - * TODO: We should standardize and define a better approach for this type of check and apply it to all the frameworks we support. - * - * @param projectPath The path to the project - */ -function validateMinimumWakuVersion(projectPath: string) { - const wakuVersion = getInstalledPackageVersion("waku", projectPath); - const minumumWakuVersion = "1.0.0-alpha.4"; - if (wakuVersion && semiver(wakuVersion, minumumWakuVersion) < 0) { - throw new AutoConfigFrameworkConfigurationError( - `The version of Waku used in the project (${JSON.stringify( - wakuVersion - )}) is not supported by the Wrangler automatic configuration. Please update the Waku version to at least ${JSON.stringify( - minumumWakuVersion - )} and try again.` - ); - } -} - /** * Created a waku.server.tsx file that uses the Cloudflare adapter * diff --git a/packages/wrangler/src/autoconfig/run.ts b/packages/wrangler/src/autoconfig/run.ts index 235c8e28e1..dfc3a583be 100644 --- a/packages/wrangler/src/autoconfig/run.ts +++ b/packages/wrangler/src/autoconfig/run.ts @@ -20,10 +20,15 @@ import { confirmAutoConfigDetails, displayAutoConfigDetails, } from "./details"; +import { + isFrameworkSupported, + isKnownFramework, + type PackageJsonScriptsOverrides, +} from "./frameworks"; +import { getFrameworkPackageInfo } from "./frameworks/all-frameworks"; import { Static } from "./frameworks/static"; import { getAutoConfigId } from "./telemetry-utils"; import { usesTypescript } from "./uses-typescript"; -import type { PackageJsonScriptsOverrides } from "./frameworks"; import type { AutoConfigDetails, AutoConfigDetailsForNonConfiguredProject, @@ -75,12 +80,17 @@ export async function runAutoConfig( autoConfigDetails = updatedAutoConfigDetails; assertNonConfigured(autoConfigDetails); - if (!autoConfigDetails.framework.autoConfigSupported) { - throw new FatalError( - autoConfigDetails.framework.id === "cloudflare-pages" - ? `The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to a Workers one is not yet supported.` - : `The detected framework ("${autoConfigDetails.framework.name}") cannot be automatically configured.` + if (isKnownFramework(autoConfigDetails.framework.id)) { + const frameworkIsSupported = isFrameworkSupported( + autoConfigDetails.framework.id ); + if (!frameworkIsSupported) { + throw new FatalError( + autoConfigDetails.framework.id === "cloudflare-pages" + ? `The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to Workers is not yet supported.` + : `The detected framework ("${autoConfigDetails.framework.name}") cannot be automatically configured.` + ); + } } assert( @@ -105,6 +115,16 @@ export async function runAutoConfig( const isWorkspaceRoot = autoConfigDetails.isWorkspaceRoot ?? false; + const frameworkPackageInfo = getFrameworkPackageInfo( + autoConfigDetails.framework.id + ); + if (frameworkPackageInfo) { + autoConfigDetails.framework.validateFrameworkVersion( + autoConfigDetails.projectPath, + frameworkPackageInfo + ); + } + const dryRunConfigurationResults = await autoConfigDetails.framework.configure({ outputDir: autoConfigDetails.outputDir, diff --git a/packages/wrangler/src/autoconfig/types.ts b/packages/wrangler/src/autoconfig/types.ts index e28b85baa9..f173ffed5c 100644 --- a/packages/wrangler/src/autoconfig/types.ts +++ b/packages/wrangler/src/autoconfig/types.ts @@ -1,6 +1,6 @@ import type { PackageManager } from "../package-manager"; import type { Optional } from "../utils/types"; -import type { Framework } from "./frameworks/index"; +import type { Framework } from "./frameworks/framework-class"; import type { PackageJSON, RawConfig } from "@cloudflare/workers-utils"; type AutoConfigDetailsBase = { diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index bfb4c98185..6ab3fbd4d7 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -128,6 +128,6 @@ export type { StartRemoteProxySessionOptions, Binding, RemoteProxySession }; export { getDetailsForAutoConfig as experimental_getDetailsForAutoConfig } from "./autoconfig/details"; export { runAutoConfig as experimental_runAutoConfig } from "./autoconfig/run"; -export { Framework as experimental_AutoConfigFramework } from "./autoconfig/frameworks/index"; +export { Framework as experimental_AutoConfigFramework } from "./autoconfig/frameworks/framework-class"; export { experimental_getWranglerCommands } from "./experimental-commands-api";