diff --git a/.changeset/c3-next-vinext.md b/.changeset/c3-next-vinext.md new file mode 100644 index 00000000000..fc4e9195f9e --- /dev/null +++ b/.changeset/c3-next-vinext.md @@ -0,0 +1,19 @@ +--- +"create-cloudflare": minor +--- + +feat: make vinext the default Next.js scaffold, keep OpenNext as a variant + +`create-cloudflare --framework=next` now prompts for a Next.js adapter: + +- **vinext** (default / recommended) — scaffolds via `create-vinext-app` (`vinext dev` / `vinext build` / `vinext-cloudflare deploy`) +- **opennext** — keeps the previous OpenNext remote template for projects that need standard `next build` output or a capability vinext does not support yet + +Non-interactive usage: + +```sh +npm create cloudflare@latest my-app -- --framework=next --variant=vinext +npm create cloudflare@latest my-app -- --framework=next --variant=opennext +``` + +`-y` / `--accept-defaults` selects vinext. This aligns C3 with the recommended Next.js-on-Workers path in the Cloudflare docs while preserving an opt-in OpenNext path. diff --git a/packages/create-cloudflare/e2e/helpers/framework-helpers.ts b/packages/create-cloudflare/e2e/helpers/framework-helpers.ts index fcb8afb0645..a6f31394da6 100644 --- a/packages/create-cloudflare/e2e/helpers/framework-helpers.ts +++ b/packages/create-cloudflare/e2e/helpers/framework-helpers.ts @@ -36,6 +36,7 @@ import type { ExpectStatic } from "vitest"; export type FrameworkTestConfig = RunnerConfig & { testCommitMessage: boolean; nodeCompat: boolean; + typesPath?: string; unsupportedPms?: string[]; unsupportedOSs?: string[]; /** @@ -326,10 +327,14 @@ export async function verifyPreviewScript( export async function verifyTypes( expect: ExpectStatic, - { nodeCompat, verifyTypes: verify }: FrameworkTestConfig, + { + nodeCompat, + typesPath: configuredTypesPath, + verifyTypes: verify, + }: FrameworkTestConfig, { workersTypes, - typesPath = "./worker-configuration.d.ts", + typesPath: templateTypesPath = "./worker-configuration.d.ts", envInterfaceName = "Env", }: TemplateConfig, projectPath: string @@ -338,6 +343,7 @@ export async function verifyTypes( return; } + const typesPath = configuredTypesPath ?? templateTypesPath; const outputFileContent = readFile(join(projectPath, typesPath)).split("\n"); const hasEnvInterface = outputFileContent.some( @@ -456,7 +462,16 @@ export function getFrameworkConfig(frameworkKey: string) { const frameworkMap = getFrameworkMap({ experimental: isExperimental, }); - const [frameworkId, platformVariant] = frameworkKey.split(":"); + // Test keys may include optional labels after the framework id, e.g. + // "next" | "next:vinext" | "nuxt:pages" | "nuxt:pages:minimal" + // Only "pages" / "workers" are treated as platform variants; any other + // trailing segment is a disambiguating label ignored here. + const [frameworkId, second, third] = frameworkKey.split(":"); + const platformVariant = + second === "pages" || second === "workers" ? second : undefined; + const _label = platformVariant ? third : second; // reserved for callers + void _label; + if ("platformVariants" in frameworkMap[frameworkId]) { assert( platformVariant === "pages" || platformVariant === "workers", diff --git a/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts b/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts index b6a4ab88223..11a7e676076 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts @@ -51,10 +51,16 @@ describe envInterfaceName: "Env", ...getFrameworkConfig(testConfig.name), }; - // A third `:`-segment in the test name (e.g. `nuxt:pages:minimal`) - // is a variant label that disambiguates tests sharing the same - // framework id + platform. getFrameworkConfig ignores it. - const variantLabel = testConfig.name.split(":")[2]; + // Trailing `:`-segments disambiguate tests that share a framework + // id (and optional platform). Examples: + // "nuxt:pages:minimal" → label "minimal" + // "next:opennext" → label "opennext" + // getFrameworkConfig ignores non-platform labels. + const nameParts = testConfig.name.split(":"); + const variantLabel = + nameParts[1] === "pages" || nameParts[1] === "workers" + ? nameParts[2] + : nameParts[1]; test.runIf(shouldRunTest(testConfig))( `${frameworkConfig.id} (${frameworkConfig.platform ?? "pages"})${ variantLabel ? ` [${variantLabel}]` : "" diff --git a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts index cc790472215..2884e99a5ab 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts @@ -345,8 +345,30 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { }, { name: "next", + // Default Next.js path: vinext via create-vinext-app. + argv: ["--variant", "vinext"], timeout: LONG_TIMEOUT, testCommitMessage: true, + // preview script is `build && start` (wrangler dev on build output). + verifyPreview: { + previewArgs: ["--inspector-port=0"], + route: "/", + expectedText: "vinext + Cloudflare Workers", + }, + verifyDeploy: { + route: "/", + expectedText: "vinext + Cloudflare Workers", + }, + nodeCompat: true, + unsupportedOSs: ["win32"], + }, + { + name: "next:opennext", + // Opt-in OpenNext adapter path (previous C3 default). + argv: ["--variant", "opennext"], + timeout: LONG_TIMEOUT, + testCommitMessage: true, + typesPath: "./cloudflare-env.d.ts", verifyPreview: { previewArgs: ["--", "--inspector-port=0"], route: "/", @@ -1098,13 +1120,36 @@ function getExperimentalFrameworkTestConfig( }, { name: "next", - argv: ["--platform", "workers"], + // Default experimental Next.js path: vinext. + argv: ["--platform", "workers", "--variant", "vinext"], + flags: ["--yes"], + testCommitMessage: true, + unsupportedOSs: ["win32"], + quarantine: !CLOUDFLARE_API_TOKEN, + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "vinext + Cloudflare Workers", + }, + verifyPreview: { + previewArgs: ["--inspector-port=0"], + route: "/", + expectedText: "vinext + Cloudflare Workers", + }, + nodeCompat: true, + // vinext does not ship a pre-baked cloudflare-env.d.ts the way the + // OpenNext template does. + verifyTypes: false, + }, + { + name: "next:opennext", + // Opt-in OpenNext adapter path under --experimental. + argv: ["--platform", "workers", "--variant", "opennext"], flags: ["--yes"], testCommitMessage: true, unsupportedOSs: ["win32"], unsupportedPms: ["npm", "yarn"], - // this test creates an R2 bucket, so it requires a Cloudflare API token - // and needs to be skipped on forks + // OpenNext template may create an R2 bucket; requires a CF API token. quarantine: !CLOUDFLARE_API_TOKEN, timeout: LONG_TIMEOUT, verifyDeploy: { diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index 3c4919ba3ea..85f08c27810 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -8,6 +8,7 @@ "create-docusaurus": "3.10.2", "create-hono": "0.19.4", "create-next-app": "16.2.11", + "create-vinext-app": "1.0.0-beta.1", "create-qwik": "1.20.0", "create-react-router": "8.3.0", "create-rwsdk": "3.1.3", diff --git a/packages/create-cloudflare/templates/next/c3.ts b/packages/create-cloudflare/templates/next/c3.ts index 26aafeaab31..5361efcf7cc 100644 --- a/packages/create-cloudflare/templates/next/c3.ts +++ b/packages/create-cloudflare/templates/next/c3.ts @@ -1,8 +1,96 @@ +import assert from "node:assert"; +import { logRaw } from "@cloudflare/cli-shared-helpers"; +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { runFrameworkGenerator } from "frameworks/index"; +import { detectPackageManager } from "helpers/packageManagers"; import { downloadRemoteTemplate, updatePackageName } from "../../src/templates"; import type { TemplateConfig } from "../../src/templates"; import type { C3Context } from "types"; -const generate = async (ctx: C3Context) => { +const { npm } = detectPackageManager(); + +type NextVariantValue = "vinext" | "opennext"; + +const VINEXT_TYPES_PATH = "./worker-configuration.d.ts"; +const OPENNEXT_TYPES_PATH = "./cloudflare-env.d.ts"; + +type NextVariant = { + value: NextVariantValue; + label: string; +}; + +const NEXT_VARIANTS: NextVariant[] = [ + { + value: "vinext", + label: "vinext (recommended)", + }, + { + value: "opennext", + label: "OpenNext adapter", + }, +]; + +async function getNextVariant(ctx: C3Context): Promise { + if (ctx.args.variant) { + const selected = NEXT_VARIANTS.find( + (variant) => variant.value === ctx.args.variant + ); + if (!selected) { + throw new Error( + `Unknown Next.js variant "${ + ctx.args.variant + }". Valid variants are: ${NEXT_VARIANTS.map((v) => v.value).join(", ")}` + ); + } + return selected; + } + + const value = await inputPrompt({ + type: "select", + question: "Which Next.js adapter do you want to use?", + label: "variant", + options: NEXT_VARIANTS, + defaultValue: NEXT_VARIANTS[0].value, + // Honour -y / --accept-defaults by taking the recommended vinext path. + acceptDefault: Boolean(ctx.args.acceptDefaults), + }); + + const selected = NEXT_VARIANTS.find((variant) => variant.value === value); + assert(selected, "Expected a Next.js variant to be selected"); + return selected; +} + +async function generateVinext(ctx: C3Context) { + // Delegate to create-vinext-app, which scaffolds a Next.js App Router project + // already configured for vinext + Cloudflare Workers. + const pmFlag = + npm === "pnpm" + ? "--use-pnpm" + : npm === "yarn" + ? "--use-yarn" + : npm === "bun" + ? "--use-bun" + : "--use-npm"; + + await runFrameworkGenerator(ctx, [ + ctx.project.name, + "--platform", + "cloudflare", + // Avoid a placeholder KV namespace id that would block `wrangler deploy` + // until the user manually provisions one. Caching can be added later + // via vinext's Cloudflare cache adapters. + "--data-cache", + "none", + "--yes", + "--skip-install", + "--disable-git", + pmFlag, + ]); + + logRaw(""); +} + +async function generateOpenNext(ctx: C3Context) { // Easy way to switch branch for local testing const branch = "main"; @@ -13,20 +101,52 @@ const generate = async (ctx: C3Context) => { }); await updatePackageName(ctx); +} + +const generate = async (ctx: C3Context) => { + const variant = await getNextVariant(ctx); + // Stash on args so transformPackageJson can branch without re-prompting. + ctx.args.variant = variant.value; + ctx.template.typesPath = + variant.value === "opennext" ? OPENNEXT_TYPES_PATH : VINEXT_TYPES_PATH; + + if (variant.value === "opennext") { + await generateOpenNext(ctx); + return; + } + + await generateVinext(ctx); }; const envInterfaceName = "CloudflareEnv"; -const typesPath = "./cloudflare-env.d.ts"; + export default { configVersion: 1, id: "next", - frameworkCli: "create-next-app", + // Used by runFrameworkGenerator for the vinext path. OpenNext downloads a + // remote template and does not invoke this CLI. + frameworkCli: "create-vinext-app", platform: "workers", displayName: "Next.js", generate, + transformPackageJson: async (_pkgJson, ctx) => { + // OpenNext's remote template already has deploy/preview/cf-typegen. + if (ctx.args.variant === "opennext") { + return {}; + } + + return { + scripts: { + // Align with OpenNext so the shared previewScript: "preview" works + // for both variants (vinext only ships dev/build/start/deploy). + preview: `${npm} run build && ${npm} run start`, + "cf-typegen": `wrangler types --env-interface ${envInterfaceName} ${VINEXT_TYPES_PATH}`, + }, + }; + }, devScript: "dev", previewScript: "preview", deployScript: "deploy", - typesPath, + typesPath: VINEXT_TYPES_PATH, envInterfaceName, } as TemplateConfig; diff --git a/packages/create-cloudflare/templates/next/experimental_c3.ts b/packages/create-cloudflare/templates/next/experimental_c3.ts index df12915f49a..223f5d5dac1 100644 --- a/packages/create-cloudflare/templates/next/experimental_c3.ts +++ b/packages/create-cloudflare/templates/next/experimental_c3.ts @@ -1,26 +1,4 @@ -import { runFrameworkGenerator } from "frameworks/index"; -import type { TemplateConfig } from "../../src/templates"; -import type { C3Context } from "types"; - -const generate = async (ctx: C3Context) => { - await runFrameworkGenerator(ctx, [ - ctx.project.name, - "--skip-install", - ]); -}; - -const envInterfaceName = "CloudflareEnv"; -const typesPath = "./cloudflare-env.d.ts"; -export default { - configVersion: 1, - id: "next", - frameworkCli: "create-next-app", - platform: "workers", - displayName: "Next.js", - generate, - devScript: "dev", - previewScript: "preview", - deployScript: "deploy", - typesPath, - envInterfaceName, -} as TemplateConfig; +// Experimental Next.js path shares the stable vinext/OpenNext variant flow. +// create-vinext-app (default) and the OpenNext remote template are both already +// Workers-ready, so no separate experimental generator is needed. +export { default } from "./c3";