diff --git a/.changeset/strange-laws-hang.md b/.changeset/strange-laws-hang.md new file mode 100644 index 00000000..74c58e01 --- /dev/null +++ b/.changeset/strange-laws-hang.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/cloudflare": patch +--- + +fix pages api routes + +fixed pages api routes by inlining a dynamic require in the `NodeModuleLoader` class diff --git a/examples/e2e/pages-router/e2e/api.test.ts b/examples/e2e/pages-router/e2e/api.test.ts new file mode 100644 index 00000000..12800f56 --- /dev/null +++ b/examples/e2e/pages-router/e2e/api.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "@playwright/test"; + +test("should not fail on an api route", async ({ page }) => { + const result = await page.goto("/api/hello"); + expect(result?.status()).toBe(200); + const body = await result?.json(); + expect(body).toEqual({ hello: "world" }); +}); diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 458bcccd..4ee0a329 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -11,6 +11,7 @@ import { patchWebpackRuntime } from "./patches/ast/webpack-runtime.js"; import * as patches from "./patches/index.js"; import { inlineBuildId } from "./patches/plugins/build-id.js"; import { ContentUpdater } from "./patches/plugins/content-updater.js"; +import { inlineDynamicRequires } from "./patches/plugins/dynamic-requires.js"; import { inlineEvalManifest } from "./patches/plugins/eval-manifest.js"; import { patchFetchCacheSetMissingWaitUntil } from "./patches/plugins/fetch-cache-wait-until.js"; import { inlineFindDir } from "./patches/plugins/find-dir.js"; @@ -20,7 +21,6 @@ import { handleOptionalDependencies } from "./patches/plugins/optional-deps.js"; import { patchDepdDeprecations } from "./patches/plugins/patch-depd-deprecations.js"; import { fixRequire } from "./patches/plugins/require.js"; import { shimRequireHook } from "./patches/plugins/require-hook.js"; -import { inlineRequirePage } from "./patches/plugins/require-page.js"; import { setWranglerExternal } from "./patches/plugins/wrangler-external.js"; import { normalizePath, patchCodeWithValidations } from "./utils/index.js"; @@ -88,7 +88,7 @@ export async function bundleServer(buildOpts: BuildOptions): Promise { conditions: [], plugins: [ shimRequireHook(buildOpts), - inlineRequirePage(updater, buildOpts), + inlineDynamicRequires(updater, buildOpts), setWranglerExternal(), fixRequire(updater), handleOptionalDependencies(optionalDependencies), diff --git a/packages/cloudflare/src/cli/build/patches/plugins/dynamic-requires.ts b/packages/cloudflare/src/cli/build/patches/plugins/dynamic-requires.ts new file mode 100644 index 00000000..436ee54b --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/dynamic-requires.ts @@ -0,0 +1,143 @@ +import { readFile } from "node:fs/promises"; +import { join, posix, sep } from "node:path"; + +import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js"; +import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; +import type { Plugin } from "esbuild"; + +import { normalizePath } from "../../utils/normalize-path.js"; +import { patchCode, type RuleConfig } from "../ast/util.js"; +import type { ContentUpdater } from "./content-updater.js"; + +async function getPagesManifests(serverDir: string): Promise { + try { + return Object.values(JSON.parse(await readFile(join(serverDir, "pages-manifest.json"), "utf-8"))); + } catch { + // The file does not exist + return []; + } +} + +async function getAppPathsManifests(serverDir: string): Promise { + try { + return Object.values(JSON.parse(await readFile(join(serverDir, "app-paths-manifest.json"), "utf-8"))); + } catch { + // The file does not exist + return []; + } +} + +function getServerDir(buildOpts: BuildOptions) { + return join(buildOpts.outputDir, "server-functions/default", getPackagePath(buildOpts), ".next/server"); +} + +function getRequires(idVariable: string, files: string[], serverDir: string) { + // Inline fs access and dynamic requires that are not supported by workerd. + return files + .map( + (file) => ` + if (${idVariable}.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)}).endsWith(${JSON.stringify(normalizePath(file))})) { + return require(${JSON.stringify(join(serverDir, file))}); + }` + ) + .join("\n"); +} + +export function inlineDynamicRequires(updater: ContentUpdater, buildOpts: BuildOptions): Plugin { + updater.updateContent( + "inline-node-module-loader", + { + filter: getCrossPlatformPathRegex( + String.raw`/next/dist/server/lib/module-loader/node-module-loader\.js$`, + { escape: false } + ), + contentFilter: /class NodeModuleLoader {/, + }, + async ({ contents }) => patchCode(contents, await getNodeModuleLoaderRule(buildOpts)) + ); + updater.updateContent( + "inline-require-page", + { + filter: getCrossPlatformPathRegex(String.raw`/next/dist/server/require\.js$`, { escape: false }), + contentFilter: /function requirePage\(/, + }, + async ({ contents }) => patchCode(contents, await getRequirePageRule(buildOpts)) + ); + return { name: "inline-dynamic-requires", setup() {} }; +} + +async function getNodeModuleLoaderRule(buildOpts: BuildOptions) { + const serverDir = getServerDir(buildOpts); + + const manifests = await getPagesManifests(serverDir); + + const files = manifests.filter((file) => file.endsWith(".js")); + + return ` +rule: + kind: method_definition + all: + - has: + field: name + regex: ^load$ + - has: + field: parameters + has: + kind: required_parameter + pattern: $ID + inside: + stopBy: + kind: class_declaration + kind: class_declaration + has: + field: name + regex: ^NodeModuleLoader$ +fix: | + async load($ID) { + ${getRequires("$ID", files, serverDir)} + }`; +} + +async function getRequirePageRule(buildOpts: BuildOptions) { + const serverDir = getServerDir(buildOpts); + + const pagesManifests = await getPagesManifests(serverDir); + const appPathsManifests = await getAppPathsManifests(serverDir); + + const manifests = pagesManifests.concat(appPathsManifests); + + const htmlFiles = manifests.filter((file) => file.endsWith(".html")); + const jsFiles = manifests.filter((file) => file.endsWith(".js")); + + return { + rule: { + pattern: ` +function requirePage($PAGE, $DIST_DIR, $IS_APP_PATH) { + const $_ = getPagePath($$$ARGS); + $$$_BODY +}`, + }, // Inline fs access and dynamic require that are not supported by workerd. + fix: ` +function requirePage($PAGE, $DIST_DIR, $IS_APP_PATH) { + const pagePath = getPagePath($$$ARGS).replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)}); + + // html + ${( + await Promise.all( + htmlFiles.map( + async (file) => `if (pagePath.endsWith(${JSON.stringify(normalizePath(file))})) { + return ${JSON.stringify(await readFile(join(serverDir, file), "utf-8"))}; + }` + ) + ) + ).join("\n")} + // js + process.env.__NEXT_PRIVATE_RUNTIME_TYPE = $IS_APP_PATH ? 'app' : 'pages'; + try { + ${getRequires("pagePath", jsFiles, serverDir)} + } finally { + process.env.__NEXT_PRIVATE_RUNTIME_TYPE = ''; + } +}`, + } satisfies RuleConfig; +} diff --git a/packages/cloudflare/src/cli/build/patches/plugins/eval-manifest.ts b/packages/cloudflare/src/cli/build/patches/plugins/eval-manifest.ts index d2204785..0f7a464f 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/eval-manifest.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/eval-manifest.ts @@ -3,7 +3,7 @@ * that are not supported by workerd. */ -import { join, relative } from "node:path"; +import { join, posix, relative, sep } from "node:path"; import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js"; import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; @@ -62,8 +62,7 @@ function evalManifest($PATH, $$$ARGS) { }, fix: ` function evalManifest($PATH, $$$ARGS) { - const { platform } = require('process'); - $PATH = platform === 'win32' ? $PATH.replaceAll('\\\\', '/') : $PATH; + $PATH = $PATH.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)}); ${returnManifests} throw new Error(\`Unexpected evalManifest(\${$PATH}) call!\`); }`, diff --git a/packages/cloudflare/src/cli/build/patches/plugins/find-dir.ts b/packages/cloudflare/src/cli/build/patches/plugins/find-dir.ts index 1cc0ee40..6da7a428 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/find-dir.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/find-dir.ts @@ -3,7 +3,7 @@ */ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, posix, sep } from "node:path"; import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js"; import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; @@ -35,8 +35,7 @@ rule: pattern: function findDir($DIR, $NAME) { $$$_ } fix: |- function findDir($DIR, $NAME) { - const { platform } = require('process'); - $DIR = platform === 'win32' ? $DIR.replaceAll('\\\\', '/') : $DIR; + $DIR = $DIR.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)}); if ($DIR.endsWith(".next/server")) { if ($NAME === "app") { return ${appExists}; diff --git a/packages/cloudflare/src/cli/build/patches/plugins/load-manifest.ts b/packages/cloudflare/src/cli/build/patches/plugins/load-manifest.ts index e471be62..e756d19e 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/load-manifest.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/load-manifest.ts @@ -3,7 +3,7 @@ */ import { readFile } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { join, posix, relative, sep } from "node:path"; import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js"; import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; @@ -53,8 +53,7 @@ function loadManifest($PATH, $$$ARGS) { }, fix: ` function loadManifest($PATH, $$$ARGS) { - const { platform } = require('process'); - $PATH = platform === 'win32' ? $PATH.replaceAll('\\\\', '/') : $PATH; + $PATH = $PATH.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)}); ${returnManifests} throw new Error(\`Unexpected loadManifest(\${$PATH}) call!\`); }`, diff --git a/packages/cloudflare/src/cli/build/patches/plugins/require-page.ts b/packages/cloudflare/src/cli/build/patches/plugins/require-page.ts deleted file mode 100644 index 393a1576..00000000 --- a/packages/cloudflare/src/cli/build/patches/plugins/require-page.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js"; -import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; - -import { normalizePath } from "../../utils/normalize-path.js"; -import { patchCode, type RuleConfig } from "../ast/util.js"; -import type { ContentUpdater } from "./content-updater.js"; - -export function inlineRequirePage(updater: ContentUpdater, buildOpts: BuildOptions) { - return updater.updateContent( - "inline-require-page", - { - filter: getCrossPlatformPathRegex(String.raw`/next/dist/server/require\.js$`, { escape: false }), - contentFilter: /function requirePage\(/, - }, - async ({ contents }) => patchCode(contents, await getRule(buildOpts)) - ); -} - -async function getRule(buildOpts: BuildOptions) { - const { outputDir } = buildOpts; - const serverDir = join(outputDir, "server-functions/default", getPackagePath(buildOpts), ".next/server"); - - const pagesManifestFile = join(serverDir, "pages-manifest.json"); - const appPathsManifestFile = join(serverDir, "app-paths-manifest.json"); - - let pagesManifests: string[] = []; - try { - pagesManifests = Object.values(JSON.parse(await readFile(pagesManifestFile, "utf-8"))); - } catch { - // The file does not exists - pagesManifests = []; - } - - let appPathsManifests: string[]; - try { - appPathsManifests = Object.values(JSON.parse(await readFile(appPathsManifestFile, "utf-8"))); - } catch { - // The file does not exists - appPathsManifests = []; - } - - const manifests = pagesManifests.concat(appPathsManifests).map((path) => normalizePath(path)); - - const htmlFiles = manifests.filter((file) => file.endsWith(".html")); - const jsFiles = manifests.filter((file) => file.endsWith(".js")); - - // Inline fs access and dynamic require that are not supported by workerd. - const fnBody = ` -// html -${( - await Promise.all( - htmlFiles.map( - async (file) => `if (pagePath.endsWith("${file}")) { - return ${JSON.stringify(await readFile(join(serverDir, file), "utf-8"))}; - }` - ) - ) -).join("\n")} -// js -process.env.__NEXT_PRIVATE_RUNTIME_TYPE = isAppPath ? 'app' : 'pages'; -try { - ${jsFiles - .map( - (file) => `if (pagePath.endsWith("${file}")) { - return require(${JSON.stringify(join(serverDir, file))}); - }` - ) - .join("\n")} -} finally { - process.env.__NEXT_PRIVATE_RUNTIME_TYPE = ''; -} -`; - - return { - rule: { - pattern: ` -function requirePage($PAGE, $DIST_DIR, $IS_APP_PATH) { - const $_ = getPagePath($$$ARGS); - $$$_BODY -}`, - }, - fix: ` -function requirePage($PAGE, $DIST_DIR, $IS_APP_PATH) { - const { platform } = require('process'); - const pagePath = platform === 'win32' ? getPagePath($$$ARGS).replaceAll('\\\\', '/') : getPagePath($$$ARGS); - ${fnBody} -}`, - } satisfies RuleConfig; -}