From 3c76945c345dfb755b61e0247aef157cecca8ff7 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Tue, 4 Aug 2026 11:31:33 -0500 Subject: [PATCH] [wrangler] Configure Next.js projects with vinext --- .changeset/next-vinext-autoconfig.md | 6 + .../src/frameworks/all-frameworks.ts | 5 +- packages/autoconfig/src/frameworks/next.ts | 31 ++-- .../autoconfig/tests/frameworks/next.test.ts | 68 +++++++++ .../src/__tests__/deploy/vinext.test.ts | 140 ++++++++++++++++++ packages/wrangler/src/deploy/autoconfig.ts | 8 +- packages/wrangler/src/deploy/index.ts | 8 +- packages/wrangler/src/deploy/open-next.ts | 5 + packages/wrangler/src/deploy/vinext.ts | 132 +++++++++++++++++ 9 files changed, 381 insertions(+), 22 deletions(-) create mode 100644 .changeset/next-vinext-autoconfig.md create mode 100644 packages/autoconfig/tests/frameworks/next.test.ts create mode 100644 packages/wrangler/src/__tests__/deploy/vinext.test.ts create mode 100644 packages/wrangler/src/deploy/vinext.ts diff --git a/.changeset/next-vinext-autoconfig.md b/.changeset/next-vinext-autoconfig.md new file mode 100644 index 00000000000..79236a9a11b --- /dev/null +++ b/.changeset/next-vinext-autoconfig.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/autoconfig": minor +"wrangler": minor +--- + +Configure and deploy Next.js 16 projects with vinext instead of OpenNext during Wrangler automatic configuration. diff --git a/packages/autoconfig/src/frameworks/all-frameworks.ts b/packages/autoconfig/src/frameworks/all-frameworks.ts index 7770d9fe39f..51ee38298ca 100644 --- a/packages/autoconfig/src/frameworks/all-frameworks.ts +++ b/packages/autoconfig/src/frameworks/all-frameworks.ts @@ -75,9 +75,8 @@ export const allKnownFrameworks = [ 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", + // vinext targets the latest major version of Next.js. + minimumVersion: "16.0.0", maximumKnownMajorVersion: "16", }, supported: true, diff --git a/packages/autoconfig/src/frameworks/next.ts b/packages/autoconfig/src/frameworks/next.ts index 70af2173cb1..ab715e847eb 100644 --- a/packages/autoconfig/src/frameworks/next.ts +++ b/packages/autoconfig/src/frameworks/next.ts @@ -17,12 +17,14 @@ export class NextJs extends Framework { await runCommand( [ ...dlx, - "@opennextjs/cloudflare", - "migrate", - // Note: we force-install so that even if an incompatible version of - // Next.js is used this installation still succeeds, moving users - // (hopefully) in right direction (instead of failing at this step) - "--force-install", + "vinext", + "init", + "--platform=cloudflare", + "--cdn-cache=workers-cache", + "--data-cache=none", + "--image-optimization=cloudflare-images", + "--no-prerender", + "--no-experimental-warm-cdn-cache", ], { cwd: projectPath, @@ -31,18 +33,19 @@ export class NextJs extends Framework { } return { - // `@opennextjs/cloudflare migrate` creates the wrangler config file - wranglerConfig: {}, + // `vinext init` creates the Wrangler and Vite configuration files. + wranglerConfig: null, packageJsonScriptsOverrides: { - preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview", - deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + preview: + "vinext build && wrangler dev --config dist/server/wrangler.json", + deploy: "vinext-cloudflare deploy --config dist/server/wrangler.json", }, - buildCommandOverride: `${npx} opennextjs-cloudflare build`, - deployCommandOverride: `${npx} opennextjs-cloudflare deploy`, - versionCommandOverride: `${npx} opennextjs-cloudflare upload`, + buildCommandOverride: `${npx} vinext build`, + deployCommandOverride: `${npx} vinext-cloudflare deploy`, + versionCommandOverride: `${npx} wrangler versions upload --config dist/server/wrangler.json`, }; } configurationDescription = - "Configuring project for Next.js with OpenNext by running `@opennextjs/cloudflare migrate`"; + "Configuring project for Next.js with vinext by running `vinext init`"; } diff --git a/packages/autoconfig/tests/frameworks/next.test.ts b/packages/autoconfig/tests/frameworks/next.test.ts new file mode 100644 index 00000000000..5e009fc514d --- /dev/null +++ b/packages/autoconfig/tests/frameworks/next.test.ts @@ -0,0 +1,68 @@ +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { beforeEach, describe, it, vi } from "vitest"; +import { NextJs } from "../../src/frameworks/next"; +import { createMockContext } from "../helpers/mock-context"; + +vi.mock("@cloudflare/cli-shared-helpers/command"); + +const context = createMockContext(); +const BASE_OPTIONS = { + projectPath: "/next-app", + workerName: "next-app", + outputDir: "dist/", + dryRun: false, + packageManager: NpmPackageManager, + isWorkspaceRoot: false, + context, +}; + +describe("Next.js framework configure()", () => { + beforeEach(() => { + vi.mocked(runCommand).mockReset(); + }); + + it("configures Cloudflare deployment with vinext", async ({ expect }) => { + const framework = new NextJs({ id: "next", name: "Next.js" }); + + const result = await framework.configure(BASE_OPTIONS); + + expect(runCommand).toHaveBeenCalledWith( + [ + "npx", + "vinext", + "init", + "--platform=cloudflare", + "--cdn-cache=workers-cache", + "--data-cache=none", + "--image-optimization=cloudflare-images", + "--no-prerender", + "--no-experimental-warm-cdn-cache", + ], + { cwd: "/next-app" } + ); + expect(result).toEqual({ + wranglerConfig: null, + packageJsonScriptsOverrides: { + preview: + "vinext build && wrangler dev --config dist/server/wrangler.json", + deploy: "vinext-cloudflare deploy --config dist/server/wrangler.json", + }, + buildCommandOverride: "npx vinext build", + deployCommandOverride: "npx vinext-cloudflare deploy", + versionCommandOverride: + "npx wrangler versions upload --config dist/server/wrangler.json", + }); + expect(framework.configurationDescription).toBe( + "Configuring project for Next.js with vinext by running `vinext init`" + ); + }); + + it("does not run vinext init during a dry run", async ({ expect }) => { + const framework = new NextJs({ id: "next", name: "Next.js" }); + + await framework.configure({ ...BASE_OPTIONS, dryRun: true }); + + expect(runCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/wrangler/src/__tests__/deploy/vinext.test.ts b/packages/wrangler/src/__tests__/deploy/vinext.test.ts new file mode 100644 index 00000000000..dd7f765c786 --- /dev/null +++ b/packages/wrangler/src/__tests__/deploy/vinext.test.ts @@ -0,0 +1,140 @@ +import { writeFile } from "node:fs/promises"; +import { getInstalledPackageVersion } from "@cloudflare/autoconfig"; +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { + getVinextDeployArguments, + maybeDelegateToVinextDeployCommand, +} from "../../deploy/vinext"; +import { getPackageManager } from "../../package-manager"; + +vi.mock("@cloudflare/autoconfig", async (importOriginal) => ({ + ...(await importOriginal()), + getInstalledPackageVersion: vi.fn(), +})); +vi.mock("@cloudflare/cli-shared-helpers/command"); +vi.mock("../../package-manager", async (importOriginal) => ({ + ...(await importOriginal()), + getPackageManager: vi.fn(), +})); + +describe("vinext deploy delegation", () => { + runInTempDir(); + const originalArgv = process.argv; + + beforeEach(async () => { + process.argv = ["node", "wrangler", "deploy", "--name", "my-worker"]; + vi.mocked(getPackageManager).mockResolvedValue({ + type: "npm", + npx: "npx", + dlx: ["npx"], + lockFiles: ["package-lock.json"], + }); + vi.mocked(getInstalledPackageVersion).mockReturnValue("1.0.0-beta.4"); + await writeFile( + "vite.config.ts", + 'import vinext from "vinext";\nimport { cloudflare } from "@cloudflare/vite-plugin";\nexport default { plugins: [vinext(), cloudflare()] };\n' + ); + await writeFile( + "wrangler.jsonc", + JSON.stringify({ + main: "vinext/server/fetch-handler", + assets: { + directory: "dist/client", + not_found_handling: "none", + binding: "ASSETS", + }, + }) + ); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it("delegates to the vinext Cloudflare deploy command", async ({ + expect, + }) => { + await expect( + maybeDelegateToVinextDeployCommand(process.cwd(), { skipBuild: true }) + ).resolves.toBe(true); + expect(runCommand).toHaveBeenCalledWith( + [ + "npx", + "vinext-cloudflare", + "deploy", + "--skip-build", + "--name", + "my-worker", + ], + { env: { VINEXT_CLOUDFLARE_DEPLOY: "true" } } + ); + }); + + it("does not delegate the nested Wrangler deploy", async ({ expect }) => { + vi.stubEnv("VINEXT_CLOUDFLARE_DEPLOY", "true"); + + await expect( + maybeDelegateToVinextDeployCommand(process.cwd()) + ).resolves.toBe(false); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it("does not replace an active OpenNext deployment", async ({ expect }) => { + vi.stubEnv("OPEN_NEXT_DEPLOY", "true"); + + await expect( + maybeDelegateToVinextDeployCommand(process.cwd()) + ).resolves.toBe(false); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it("does not delegate without the vinext Cloudflare packages", async ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); + + await expect( + maybeDelegateToVinextDeployCommand(process.cwd()) + ).resolves.toBe(false); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it("translates supported Wrangler deploy arguments", ({ expect }) => { + expect( + getVinextDeployArguments([ + "--autoconfig=true", + "--name=my-worker", + "-e=staging", + ]) + ).toEqual(["--name", "my-worker", "--env", "staging"]); + }); + + it("recognizes CommonJS Vite configuration", async ({ expect }) => { + await writeFile( + "vite.config.ts", + 'const vinext = require("vinext");\nconst { cloudflare } = require("@cloudflare/vite-plugin");\nmodule.exports = { plugins: [vinext(), cloudflare()] };\n' + ); + + await expect( + maybeDelegateToVinextDeployCommand(process.cwd()) + ).resolves.toBe(true); + }); + + it("requires vinext Wrangler scaffolding", async ({ expect }) => { + await writeFile("wrangler.jsonc", "{}\n"); + + await expect( + maybeDelegateToVinextDeployCommand(process.cwd()) + ).resolves.toBe(false); + }); + + it("rejects Wrangler arguments that vinext cannot preserve", ({ expect }) => { + expect(() => getVinextDeployArguments(["--keep-vars"])).toThrow( + 'option "--keep-vars" cannot be forwarded to vinext' + ); + }); +}); diff --git a/packages/wrangler/src/deploy/autoconfig.ts b/packages/wrangler/src/deploy/autoconfig.ts index 59f9f56a3dc..f93e9d5cb44 100644 --- a/packages/wrangler/src/deploy/autoconfig.ts +++ b/packages/wrangler/src/deploy/autoconfig.ts @@ -77,7 +77,8 @@ export async function maybeRunAutoConfig( args: Args, config: Config, options: { skipConfirmations?: boolean } = {} -): Promise<{ config: Config; aborted: boolean }> { +): Promise<{ config: Config; aborted: boolean; configured: boolean }> { + let configured = false; const shouldRunAutoConfig = args.autoconfig && // If there is a positional parameter, an assets directory specified via --assets, or an @@ -132,7 +133,7 @@ export async function maybeRunAutoConfig( command: "wrangler deploy", dryRun: !!args.dryRun, }); - return { config, aborted: true }; + return { config, aborted: true, configured }; } } else if (!details.configured) { const autoConfigSummary = await runAutoConfigLogic(details, { @@ -140,6 +141,7 @@ export async function maybeRunAutoConfig( dryRun: !!args.dryRun, skipConfirmations: options.skipConfirmations === true, }); + configured = !args.dryRun; writeOutput({ type: "autoconfig", @@ -171,7 +173,7 @@ export async function maybeRunAutoConfig( }); } - return { config, aborted: false }; + return { config, aborted: false, configured }; } /** diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 5820d2f539a..3fbb47e652f 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -26,6 +26,7 @@ import { detectAgent } from "../utils/detect-agent"; import { getScriptName } from "../utils/getScriptName"; import { maybeRunAutoConfig, promptForMissingDeployConfig } from "./autoconfig"; import { maybeDelegateToOpenNextDeployCommand } from "./open-next"; +import { maybeDelegateToVinextDeployCommand } from "./vinext"; import type { Config } from "@cloudflare/workers-utils"; export const deployCommand = createCommand({ @@ -165,7 +166,7 @@ export async function runDeployCommandHandler( ); } - // Needs to happen after auto-config logic to capture newly auto-configured open-next apps. + // Needs to happen after auto-config logic to capture newly auto-configured framework apps. // As a precaution we're gating the feature under the autoconfig flag for the time being. // If the user explicitly provided a --config path, they are targeting a specific Worker config and we should not delegate if ( @@ -173,7 +174,10 @@ export async function runDeployCommandHandler( args.autoconfig && !args.config && !args.dryRun && - (await maybeDelegateToOpenNextDeployCommand(process.cwd())) + ((await maybeDelegateToVinextDeployCommand(process.cwd(), { + skipBuild: autoConfigResult.configured && !args.env, + })) || + (await maybeDelegateToOpenNextDeployCommand(process.cwd()))) ) { return; } diff --git a/packages/wrangler/src/deploy/open-next.ts b/packages/wrangler/src/deploy/open-next.ts index 534a03abdd0..955755da72f 100644 --- a/packages/wrangler/src/deploy/open-next.ts +++ b/packages/wrangler/src/deploy/open-next.ts @@ -5,6 +5,7 @@ import { runCommand } from "@cloudflare/cli-shared-helpers/command"; import { getOpenNextDeployFromEnv } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { getPackageManager } from "../package-manager"; +import { getVinextDeployFromEnv } from "./vinext"; /** * If appropriate (when `wrangler deploy` is run in an OpenNext project without setting the `OPEN_NEXT_DEPLOY` environment variable) @@ -16,6 +17,10 @@ import { getPackageManager } from "../package-manager"; export async function maybeDelegateToOpenNextDeployCommand( projectRoot: string ): Promise { + if (getVinextDeployFromEnv()) { + return false; + } + if (await isOpenNextProject(projectRoot)) { const openNextDeploy = getOpenNextDeployFromEnv(); if (!openNextDeploy) { diff --git a/packages/wrangler/src/deploy/vinext.ts b/packages/wrangler/src/deploy/vinext.ts new file mode 100644 index 00000000000..e6430921957 --- /dev/null +++ b/packages/wrangler/src/deploy/vinext.ts @@ -0,0 +1,132 @@ +import assert from "node:assert"; +import { readFile, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { getInstalledPackageVersion } from "@cloudflare/autoconfig"; +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { + getOpenNextDeployFromEnv, + parseJSONC, + UserError, +} from "@cloudflare/workers-utils"; +import { logger } from "../logger"; +import { getPackageManager } from "../package-manager"; + +const VINEXT_DEPLOY_ENV = "VINEXT_CLOUDFLARE_DEPLOY"; + +export function getVinextDeployFromEnv(): boolean { + return process.env[VINEXT_DEPLOY_ENV] === "true"; +} + +export async function maybeDelegateToVinextDeployCommand( + projectRoot: string, + options: { skipBuild?: boolean } = {} +): Promise { + if ( + getVinextDeployFromEnv() || + getOpenNextDeployFromEnv() || + !(await isVinextProject(projectRoot)) + ) { + return false; + } + + logger.log("vinext project detected, calling `@vinext/cloudflare deploy`"); + + const deployArgIdx = process.argv.findIndex((arg) => arg === "deploy"); + assert(deployArgIdx !== -1, "Could not find `deploy` argument"); + const deployArguments = getVinextDeployArguments( + process.argv.slice(deployArgIdx + 1) + ); + if (options.skipBuild) { + deployArguments.unshift("--skip-build"); + } + const { npx } = await getPackageManager(); + + await runCommand([npx, "vinext-cloudflare", "deploy", ...deployArguments], { + env: { + [VINEXT_DEPLOY_ENV]: "true", + }, + }); + + return true; +} + +export function getVinextDeployArguments(args: string[]): string[] { + const supportedArguments: string[] = []; + + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === "--autoconfig" || argument === "--autoconfig=true") { + continue; + } + + const argumentWithValue = /^(--name|--env)=(.+)$/.exec(argument); + if (argumentWithValue) { + supportedArguments.push(argumentWithValue[1], argumentWithValue[2]); + continue; + } + const shortEnvWithValue = /^-e=(.+)$/.exec(argument); + if (shortEnvWithValue) { + supportedArguments.push("--env", shortEnvWithValue[1]); + continue; + } + + if (["--name", "--env", "-e"].includes(argument)) { + const value = args[++index]; + assert(value, `Expected a value after ${argument}`); + supportedArguments.push(argument === "-e" ? "--env" : argument, value); + continue; + } + + throw new UserError( + `The Wrangler option ${JSON.stringify(argument)} cannot be forwarded to vinext. Add the equivalent setting to wrangler.jsonc, then run \`wrangler deploy\` again.`, + { telemetryMessage: "vinext deploy option unsupported" } + ); + } + + return supportedArguments; +} + +async function isVinextProject(projectRoot: string): Promise { + try { + const projectFiles = await readdir(projectRoot); + const viteConfigFile = projectFiles.find((file) => + /^vite\.config\.(m|c)?(ts|js)$/.test(file) + ); + if (!viteConfigFile) { + return false; + } + + const wranglerConfigFile = projectFiles.find((file) => + /^wrangler\.jsonc?$/.test(file) + ); + if (!wranglerConfigFile) { + return false; + } + const wranglerConfig = parseJSONC( + await readFile(resolve(projectRoot, wranglerConfigFile), "utf8"), + wranglerConfigFile + ) as { + assets?: { + binding?: string; + directory?: string; + not_found_handling?: string; + }; + }; + if ( + wranglerConfig.assets?.binding !== "ASSETS" || + wranglerConfig.assets.directory !== "dist/client" || + wranglerConfig.assets.not_found_handling !== "none" + ) { + return false; + } + + return ["vinext", "@vinext/cloudflare"].every( + (packageName) => + getInstalledPackageVersion(packageName, projectRoot, { + stopAtProjectPath: true, + }) !== undefined + ); + } catch { + return false; + } +}