diff --git a/.changeset/add-init-command.md b/.changeset/add-init-command.md new file mode 100644 index 000000000..e9dd456e7 --- /dev/null +++ b/.changeset/add-init-command.md @@ -0,0 +1,9 @@ +--- +"@opennextjs/cloudflare": minor +--- + +feature: add `migrate` command to set up OpenNext for Cloudflare adapter + +This command helps users migrate existing Next.js applications to the OpenNext Cloudflare adapter by automatically setting up all necessary configuration files, dependencies, and scripts. + +To use the command simply run: `npx opennextjs-cloudflare migrate` diff --git a/packages/cloudflare/src/cli/build/utils/create-config-files.ts b/packages/cloudflare/src/cli/build/utils/create-config-files.ts index 0f7061550..b6e49355d 100644 --- a/packages/cloudflare/src/cli/build/utils/create-config-files.ts +++ b/packages/cloudflare/src/cli/build/utils/create-config-files.ts @@ -1,9 +1,7 @@ -import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { getPackageTemplatesDirPath } from "../../../utils/get-package-templates-dir-path.js"; import type { ProjectOptions } from "../../project-options.js"; import { askConfirmation } from "../../utils/ask-confirmation.js"; +import { createOpenNextConfigFile, findOpenNextConfig } from "../../utils/open-next-config.js"; +import { createWranglerConfigFile, findWranglerConfig } from "../../utils/wrangler-config.js"; /** * Creates a `wrangler.jsonc` file for the user if a wrangler config file doesn't already exist, @@ -13,12 +11,8 @@ import { askConfirmation } from "../../utils/ask-confirmation.js"; * * @param projectOpts The options for the project */ -export async function createWranglerConfigIfNotExistent(projectOpts: ProjectOptions): Promise { - const possibleExts = ["toml", "json", "jsonc"]; - - const wranglerConfigFileExists = possibleExts.some((ext) => - existsSync(join(projectOpts.sourceDir, `wrangler.${ext}`)) - ); +export async function createWranglerConfigIfNonExistent(projectOpts: ProjectOptions): Promise { + const wranglerConfigFileExists = Boolean(findWranglerConfig(projectOpts.sourceDir)); if (wranglerConfigFileExists) { return; } @@ -36,54 +30,7 @@ export async function createWranglerConfigIfNotExistent(projectOpts: ProjectOpti return; } - let wranglerConfig = readFileSync(join(getPackageTemplatesDirPath(), "wrangler.jsonc"), "utf8"); - - const appName = getAppNameFromPackageJson(projectOpts.sourceDir) ?? "app-name"; - - wranglerConfig = wranglerConfig.replaceAll('""', JSON.stringify(appName.replaceAll("_", "-"))); - - const compatDate = await getLatestCompatDate(); - if (compatDate) { - wranglerConfig = wranglerConfig.replace( - /"compatibility_date": "\d{4}-\d{2}-\d{2}"/, - `"compatibility_date": ${JSON.stringify(compatDate)}` - ); - } - - writeFileSync(join(projectOpts.sourceDir, "wrangler.jsonc"), wranglerConfig); -} - -function getAppNameFromPackageJson(sourceDir: string): string | undefined { - try { - const packageJsonStr = readFileSync(join(sourceDir, "package.json"), "utf8"); - const packageJson: Record = JSON.parse(packageJsonStr); - if (typeof packageJson.name === "string") return packageJson.name; - } catch { - /* empty */ - } -} - -export async function getLatestCompatDate(): Promise { - try { - const resp = await fetch(`https://registry.npmjs.org/workerd`); - const latestWorkerdVersion = ( - (await resp.json()) as { - "dist-tags": { latest: string }; - } - )["dist-tags"].latest; - - // The format of the workerd version is `major.yyyymmdd.patch`. - const match = latestWorkerdVersion.match(/\d+\.(\d{4})(\d{2})(\d{2})\.\d+/); - - if (match) { - const [, year, month, date] = match; - const compatDate = `${year}-${month}-${date}`; - - return compatDate; - } - } catch { - /* empty */ - } + await createWranglerConfigFile(projectOpts.sourceDir); } /** @@ -95,9 +42,8 @@ export async function getLatestCompatDate(): Promise { * @return The path to the created source file */ export async function createOpenNextConfigIfNotExistent(sourceDir: string): Promise { - const openNextConfigPath = join(sourceDir, "open-next.config.ts"); - - if (!existsSync(openNextConfigPath)) { + const openNextConfigPath = findOpenNextConfig(sourceDir); + if (!openNextConfigPath) { const answer = await askConfirmation( "Missing required `open-next.config.ts` file, do you want to create one?" ); @@ -106,7 +52,7 @@ export async function createOpenNextConfigIfNotExistent(sourceDir: string): Prom throw new Error("The `open-next.config.ts` file is required, aborting!"); } - cpSync(join(getPackageTemplatesDirPath(), "open-next.config.ts"), openNextConfigPath); + return createOpenNextConfigFile(sourceDir); } return openNextConfigPath; diff --git a/packages/cloudflare/src/cli/build/utils/files.ts b/packages/cloudflare/src/cli/build/utils/files.ts new file mode 100644 index 000000000..0166dc712 --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/files.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; + +/** + * Appends text to a file + * + * When the file does not exists, it is always created with the text content. + * When the file exists, the text is appended only when the predicate return `true`. + * + * @param filepath The path to the file. + * @param text The text to append to the file. + * @param condition A function that receives the current file content and returns `true` if the text should be appended to it, the condition is skipped when the file is being created. + */ +export function conditionalAppendFileSync( + filepath: string, + text: string, + condition: (fileContent: string) => boolean +): void { + const fileExists = fs.existsSync(filepath); + + if (!fileExists || condition(fs.readFileSync(filepath, "utf8"))) { + fs.appendFileSync(filepath, text); + } +} diff --git a/packages/cloudflare/src/cli/commands/build.ts b/packages/cloudflare/src/cli/commands/build.ts index 7191989fa..60b85e6ca 100644 --- a/packages/cloudflare/src/cli/commands/build.ts +++ b/packages/cloudflare/src/cli/commands/build.ts @@ -1,7 +1,7 @@ import type yargs from "yargs"; import { build as buildImpl } from "../build/build.js"; -import { createWranglerConfigIfNotExistent } from "../build/utils/index.js"; +import { createWranglerConfigIfNonExistent } from "../build/utils/index.js"; import type { WithWranglerArgs } from "./utils.js"; import { compileConfig, @@ -38,7 +38,7 @@ async function buildCommand( // Note: We don't ask when a custom config file is specified via `--config` // nor when `--skipWranglerConfigCheck` is used. if (!projectOpts.wranglerConfigPath && !args.skipWranglerConfigCheck) { - await createWranglerConfigIfNotExistent(projectOpts); + await createWranglerConfigIfNonExistent(projectOpts); } const wranglerConfig = await readWranglerConfig(args); diff --git a/packages/cloudflare/src/cli/commands/migrate.ts b/packages/cloudflare/src/cli/commands/migrate.ts new file mode 100644 index 000000000..58fbd1019 --- /dev/null +++ b/packages/cloudflare/src/cli/commands/migrate.ts @@ -0,0 +1,246 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { + checkRunningInsideNextjsApp, + findNextConfig, + findPackagerAndRoot, +} from "@opennextjs/aws/build/helper.js"; +import logger from "@opennextjs/aws/logger.js"; +import type yargs from "yargs"; + +import { conditionalAppendFileSync } from "../build/utils/files.js"; +import { createOpenNextConfigFile, findOpenNextConfig } from "../utils/open-next-config.js"; +import { createWranglerConfigFile, findWranglerConfig } from "../utils/wrangler-config.js"; +import { printHeaders } from "./utils.js"; + +/** + * Implementation of the `opennextjs-cloudflare migrate` command. + * + * @param args + */ +async function migrateCommand(args: { forceInstall: boolean }): Promise { + printHeaders("migrate"); + + logger.info("🚀 Setting up the OpenNext Cloudflare adapter...\n"); + + const projectDir = process.cwd(); + + checkRunningInsideNextjsApp({ appPath: projectDir }); + + const wranglerConfigFilePath = findWranglerConfig(projectDir); + if (wranglerConfigFilePath) { + logger.error( + `The project already contains a Wrangler config file (at ${wranglerConfigFilePath}).\n` + + "This means that your application is either a static site or a next-on-pages project.\n" + + "If your project is a static site and you want to migrate to OpenNext, delete the Wrangler configuration file, convert the project to a full stack one and try again." + + " if your project is a next-on-pages one remove any next-on-pages configuration, any edge runtime usage and try again." + ); + process.exit(1); + } + + if (findOpenNextConfig(projectDir)) { + logger.info( + `Exiting since the project is already configured for OpenNext (an \`open-next.config.ts\` file already exists)\n` + ); + return; + } + + const { packager } = findPackagerAndRoot(projectDir); + const packageManager = packageManagers[packager]; + + printStepTitle("Installing dependencies"); + try { + const forceFlag = args.forceInstall ? " --force" : ""; + childProcess.execSync(`${packageManager.install}${forceFlag} @opennextjs/cloudflare@latest`, { + stdio: "inherit", + }); + childProcess.execSync(`${packageManager.installDev}${forceFlag} wrangler@latest`, { stdio: "inherit" }); + } catch (error) { + logger.error("Failed to install dependencies:", (error as Error).message); + process.exit(1); + } + + printStepTitle("Creating wrangler.jsonc"); + await createWranglerConfigFile("./"); + + printStepTitle("Creating open-next.config.ts"); + await createOpenNextConfigFile("./"); + + const devVarsExists = fs.existsSync(".dev.vars"); + printStepTitle(`${devVarsExists ? "Updating" : "Creating"} .dev.vars file`); + conditionalAppendFileSync( + ".dev.vars", + "\nNEXTJS_ENV=development\n", + (content) => !/\bNEXTJS_ENV\b/.test(content) + ); + + printStepTitle(`${fs.existsSync("public/_headers") ? "Updating" : "Creating"} public/_headers file`); + conditionalAppendFileSync( + "public/_headers", + "\n\n# https://developers.cloudflare.com/workers/static-assets/headers\n" + + "# https://opennext.js.org/cloudflare/caching#static-assets-caching\n" + + "/_next/static/*\n" + + " Cache-Control: public,max-age=31536000,immutable\n\n", + (content) => !/^\/_next\/static\/*\b/.test(content) + ); + + printStepTitle("Updating package.json scripts"); + const openNextScripts = { + preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview", + deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + upload: "opennextjs-cloudflare build && opennextjs-cloudflare upload", + ["cf-typegen"]: "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + }; + try { + let packageJson: { scripts?: Record } = {}; + if (fs.existsSync("package.json")) { + packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as { + scripts?: Record; + }; + } + + packageJson.scripts = { + build: "next build", + ...packageJson.scripts, + ...openNextScripts, + }; + + fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2)); + } catch (error) { + logger.error("Failed to update package.json", (error as Error).message); + logger.warn( + "\nPlease ensure that your package.json contains the following scripts:\n" + + console.log( + [...Object.entries(openNextScripts)].map(([key, value]) => ` - ${key}: ${value}`).join("\n") + ) + + "\n" + ); + } + + const gitIgnoreExists = fs.existsSync(".gitignore"); + printStepTitle(`${gitIgnoreExists ? "Updating" : "Creating"} .gitignore file`); + conditionalAppendFileSync( + ".gitignore", + "\n# OpenNext\n.open-next\n", + (content) => !content.includes(".open-next") + ); + + printStepTitle("Updating Next.js config"); + conditionalAppendFileSync( + findNextConfig({ appPath: projectDir })!, + "\nimport('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev());\n", + (content) => !content.includes("initOpenNextCloudflareForDev") + ); + + printStepTitle("Checking for edge runtime usage"); + try { + const extensions = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"]; + const files = findFilesRecursive(projectDir, extensions); + let foundEdgeRuntime = false; + + for (const file of files) { + try { + const content = fs.readFileSync(file, "utf8"); + if (content.includes('export const runtime = "edge"')) { + logger.warn(`Found edge runtime in: ${file}`); + foundEdgeRuntime = true; + break; + } + } catch { + // Skip files that can't be read + } + } + + if (foundEdgeRuntime) { + logger.warn( + "Detected usage of the edge runtime.\n" + + "The edge runtime is not supported yet with @opennextjs/cloudflare.\n" + + 'Remove all the `export const runtime = "edge";` lines from your source files' + ); + } + } catch { + logger.warn( + "Failed to check for edge runtime usage.\n" + + "The edge runtime is not supported yet with @opennextjs/cloudflare.\n" + + 'If present, remove all the `export const runtime = "edge";` lines from your source files' + ); + } + + logger.info( + "🎉 OpenNext Cloudflare adapter complete!\n" + + "\nNext steps:\n" + + `- Run: "${packageManager.run} preview" to build and preview your Cloudflare application locally\n` + + `- Run: "${packageManager.run} deploy" to deploy your application to Cloudflare Workers\n` + ); +} + +interface PackageManager { + name: string; + install: string; + installDev: string; + run: string; +} + +const packageManagers = { + pnpm: { name: "pnpm", install: "pnpm add", installDev: "pnpm add -D", run: "pnpm" }, + npm: { name: "npm", install: "npm install", installDev: "npm install --save-dev", run: "npm run" }, + bun: { name: "bun", install: "bun add", installDev: "bun add -D", run: "bun" }, + yarn: { name: "yarn", install: "yarn add", installDev: "yarn add -D", run: "yarn" }, +} satisfies Record; + +/** + * Recursively searches a directory for files with specified extensions. + * + * Skips common build/cache directories: node_modules, .next, .open-next, .git, dist, build. + * + * @param dir - The directory path to start searching from + * @param extensions - Array of file extensions to match + * @param fileList - Accumulator array for found files (used internally for recursion) + * @returns Array of file paths matching the specified extensions + */ +function findFilesRecursive(dir: string, extensions: string[], fileList: string[] = []): string[] { + const files = fs.readdirSync(dir); + + files.forEach((file) => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + // Skip node_modules, .next, .open-next, and other common build/cache directories + if (!["node_modules", ".next", ".open-next", ".git", "dist", "build"].includes(file)) { + findFilesRecursive(filePath, extensions, fileList); + } + } else if (stat.isFile()) { + const ext = path.extname(file).toLowerCase(); + if (extensions.includes(ext)) { + fileList.push(filePath); + } + } + }); + + return fileList; +} + +function printStepTitle(title: string): void { + logger.info(`⚙️ ${title}...\n`); +} + +/** + * Add the `migrate` command to yargs configuration. + */ +export function addMigrateCommand(y: T) { + return y.command( + "migrate", + "Set up the OpenNext Cloudflare adapter in an existing Next.js project", + (args) => + args.option("forceInstall", { + type: "boolean", + alias: "f", + desc: "Install the dependencies using the `--force` flag.", + default: false, + }), + (args) => migrateCommand(args) + ); +} diff --git a/packages/cloudflare/src/cli/index.ts b/packages/cloudflare/src/cli/index.ts index 6f982c67e..18cd59eb8 100644 --- a/packages/cloudflare/src/cli/index.ts +++ b/packages/cloudflare/src/cli/index.ts @@ -4,6 +4,7 @@ import yargs from "yargs"; import { addBuildCommand } from "./commands/build.js"; import { addDeployCommand } from "./commands/deploy.js"; +import { addMigrateCommand } from "./commands/migrate.js"; import { addPopulateCacheCommand } from "./commands/populate-cache.js"; import { addPreviewCommand } from "./commands/preview.js"; import { addUploadCommand } from "./commands/upload.js"; @@ -18,6 +19,7 @@ export function runCommand() { addDeployCommand(y); addUploadCommand(y); addPopulateCacheCommand(y); + addMigrateCommand(y); return y.demandCommand(1, 1).parse(); } diff --git a/packages/cloudflare/src/cli/utils/open-next-config.ts b/packages/cloudflare/src/cli/utils/open-next-config.ts new file mode 100644 index 000000000..1a6e21cd5 --- /dev/null +++ b/packages/cloudflare/src/cli/utils/open-next-config.ts @@ -0,0 +1,34 @@ +import { cpSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { getPackageTemplatesDirPath } from "../../utils/get-package-templates-dir-path.js"; + +/** + * Finds the path to the OpenNext configuration file if it exists. + * + * @param appDir The directory to check for the open-next.config.ts file + * @returns The full path to open-next.config.ts if it exists, undefined otherwise + */ +export function findOpenNextConfig(appDir: string): string | undefined { + const openNextConfigPath = join(appDir, "open-next.config.ts"); + + if (existsSync(openNextConfigPath)) { + return openNextConfigPath; + } + + return undefined; +} + +/** + * Creates a `open-next.config.ts` file in the target directory for the project. + * + * @param appDir The Next application root + * @return The path to the created source file + */ +export async function createOpenNextConfigFile(appDir: string): Promise { + const openNextConfigPath = join(appDir, "open-next.config.ts"); + + cpSync(join(getPackageTemplatesDirPath(), "open-next.config.ts"), openNextConfigPath); + + return openNextConfigPath; +} diff --git a/packages/cloudflare/src/cli/utils/wrangler-config.ts b/packages/cloudflare/src/cli/utils/wrangler-config.ts new file mode 100644 index 000000000..10164cb7a --- /dev/null +++ b/packages/cloudflare/src/cli/utils/wrangler-config.ts @@ -0,0 +1,81 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { getPackageTemplatesDirPath } from "../../utils/get-package-templates-dir-path.js"; + +/** + * Gets the path to the Wrangler configuration file if it exists. + * + * @param appDir The directory to check for the Wrangler config file + * @returns The path to Wrangler config file if it exists, undefined otherwise + */ +export function findWranglerConfig(appDir: string): string | undefined { + const possibleExts = ["toml", "json", "jsonc"]; + + for (const ext of possibleExts) { + const path = join(appDir, `wrangler.${ext}`); + if (existsSync(path)) { + return path; + } + } + + return undefined; +} + +/** + * Creates a wrangler.jsonc config file in the target directory for the project. + * + * If a wrangler.jsonc file already exists it will be overridden. + * + * @param projectDir The target directory for the project + */ +export async function createWranglerConfigFile(projectDir: string) { + let wranglerConfig = readFileSync(join(getPackageTemplatesDirPath(), "wrangler.jsonc"), "utf8"); + + const appName = getAppNameFromPackageJson(projectDir) ?? "app-name"; + + wranglerConfig = wranglerConfig.replaceAll('""', JSON.stringify(appName.replaceAll("_", "-"))); + + const compatDate = await getLatestCompatDate(); + if (compatDate) { + wranglerConfig = wranglerConfig.replace( + /"compatibility_date": "\d{4}-\d{2}-\d{2}"/, + `"compatibility_date": ${JSON.stringify(compatDate)}` + ); + } + + writeFileSync(join(projectDir, "wrangler.jsonc"), wranglerConfig); +} + +function getAppNameFromPackageJson(sourceDir: string): string | undefined { + try { + const packageJsonStr = readFileSync(join(sourceDir, "package.json"), "utf8"); + const packageJson: Record = JSON.parse(packageJsonStr); + if (typeof packageJson.name === "string") return packageJson.name; + } catch { + /* empty */ + } +} + +async function getLatestCompatDate(): Promise { + try { + const resp = await fetch(`https://registry.npmjs.org/workerd`); + const latestWorkerdVersion = ( + (await resp.json()) as { + "dist-tags": { latest: string }; + } + )["dist-tags"].latest; + + // The format of the workerd version is `major.yyyymmdd.patch`. + const match = latestWorkerdVersion.match(/\d+\.(\d{4})(\d{2})(\d{2})\.\d+/); + + if (match) { + const [, year, month, date] = match; + const compatDate = `${year}-${month}-${date}`; + + return compatDate; + } + } catch { + /* empty */ + } +}