diff --git a/.changeset/silver-phones-brush.md b/.changeset/silver-phones-brush.md new file mode 100644 index 000000000..697b678f2 --- /dev/null +++ b/.changeset/silver-phones-brush.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": minor +--- + +feat: turbopack support diff --git a/examples/common/config-e2e.ts b/examples/common/config-e2e.ts index 9745198e8..824c96510 100644 --- a/examples/common/config-e2e.ts +++ b/examples/common/config-e2e.ts @@ -27,9 +27,9 @@ export function configurePlaywright( if (isCI) { // Do not build on CI - there is a preceding build step command = `pnpm preview:worker -- --port ${port} --inspector-port ${inspectorPort} ${env}`; - timeout = 200_000; + timeout = 800_000; } else { - timeout = 500_000; + timeout = 800_000; command = `pnpm preview -- --port ${port} --inspector-port ${inspectorPort} ${env}`; } } else { diff --git a/examples/e2e/app-router/package.json b/examples/e2e/app-router/package.json index 3101e5313..451340479 100644 --- a/examples/e2e/app-router/package.json +++ b/examples/e2e/app-router/package.json @@ -5,7 +5,7 @@ "scripts": { "openbuild": "node ../../packages/open-next/dist/index.js build --streaming --build-command \"npx turbo build\"", "dev": "next dev --turbopack --port 3001", - "build": "next build", + "build": "next build --turbopack", "start": "next start --port 3001", "lint": "next lint", "clean": "rm -rf .turbo node_modules .next .open-next", diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 954024513..8287c9aff 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -50,15 +50,12 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project copyPackageCliFiles(packageDistDir, buildOpts); const { appPath, outputDir, monorepoRoot, debug } = buildOpts; - const baseManifestPath = path.join( - outputDir, - "server-functions/default", - getPackagePath(buildOpts), - ".next" - ); - const serverFiles = path.join(baseManifestPath, "required-server-files.json"); + const dotNextPath = path.join(outputDir, "server-functions/default", getPackagePath(buildOpts), ".next"); + const serverFiles = path.join(dotNextPath, "required-server-files.json"); const nextConfig = JSON.parse(fs.readFileSync(serverFiles, "utf-8")).config; + const useTurbopack = fs.existsSync(path.join(dotNextPath, "server/chunks/[turbopack]_runtime.js")); + console.log(`\x1b[35m⚙️ Bundling the OpenNext server...\n\x1b[0m`); await patchWebpackRuntime(buildOpts); @@ -141,13 +138,12 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project // Note: we need the __non_webpack_require__ variable declared as it is used by next-server: // https://github.com/vercel/next.js/blob/be0c3283/packages/next/src/server/next-server.ts#L116-L119 __non_webpack_require__: "require", + // The 2 following defines are used to reduce the bundle size by removing unnecessary code + // Next uses different precompiled renderers (i.e. `app-page.runtime.prod.js`) based on if you use `TURBOPACK` or some experimental React features + ...(useTurbopack ? {} : { "process.env.TURBOPACK": "false" }), // We make sure that environment variables that Next.js expects are properly defined "process.env.NEXT_RUNTIME": '"nodejs"', "process.env.NODE_ENV": '"production"', - // The 2 following defines are used to reduce the bundle size by removing unnecessary code - // Next uses different precompiled renderers (i.e. `app-page.runtime.prod.js`) based on if you use `TURBOPACK` or some experimental React features - // Turbopack is not supported for build at the moment, so we disable it - "process.env.TURBOPACK": "false", // This define should be safe to use for Next 14.2+, earlier versions (13.5 and less) will cause trouble "process.env.__NEXT_EXPERIMENTAL_REACT": `${needsExperimentalReact(nextConfig)}`, // Fix `res.validate` in Next 15.4 (together with the `route-module` patch) diff --git a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts b/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts index b78aafc0a..479bf0d39 100644 --- a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts +++ b/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts @@ -27,6 +27,7 @@ import type { Plugin } from "esbuild"; import { getOpenNextConfig } from "../../../api/config.js"; import { patchResRevalidate } from "../patches/plugins/res-revalidate.js"; +import { patchTurbopackRuntime } from "../patches/plugins/turbopack.js"; import { patchUseCacheIO } from "../patches/plugins/use-cache.js"; import { normalizePath } from "../utils/index.js"; import { copyWorkerdPackages } from "../utils/workerd.js"; @@ -210,6 +211,7 @@ async function generateBundle( // Cloudflare specific patches patchResRevalidate, patchUseCacheIO, + patchTurbopackRuntime, ...additionalCodePatches, ]); diff --git a/packages/cloudflare/src/cli/build/patches/ast/patch-vercel-og-library.ts b/packages/cloudflare/src/cli/build/patches/ast/patch-vercel-og-library.ts index 0f22d70fb..f0d8a7949 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/patch-vercel-og-library.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/patch-vercel-og-library.ts @@ -24,6 +24,8 @@ export function patchVercelOgLibrary(buildOpts: BuildOptions) { for (const traceInfoPath of globSync(path.join(appBuildOutputPath, ".next/server/**/*.nft.json"), { windowsPathsNoEscape: true, })) { + let edgeFilePatched = false; + const traceInfo: TraceInfo = JSON.parse(readFileSync(traceInfoPath, { encoding: "utf8" })); const tracedNodePath = traceInfo.files.find((p) => p.endsWith("@vercel/og/index.node.js")); @@ -40,17 +42,23 @@ export function patchVercelOgLibrary(buildOpts: BuildOptions) { ); copyFileSync(tracedEdgePath, outputEdgePath); + } + if (!edgeFilePatched) { + edgeFilePatched = true; // Change font fetches in the library to use imports. const node = parseFile(outputEdgePath); const { edits, matches } = patchVercelOgFallbackFont(node); writeFileSync(outputEdgePath, node.commitEdits(edits)); - const fontFileName = matches[0]!.getMatch("PATH")!.text(); - renameSync(path.join(outputDir, fontFileName), path.join(outputDir, `${fontFileName}.bin`)); + if (matches.length > 0) { + const fontFileName = matches[0]!.getMatch("PATH")!.text(); + renameSync(path.join(outputDir, fontFileName), path.join(outputDir, `${fontFileName}.bin`)); + } } // Change node imports for the library to edge imports. + // This is only useful when turbopack is not used to bundle the function. const routeFilePath = traceInfoPath.replace(appBuildOutputPath, packagePath).replace(".nft.json", ""); const node = parseFile(routeFilePath); diff --git a/packages/cloudflare/src/cli/build/patches/plugins/turbopack.ts b/packages/cloudflare/src/cli/build/patches/plugins/turbopack.ts new file mode 100644 index 000000000..715334e4c --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/turbopack.ts @@ -0,0 +1,89 @@ +import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import type { CodePatcher } from "@opennextjs/aws/build/patch/codePatcher.js"; +import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; + +const inlineChunksRule = ` +rule: + kind: call_expression + pattern: require(resolved) +fix: + requireChunk(chunkPath) +`; + +export const patchTurbopackRuntime: CodePatcher = { + name: "inline-turbopack-chunks", + patches: [ + { + versions: ">=15.0.0", + pathFilter: getCrossPlatformPathRegex(String.raw`\[turbopack\]_runtime\.js$`, { + escape: false, + }), + contentFilter: /loadRuntimeChunkPath/, + patchCode: async ({ code, tracedFiles }) => { + let patched = patchCode(code, inlineExternalImportRule); + patched = patchCode(patched, inlineChunksRule); + + return `${patched}\n${inlineChunksFn(tracedFiles)}`; + }, + }, + ], +}; + +function getInlinableChunks(tracedFiles: string[]): string[] { + const chunks = new Set(); + for (const file of tracedFiles) { + if (file === "[turbopack]_runtime.js") { + continue; + } + if (file.includes(".next/server/chunks/")) { + chunks.add(file); + } + } + return Array.from(chunks); +} + +function inlineChunksFn(tracedFiles: string[]) { + // From the outputs, we extract every chunks + const chunks = getInlinableChunks(tracedFiles); + return ` + function requireChunk(chunkPath) { + switch(chunkPath) { +${chunks + .map( + (chunk) => + ` case "${ + // we only want the path after /path/to/.next/ + chunk.replace(/.*\/\.next\//, "") + }": return require("${chunk}");` + ) + .join("\n")} + default: + throw new Error(\`Not found \${chunkPath}\`); + } + } +`; +} + +// Turbopack imports `og` via `externalImport`. +// We patch it to: +// - add the explicit path so that the file is inlined by wrangler +// - use the edge version of the module instead of the node version. +// +// Modules that are not inlined (no added to the switch), would generate an error similar to: +// Failed to load external module path/to/module: Error: No such module "path/to/module" +const inlineExternalImportRule = ` +rule: + pattern: "$RAW = await import($ID)" + inside: + regex: "externalImport" + kind: function_declaration + stopBy: end +fix: |- + switch ($ID) { + case "next/dist/compiled/@vercel/og/index.node.js": + $RAW = await import("next/dist/compiled/@vercel/og/index.edge.js"); + break; + default: + $RAW = await import($ID); + } +`;