diff --git a/.changeset/fuzzy-workers-push.md b/.changeset/fuzzy-workers-push.md new file mode 100644 index 000000000..5d2298399 --- /dev/null +++ b/.changeset/fuzzy-workers-push.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/cloudflare": patch +--- + +feature: support Workers-compatible Next.js Node middleware. + +The Cloudflare adapter now builds Node middleware through a workerd-compatible external middleware bundle, validates unsupported Node/runtime features before packaging, supports `config.matcher` `has` / `missing` predicates, and covers redirects, rewrites, cookies, headers, request header overrides, matcher predicates, and direct responses in the experimental e2e fixture. diff --git a/examples/e2e/experimental/e2e/nodeMiddleware.test.ts b/examples/e2e/experimental/e2e/nodeMiddleware.test.ts index 0856301fb..f7e3e02cb 100644 --- a/examples/e2e/experimental/e2e/nodeMiddleware.test.ts +++ b/examples/e2e/experimental/e2e/nodeMiddleware.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; // See https://github.com/opennextjs/opennextjs-cloudflare/issues/617 test.describe("Node Middleware", () => { - test.skip("Node middleware should add headers", async ({ request }) => { + test("Node middleware should add headers", async ({ request }) => { const resp = await request.get("/"); expect(resp.status()).toEqual(200); const headers = resp.headers(); @@ -10,23 +10,222 @@ test.describe("Node Middleware", () => { expect(headers["x-random-node"]).toBeDefined(); }); - test.skip("Node middleware should return json", async ({ request }) => { + test("Node middleware should return json", async ({ request }) => { const resp = await request.get("/api/hello"); expect(resp.status()).toEqual(200); const json = await resp.json(); expect(json).toEqual({ name: "World" }); }); - test.skip("Node middleware should redirect", async ({ page }) => { - await page.goto("/redirect"); - await page.waitForURL("/"); - const el = page.getByText("Incremental PPR"); - await expect(el).toBeVisible(); + test("Node middleware should redirect", async ({ request }) => { + const resp = await request.get("/redirect", { maxRedirects: 0 }); + expect(resp.status()).toEqual(307); + expect(resp.headers()["location"]).toEqual("/"); }); - test.skip("Node middleware should rewrite", async ({ page }) => { - await page.goto("/rewrite"); - const el = page.getByText("Incremental PPR"); - await expect(el).toBeVisible(); + test("Node middleware should rewrite", async ({ request }) => { + const resp = await request.get("/rewrite"); + expect(resp.status()).toEqual(200); + expect(await resp.text()).toContain("Incremental PPR"); + }); + + test("Node middleware should pass request header overrides", async ({ request }) => { + const resp = await request.get("/header-override"); + expect(resp.status()).toEqual(200); + expect(await resp.json()).toEqual({ fromProxy: "override" }); + }); + + test("Node middleware should remove request headers omitted from override headers", async ({ request }) => { + const resp = await request.get("/header-remove", { + headers: { + "x-remove-me": "remove me", + }, + }); + expect(resp.status()).toEqual(200); + expect(await resp.json()).toEqual({ + kept: "from-proxy", + removed: null, + }); + }); + + test("Node middleware should preserve request bodies after proxy reads them", async ({ request }) => { + const resp = await request.post("/body-pass", { + data: "request body", + headers: { + "content-type": "text/plain", + }, + }); + expect(resp.status()).toEqual(200); + expect(await resp.json()).toEqual({ + body: "request body", + seenByProxy: "request body", + }); + }); + + test("Node middleware should read and set cookies", async ({ request }) => { + const resp = await request.get("/cookies", { + headers: { + cookie: "proxy-in=request-cookie", + }, + }); + expect(resp.status()).toEqual(200); + expect(await resp.json()).toEqual({ seen: "request-cookie" }); + expect(resp.headers()["set-cookie"]).toContain("proxy-out=ok"); + }); + + test("Node middleware should return direct responses", async ({ request }) => { + const resp = await request.get("/direct-response"); + expect(resp.status()).toEqual(200); + expect(resp.headers()["x-direct-response"]).toEqual("1"); + expect(await resp.text()).toEqual("direct from proxy"); + }); + + test.describe("matcher has predicates", () => { + test("header predicates require a matching header value", async ({ request }) => { + const withoutHeader = await request.get("/matcher/has-header"); + expect(withoutHeader.status()).toEqual(200); + expect(await withoutHeader.json()).toMatchObject({ kind: "has-header", proxied: false }); + + const wrongHeader = await request.get("/matcher/has-header", { + headers: { + "x-proxy-header": "skip", + }, + }); + expect(wrongHeader.status()).toEqual(200); + expect(await wrongHeader.json()).toMatchObject({ kind: "has-header", proxied: false }); + + const matchingHeader = await request.get("/matcher/has-header", { + headers: { + "x-proxy-header": "run", + }, + }); + expect(matchingHeader.status()).toEqual(200); + expect(await matchingHeader.json()).toEqual({ + pathname: "/matcher/has-header", + proxied: true, + }); + }); + + test("cookie predicates require a matching cookie value", async ({ request }) => { + const withoutCookie = await request.get("/matcher/has-cookie"); + expect(withoutCookie.status()).toEqual(200); + expect(await withoutCookie.json()).toMatchObject({ kind: "has-cookie", proxied: false }); + + const wrongCookie = await request.get("/matcher/has-cookie", { + headers: { + cookie: "proxy-cookie=skip", + }, + }); + expect(wrongCookie.status()).toEqual(200); + expect(await wrongCookie.json()).toMatchObject({ kind: "has-cookie", proxied: false }); + + const matchingCookie = await request.get("/matcher/has-cookie", { + headers: { + cookie: "proxy-cookie=run", + }, + }); + expect(matchingCookie.status()).toEqual(200); + expect(await matchingCookie.json()).toEqual({ + pathname: "/matcher/has-cookie", + proxied: true, + }); + + const duplicateCookie = await request.get("/matcher/has-cookie", { + headers: { + cookie: "proxy-cookie=run; proxy-cookie=skip", + }, + }); + expect(duplicateCookie.status()).toEqual(200); + expect(await duplicateCookie.json()).toEqual({ + pathname: "/matcher/has-cookie", + proxied: true, + }); + + const quotedCookie = await request.get("/matcher/has-cookie", { + headers: { + cookie: 'proxy-cookie="run"', + }, + }); + expect(quotedCookie.status()).toEqual(200); + expect(await quotedCookie.json()).toEqual({ + pathname: "/matcher/has-cookie", + proxied: true, + }); + }); + + test("query predicates require a matching query value", async ({ request }) => { + const withoutQuery = await request.get("/matcher/has-query"); + expect(withoutQuery.status()).toEqual(200); + expect(await withoutQuery.json()).toMatchObject({ kind: "has-query", proxied: false }); + + const wrongQuery = await request.get("/matcher/has-query?proxy=skip"); + expect(wrongQuery.status()).toEqual(200); + expect(await wrongQuery.json()).toMatchObject({ kind: "has-query", proxied: false }); + + const matchingQuery = await request.get("/matcher/has-query?proxy=run"); + expect(matchingQuery.status()).toEqual(200); + expect(await matchingQuery.json()).toEqual({ + pathname: "/matcher/has-query", + proxied: true, + }); + }); + + test("host predicates match the host without the port", async ({ request }) => { + const resp = await request.get("/matcher/has-host"); + expect(resp.status()).toEqual(200); + expect(await resp.json()).toEqual({ + pathname: "/matcher/has-host", + proxied: true, + }); + }); + }); + + test.describe("matcher missing predicates", () => { + test("header predicates run only when the header is missing", async ({ request }) => { + const withoutHeader = await request.get("/matcher/missing-header"); + expect(withoutHeader.status()).toEqual(200); + expect(await withoutHeader.json()).toEqual({ + pathname: "/matcher/missing-header", + proxied: true, + }); + + const withHeader = await request.get("/matcher/missing-header", { + headers: { + "x-skip-proxy": "1", + }, + }); + expect(withHeader.status()).toEqual(200); + expect(await withHeader.json()).toMatchObject({ kind: "missing-header", proxied: false }); + }); + + test("cookie predicates run only when the cookie is missing", async ({ request }) => { + const withoutCookie = await request.get("/matcher/missing-cookie"); + expect(withoutCookie.status()).toEqual(200); + expect(await withoutCookie.json()).toEqual({ + pathname: "/matcher/missing-cookie", + proxied: true, + }); + + const withCookie = await request.get("/matcher/missing-cookie", { + headers: { + cookie: "skip-proxy=1", + }, + }); + expect(withCookie.status()).toEqual(200); + expect(await withCookie.json()).toMatchObject({ kind: "missing-cookie", proxied: false }); + }); + + test("query predicates run only when the query is missing", async ({ request }) => { + const withoutQuery = await request.get("/matcher/missing-query"); + expect(withoutQuery.status()).toEqual(200); + expect(await withoutQuery.json()).toEqual({ + pathname: "/matcher/missing-query", + proxied: true, + }); + + const withQuery = await request.get("/matcher/missing-query?skip-proxy=1"); + expect(withQuery.status()).toEqual(200); + expect(await withQuery.json()).toMatchObject({ kind: "missing-query", proxied: false }); + }); }); }); diff --git a/examples/e2e/experimental/src/app/body-pass/route.ts b/examples/e2e/experimental/src/app/body-pass/route.ts new file mode 100644 index 000000000..7116b1128 --- /dev/null +++ b/examples/e2e/experimental/src/app/body-pass/route.ts @@ -0,0 +1,6 @@ +export async function POST(request: Request) { + return Response.json({ + body: await request.text(), + seenByProxy: request.headers.get("x-body-seen-by-proxy"), + }); +} diff --git a/examples/e2e/experimental/src/app/header-override/route.ts b/examples/e2e/experimental/src/app/header-override/route.ts new file mode 100644 index 000000000..c028443af --- /dev/null +++ b/examples/e2e/experimental/src/app/header-override/route.ts @@ -0,0 +1,5 @@ +export function GET(request: Request) { + return Response.json({ + fromProxy: request.headers.get("x-from-proxy"), + }); +} diff --git a/examples/e2e/experimental/src/app/header-remove/route.ts b/examples/e2e/experimental/src/app/header-remove/route.ts new file mode 100644 index 000000000..06861520f --- /dev/null +++ b/examples/e2e/experimental/src/app/header-remove/route.ts @@ -0,0 +1,6 @@ +export async function GET(request: Request) { + return Response.json({ + kept: request.headers.get("x-keep-me"), + removed: request.headers.get("x-remove-me"), + }); +} diff --git a/examples/e2e/experimental/src/app/matcher/[kind]/route.ts b/examples/e2e/experimental/src/app/matcher/[kind]/route.ts new file mode 100644 index 000000000..18d28fac7 --- /dev/null +++ b/examples/e2e/experimental/src/app/matcher/[kind]/route.ts @@ -0,0 +1,16 @@ +export function GET( + request: Request, + { + params, + }: { + params: Promise<{ kind: string }>; + } +) { + return params.then(({ kind }) => + Response.json({ + kind, + proxied: false, + url: request.url, + }) + ); +} diff --git a/examples/e2e/experimental/src/middleware.ts b/examples/e2e/experimental/src/middleware.ts deleted file mode 100644 index 780273243..000000000 --- a/examples/e2e/experimental/src/middleware.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Node middleware is not supported yet in cloudflare -// See https://github.com/opennextjs/opennextjs-cloudflare/issues/617 - -// import crypto from "node:crypto"; -import { type NextRequest, NextResponse } from "next/server"; - -export default function middleware(request: NextRequest) { - if (request.nextUrl.pathname === "/api/hello") { - return NextResponse.json({ - name: "World", - }); - } - if (request.nextUrl.pathname === "/redirect") { - return NextResponse.redirect(new URL("/", request.url)); - } - if (request.nextUrl.pathname === "/rewrite") { - return NextResponse.rewrite(new URL("/", request.url)); - } - - return NextResponse.next({ - headers: { - "x-middleware-test": "1", - // "x-random-node": crypto.randomUUID(), - }, - }); -} - -// export const config = { -// runtime: "nodejs", -// }; diff --git a/examples/e2e/experimental/src/proxy.ts b/examples/e2e/experimental/src/proxy.ts new file mode 100644 index 000000000..8fddf8706 --- /dev/null +++ b/examples/e2e/experimental/src/proxy.ts @@ -0,0 +1,151 @@ +import { type NextRequest, NextResponse } from "next/server"; + +export default async function proxy(request: NextRequest) { + if (request.nextUrl.pathname.startsWith("/matcher/")) { + return NextResponse.json({ + proxied: true, + pathname: request.nextUrl.pathname, + }); + } + if (request.nextUrl.pathname === "/api/hello") { + return NextResponse.json({ + name: "World", + }); + } + if (request.nextUrl.pathname === "/cookies") { + const response = NextResponse.json({ + seen: request.cookies.get("proxy-in")?.value ?? null, + }); + response.cookies.set("proxy-out", "ok"); + return response; + } + if (request.nextUrl.pathname === "/direct-response") { + return new Response("direct from proxy", { + headers: { + "x-direct-response": "1", + }, + }); + } + if (request.nextUrl.pathname === "/redirect") { + return NextResponse.redirect(new URL("/", request.url)); + } + if (request.nextUrl.pathname === "/rewrite") { + return NextResponse.rewrite(new URL("/", request.url)); + } + if (request.nextUrl.pathname === "/header-override") { + const headers = new Headers(request.headers); + headers.set("x-from-proxy", "override"); + return NextResponse.next({ + request: { + headers, + }, + }); + } + if (request.nextUrl.pathname === "/header-remove") { + const headers = new Headers(request.headers); + headers.delete("x-remove-me"); + headers.set("x-keep-me", "from-proxy"); + return NextResponse.next({ + request: { + headers, + }, + }); + } + if (request.nextUrl.pathname === "/body-pass") { + const body = await request.text(); + const headers = new Headers(request.headers); + headers.set("x-body-seen-by-proxy", body); + return NextResponse.next({ + request: { + headers, + }, + }); + } + + return NextResponse.next({ + headers: { + "x-middleware-test": "1", + "x-random-node": crypto.randomUUID(), + }, + }); +} + +export const config = { + matcher: [ + "/", + "/api/hello", + "/cookies", + "/direct-response", + "/body-pass", + "/header-remove", + "/header-override", + "/redirect", + "/rewrite", + { + source: "/matcher/has-header", + has: [ + { + type: "header", + key: "x-proxy-header", + value: "run", + }, + ], + }, + { + source: "/matcher/has-cookie", + has: [ + { + type: "cookie", + key: "proxy-cookie", + value: "run", + }, + ], + }, + { + source: "/matcher/has-query", + has: [ + { + type: "query", + key: "proxy", + value: "run", + }, + ], + }, + { + source: "/matcher/has-host", + has: [ + { + type: "host", + value: "localhost", + }, + ], + }, + { + source: "/matcher/missing-header", + missing: [ + { + type: "header", + key: "x-skip-proxy", + }, + ], + }, + { + source: "/matcher/missing-cookie", + missing: [ + { + type: "cookie", + key: "skip-proxy", + }, + ], + }, + { + source: "/matcher/missing-query", + missing: [ + { + type: "query", + key: "skip-proxy", + }, + ], + }, + ], +}; diff --git a/packages/cloudflare/src/cli/build/build.ts b/packages/cloudflare/src/cli/build/build.ts index 9e80ec3bc..a93d9adb1 100644 --- a/packages/cloudflare/src/cli/build/build.ts +++ b/packages/cloudflare/src/cli/build/build.ts @@ -18,8 +18,9 @@ import { compileImages } from "./open-next/compile-images.js"; import { compileInit } from "./open-next/compile-init.js"; import { compileSkewProtection } from "./open-next/compile-skew-protection.js"; import { compileDurableObjects } from "./open-next/compileDurableObjects.js"; +import { createCloudflareNodeMiddleware } from "./open-next/create-node-middleware.js"; import { createServerBundle } from "./open-next/createServerBundle.js"; -import { useNodeMiddleware } from "./utils/middleware.js"; +import { useNodeMiddleware, validateNodeMiddlewareForWorkers } from "./utils/middleware.js"; import { getVersion } from "./utils/version.js"; /** @@ -81,10 +82,10 @@ export async function build( buildNextjsApp(options); } - // Make sure no Node.js middleware is used - if (useNodeMiddleware(options)) { - logger.error("Node.js middleware is not currently supported. Consider switching to Edge Middleware."); - process.exit(1); + const hasNodeMiddleware = useNodeMiddleware(options); + if (hasNodeMiddleware) { + validateNodeMiddlewareForWorkers(options); + logger.warn("Experimental: bundling Node.js middleware/proxy for Cloudflare Workers-compatible APIs."); } // Generate deployable bundle @@ -99,7 +100,11 @@ export async function build( await compileSkewProtection(options, config); // Compile middleware - await createMiddleware(options, { forceOnlyBuildOnce: true }); + if (hasNodeMiddleware) { + await createCloudflareNodeMiddleware(options); + } else { + await createMiddleware(options, { forceOnlyBuildOnce: true }); + } createStaticAssets(options, { useBasePath: true }); diff --git a/packages/cloudflare/src/cli/build/open-next/create-node-middleware.spec.ts b/packages/cloudflare/src/cli/build/open-next/create-node-middleware.spec.ts new file mode 100644 index 000000000..a77600800 --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/create-node-middleware.spec.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { build } from "esbuild"; +import { afterEach, describe, expect, test } from "vitest"; + +import { rejectUnsupportedRuntimePlugin } from "./create-node-middleware.js"; + +let tmpDir: string | undefined; + +afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +describe("rejectUnsupportedRuntimePlugin", () => { + test("rejects unsupported modules imported through middleware helpers", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opennext-node-middleware-")); + fs.writeFileSync(path.join(tmpDir, "helper.js"), `import "node:fs";\nexport const value = 1;\n`); + + await expect( + build({ + stdin: { + contents: `import { value } from "./helper.js";\nexport default value;\n`, + resolveDir: tmpDir, + loader: "js", + }, + bundle: true, + format: "esm", + write: false, + plugins: [rejectUnsupportedRuntimePlugin()], + logLevel: "silent", + }) + ).rejects.toThrow('Node.js middleware/proxy imports unsupported module "node:fs"'); + }); + + test("rejects native addons imported through middleware helpers", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opennext-node-middleware-")); + fs.writeFileSync(path.join(tmpDir, "helper.js"), `import "./native.node";\nexport const value = 1;\n`); + + await expect( + build({ + stdin: { + contents: `import { value } from "./helper.js";\nexport default value;\n`, + resolveDir: tmpDir, + loader: "js", + }, + bundle: true, + format: "esm", + write: false, + plugins: [rejectUnsupportedRuntimePlugin()], + logLevel: "silent", + }) + ).rejects.toThrow('Node.js middleware/proxy imports native addon "./native.node"'); + }); + + test("allows Next internals that are already present in the generated middleware bundle", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opennext-node-middleware-")); + const nextDistDir = path.join(tmpDir, "node_modules", "next", "dist"); + fs.mkdirSync(nextDistDir, { recursive: true }); + fs.writeFileSync(path.join(nextDistDir, "helper.js"), `import "node:fs";\nexport const value = 1;\n`); + + await expect( + build({ + stdin: { + contents: `import { value } from "next/dist/helper.js";\nexport default value;\n`, + resolveDir: tmpDir, + loader: "js", + }, + bundle: true, + external: ["node:*"], + format: "esm", + platform: "node", + write: false, + plugins: [rejectUnsupportedRuntimePlugin()], + logLevel: "silent", + }) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/cloudflare/src/cli/build/open-next/create-node-middleware.ts b/packages/cloudflare/src/cli/build/open-next/create-node-middleware.ts new file mode 100644 index 000000000..20adade2d --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/create-node-middleware.ts @@ -0,0 +1,377 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadFunctionsConfigManifest } from "@opennextjs/aws/adapters/config/util.js"; +import * as buildHelper from "@opennextjs/aws/build/helper.js"; +import logger from "@opennextjs/aws/logger.js"; +import { build, type Plugin } from "esbuild"; + +import { getUnsupportedNodeMiddlewareModuleReason, isNativeNodeAddonSpecifier } from "../utils/middleware.js"; + +const nodeMiddlewareOutputPath = "middleware/handler.mjs"; + +type NodeMiddlewareMatcher = { + regexp: string; + has?: RouteHas[]; + missing?: RouteHas[]; +}; + +type RouteHas = { + type: "header" | "cookie" | "query" | "host"; + key?: string; + value?: string; +}; + +/** + * Builds Next.js Node middleware/proxy into the external middleware entry used by the worker template. + * + * This path intentionally bypasses the generic OpenNext Node middleware adapter because that adapter + * pulls in filesystem-backed config loading that is not valid inside workerd. + */ +export async function createCloudflareNodeMiddleware(options: buildHelper.BuildOptions): Promise { + const middlewarePath = getStandaloneMiddlewarePath(options); + const matchers = getNodeMiddlewareMatchers(options); + const outFile = path.join(options.outputDir, nodeMiddlewareOutputPath); + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + + logger.info("Bundling Node.js middleware/proxy for Cloudflare Workers"); + await build({ + stdin: { + contents: createNodeMiddlewareEntry(middlewarePath, matchers), + sourcefile: "cloudflare-node-middleware-entry.mjs", + resolveDir: options.appPath, + loader: "js", + }, + outfile: outFile, + bundle: true, + format: "esm", + platform: "node", + target: "esnext", + conditions: ["node", "default"], + mainFields: ["main", "module"], + external: ["node:*"], + minify: options.minify, + sourcemap: options.debug ? "inline" : false, + sourcesContent: false, + banner: { + js: [ + `import { createRequire as __cloudflareCreateRequire } from "node:module";`, + `const require = __cloudflareCreateRequire(import.meta.url ?? "file:///worker.js");`, + ].join("\n"), + }, + plugins: [rejectUnsupportedRuntimePlugin(), stubNextNodeEnvPlugin()], + logLevel: "warning", + }); +} + +export function rejectUnsupportedRuntimePlugin(): Plugin { + return { + name: "reject-unsupported-node-middleware-runtime", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (isNextInternalImporter(args.importer)) { + return undefined; + } + + const reason = getUnsupportedNodeMiddlewareModuleReason(args.path); + if (reason) { + return { + errors: [ + { + text: `Node.js middleware/proxy imports unsupported module "${args.path}" (${reason})`, + }, + ], + }; + } + + if (isNativeNodeAddonSpecifier(args.path)) { + return { + errors: [ + { + text: `Node.js middleware/proxy imports native addon "${args.path}" (native Node addons are not available in Workers)`, + }, + ], + }; + } + + return undefined; + }); + }, + }; +} + +function isNextInternalImporter(importer: string): boolean { + const normalizedImporter = importer.split(path.sep).join("/"); + return normalizedImporter.includes("/node_modules/next/"); +} + +function getStandaloneMiddlewarePath(options: buildHelper.BuildOptions): string { + const standaloneMiddlewarePath = path.join( + options.appBuildOutputPath, + ".next/standalone", + buildHelper.getPackagePath(options), + ".next/server/middleware.js" + ); + + if (fs.existsSync(standaloneMiddlewarePath)) { + return standaloneMiddlewarePath; + } + + const middlewarePath = path.join(options.appBuildOutputPath, ".next/server/middleware.js"); + if (fs.existsSync(middlewarePath)) { + return middlewarePath; + } + + throw new Error(`Unable to find built Node.js middleware at ${standaloneMiddlewarePath}`); +} + +function getNodeMiddlewareMatchers(options: buildHelper.BuildOptions): NodeMiddlewareMatcher[] { + const functionsConfigManifest = loadFunctionsConfigManifest(path.join(options.appBuildOutputPath, ".next")); + + return functionsConfigManifest.functions["/_middleware"]?.matchers ?? [{ regexp: "^.*$" }]; +} + +function createNodeMiddlewareEntry(middlewarePath: string, matchers: NodeMiddlewareMatcher[]): string { + return ` +import { Buffer } from "node:buffer"; +import { AsyncLocalStorage } from "node:async_hooks"; + +globalThis.Buffer = Buffer; +globalThis.AsyncLocalStorage = AsyncLocalStorage; + +const matchers = ${JSON.stringify(matchers)}.map((matcher) => ({ + ...matcher, + regexp: new RegExp(matcher.regexp), +})); +let middlewareModulePromise; + +function matches(request) { + const url = new URL(request.url); + const query = getQuery(url); + + return matchers.some((matcher) => { + if (!matcher.regexp.exec(url.pathname)) { + return false; + } + + return matchHas(request, query, matcher.has, matcher.missing); + }); +} + +function getQuery(url) { + const query = {}; + for (const [key, value] of url.searchParams) { + if (query[key] === undefined) { + query[key] = value; + } else if (Array.isArray(query[key])) { + query[key].push(value); + } else { + query[key] = [query[key], value]; + } + } + return query; +} + +function parseCookies(cookieHeader) { + const cookies = {}; + if (!cookieHeader) { + return cookies; + } + + for (const cookie of cookieHeader.split(";")) { + const separatorIndex = cookie.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const key = cookie.slice(0, separatorIndex).trim(); + const value = cookie.slice(separatorIndex + 1).trim(); + if (!key || cookies[key] !== undefined) { + continue; + } + + const unquotedValue = value[0] === '"' ? value.slice(1, -1) : value; + try { + cookies[key] = decodeURIComponent(unquotedValue); + } catch { + cookies[key] = unquotedValue; + } + } + + return cookies; +} + +function getHasValue(request, query, hasItem) { + switch (hasItem.type) { + case "header": + return hasItem.key ? request.headers.get(hasItem.key) ?? undefined : undefined; + case "cookie": + return hasItem.key ? parseCookies(request.headers.get("cookie"))[hasItem.key] : undefined; + case "query": + return hasItem.key ? query[hasItem.key] : undefined; + case "host": + return request.headers.get("host")?.split(":", 1)[0].toLowerCase(); + default: + return undefined; + } +} + +function hasValue(value) { + return Array.isArray(value) ? value.length > 0 : Boolean(value); +} + +function hasMatch(request, query, hasItem) { + const value = getHasValue(request, query, hasItem); + if (!hasItem.value && hasValue(value)) { + return true; + } + + if (!hasValue(value)) { + return false; + } + + const matcher = new RegExp(\`^\${hasItem.value}$\`); + const matchValue = Array.isArray(value) ? value[value.length - 1] : value; + return Boolean(String(matchValue).match(matcher)); +} + +function matchHas(request, query, has = [], missing = []) { + return has.every((item) => hasMatch(request, query, item)) && !missing.some((item) => hasMatch(request, query, item)); +} + +async function loadMiddlewareModule() { + middlewareModulePromise ??= import(${JSON.stringify(middlewarePath)}).then((mod) => mod.default ?? mod); + return middlewareModulePromise; +} + +const filteredResponseHeaders = new Set([ + "x-middleware-override-headers", + "x-middleware-next", + "x-middleware-rewrite", + "content-encoding", +]); +const requestHeaderPrefix = "x-middleware-request-"; +const responseHeaderPrefix = "x-middleware-response-"; + +function toNodeHeaders(headers) { + const nodeHeaders = {}; + headers.forEach((value, key) => { + nodeHeaders[key] = value; + }); + return nodeHeaders; +} + +function createAdapterRequest(request) { + return { + url: request.url, + headers: toNodeHeaders(request.headers), + method: request.method, + body: request.body, + signal: request.signal, + }; +} + +function splitMiddlewareHeaders(response) { + const requestHeaders = new Headers(); + const responseHeaders = new Headers(); + const overriddenRequestHeaders = response.headers + .get("x-middleware-override-headers") + ?.split(",") + .map((key) => key.trim()) + .filter(Boolean); + response.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase(); + if (lowerKey.startsWith(requestHeaderPrefix)) { + requestHeaders.set(key.slice(requestHeaderPrefix.length), value); + return; + } + if (!filteredResponseHeaders.has(lowerKey)) { + responseHeaders.append(key, value); + } + }); + return { overriddenRequestHeaders, requestHeaders, responseHeaders }; +} + +function createDownstreamRequest(request, response, url) { + const { overriddenRequestHeaders, requestHeaders, responseHeaders } = splitMiddlewareHeaders(response); + const headers = new Headers(); + if (overriddenRequestHeaders) { + for (const key of overriddenRequestHeaders) { + const value = requestHeaders.get(key); + if (value !== null) { + headers.set(key, value); + } + } + } else { + request.headers.forEach((value, key) => { + headers.set(key, value); + }); + requestHeaders.forEach((value, key) => { + headers.set(key, value); + }); + } + responseHeaders.forEach((value, key) => { + headers.append(responseHeaderPrefix + key, value); + }); + return new Request(url, { + body: request.body, + headers, + method: request.method, + redirect: request.redirect, + }); +} + +function createDirectResponse(response) { + const { responseHeaders } = splitMiddlewareHeaders(response); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} + +export async function handler(request, _env, ctx) { + if (!matches(request)) { + return request; + } + + const mod = await loadMiddlewareModule(); + const adapterFn = mod.default || mod; + const adapterRequest = request.clone(); + const result = await adapterFn({ + handler: mod.middleware || mod, + request: createAdapterRequest(adapterRequest), + page: "middleware", + }); + + if (result.waitUntil) { + ctx.waitUntil(result.waitUntil); + } + + const response = result.response; + const rewriteUrl = response.headers.get("x-middleware-rewrite"); + if (rewriteUrl || response.headers.get("x-middleware-next")) { + return createDownstreamRequest(request, response, rewriteUrl || request.url); + } + + return createDirectResponse(response); +} +`; +} + +function stubNextNodeEnvPlugin(): Plugin { + return { + name: "stub-next-node-env", + setup(build) { + build.onResolve({ filter: /next\/dist\/build\/adapter\/setup-node-env\.external(\.js)?$/ }, (args) => ({ + path: args.path, + namespace: "stub-next-node-env", + })); + build.onLoad({ filter: /.*/, namespace: "stub-next-node-env" }, () => ({ + contents: "export {};", + loader: "js", + })); + }, + }; +} diff --git a/packages/cloudflare/src/cli/build/patches/plugins/next-server.ts b/packages/cloudflare/src/cli/build/patches/plugins/next-server.ts index 15e27b4d4..8d3219732 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/next-server.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/next-server.ts @@ -39,7 +39,7 @@ export function patchNextServer(updater: ContentUpdater, buildOpts: BuildOptions ); contents = patchCode(contents, createComposableCacheHandlersRule(composableCacheHandler)); - // Node middleware are not supported on Cloudflare yet + // Cloudflare bundles Node middleware as external middleware instead of loading it in NextServer. contents = patchCode(contents, disableNodeMiddlewareRule); contents = patchCode(contents, attachRequestMetaRule); @@ -50,7 +50,7 @@ export function patchNextServer(updater: ContentUpdater, buildOpts: BuildOptions ]); } -// Do not try to load Node middlewares +// Do not let NextServer load Node middleware; the worker invokes the external middleware bundle. export const disableNodeMiddlewareRule = ` rule: pattern: diff --git a/packages/cloudflare/src/cli/build/utils/middleware.spec.ts b/packages/cloudflare/src/cli/build/utils/middleware.spec.ts new file mode 100644 index 000000000..c3b706aed --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/middleware.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; + +import { validateNodeMiddlewareSource } from "./middleware.js"; + +describe("validateNodeMiddlewareSource", () => { + test("allows Workers-compatible proxy code", () => { + const issues = validateNodeMiddlewareSource(` + import { NextResponse } from "next/server"; + + export default function proxy(request) { + const response = NextResponse.next({ + headers: { "x-id": crypto.randomUUID() }, + }); + response.cookies.set("seen", request.cookies.get("id")?.value ?? "none"); + return response; + } + `); + + expect(issues).toEqual([]); + }); + + test.each(["node:child_process", "child_process", "node:cluster", "worker_threads"])( + "rejects unsupported module %s", + (moduleName) => { + const issues = validateNodeMiddlewareSource(`import test from "${moduleName}";`); + + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toContain(`"${moduleName}"`); + } + ); + + test("rejects filesystem imports", () => { + const issues = validateNodeMiddlewareSource(`import { readFile } from "node:fs/promises";`); + + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toContain("persistent filesystem access"); + }); + + test("allows type-only imports and exports from unsupported runtime modules", () => { + const issues = validateNodeMiddlewareSource(` + import type { Stats } from "node:fs"; + export type { Stats } from "node:fs"; + `); + + expect(issues).toEqual([]); + }); + + test("still rejects value imports and exports from unsupported runtime modules", () => { + const issues = validateNodeMiddlewareSource(` + import { readFileSync } from "node:fs"; + export { readFileSync } from "node:fs"; + `); + + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.message.includes("persistent filesystem access"))).toBe(true); + }); + + test("rejects native addons", () => { + const issues = validateNodeMiddlewareSource(`const addon = require("./native.node");`); + + expect(issues.some((issue) => issue.message.includes("native addon"))).toBe(true); + }); + + test("rejects dynamic module loading", () => { + const issues = validateNodeMiddlewareSource(` + const name = "child_process"; + const childProcess = require(name); + const module = await import(name); + `); + + expect(issues.map((issue) => issue.message)).toEqual([ + "uses dynamic require(); only statically analyzable imports are supported", + "uses dynamic import(); only statically analyzable imports are supported", + ]); + }); +}); diff --git a/packages/cloudflare/src/cli/build/utils/middleware.ts b/packages/cloudflare/src/cli/build/utils/middleware.ts index 7b6ff4f19..a89fa4e41 100644 --- a/packages/cloudflare/src/cli/build/utils/middleware.ts +++ b/packages/cloudflare/src/cli/build/utils/middleware.ts @@ -1,8 +1,28 @@ +import fs from "node:fs"; import path from "node:path"; import { loadFunctionsConfigManifest, loadMiddlewareManifest } from "@opennextjs/aws/adapters/config/util.js"; import * as buildHelper from "@opennextjs/aws/build/helper.js"; +const middlewareFileNames = ["proxy", "middleware"]; +const middlewareExtensions = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts", ".cjs", ".cts"]; + +const unsupportedModules = new Map([ + ["child_process", "spawning child processes is not available in Workers"], + ["cluster", "Node cluster workers are not available in Workers"], + ["worker_threads", "Node worker threads are not available in Workers"], + ["fs", "persistent filesystem access is not available in Workers"], + ["fs/promises", "persistent filesystem access is not available in Workers"], +]); + +export function getUnsupportedNodeMiddlewareModuleReason(specifier: string): string | undefined { + return unsupportedModules.get(specifier.replace(/^node:/, "")); +} + +export function isNativeNodeAddonSpecifier(specifier: string): boolean { + return specifier.endsWith(".node") || specifier.includes(".node?"); +} + /** * Returns whether the project is using a Node.js middleware. * @@ -24,3 +44,105 @@ export function useNodeMiddleware(options: buildHelper.BuildOptions): boolean { const functionsConfigManifest = loadFunctionsConfigManifest(buildOutputDotNextDir); return Boolean(functionsConfigManifest?.functions["/_middleware"]); } + +export function validateNodeMiddlewareForWorkers(options: buildHelper.BuildOptions): void { + const sourceFiles = findNodeMiddlewareSourceFiles(options.appPath); + const issues = sourceFiles.flatMap((sourceFile) => + validateNodeMiddlewareSource(fs.readFileSync(sourceFile, "utf-8"), sourceFile) + ); + + if (issues.length === 0) { + return; + } + + throw new Error( + [ + "Node.js middleware/proxy uses runtime features that are not supported by @opennextjs/cloudflare's Workers-compatible Node middleware bundle:", + ...issues.map((issue) => `- ${path.relative(options.appPath, issue.sourcePath)}: ${issue.message}`), + ].join("\n") + ); +} + +export type NodeMiddlewareValidationIssue = { + sourcePath: string; + message: string; +}; + +export function findNodeMiddlewareSourceFiles(appPath: string): string[] { + const files: string[] = []; + + for (const dir of [appPath, path.join(appPath, "src")]) { + for (const fileName of middlewareFileNames) { + for (const extension of middlewareExtensions) { + const filePath = path.join(dir, `${fileName}${extension}`); + if (fs.existsSync(filePath)) { + files.push(filePath); + } + } + } + } + + return files; +} + +export function validateNodeMiddlewareSource( + source: string, + sourcePath = "proxy.ts" +): NodeMiddlewareValidationIssue[] { + const issues: NodeMiddlewareValidationIssue[] = []; + const moduleSpecifiers = getStaticModuleSpecifiers(source); + + for (const specifier of moduleSpecifiers) { + const reason = getUnsupportedNodeMiddlewareModuleReason(specifier); + if (reason) { + issues.push({ + sourcePath, + message: `imports unsupported module "${specifier}" (${reason})`, + }); + } + if (isNativeNodeAddonSpecifier(specifier)) { + issues.push({ + sourcePath, + message: `imports native addon "${specifier}" (native Node addons are not available in Workers)`, + }); + } + } + + if (/\brequire\s*\(\s*(?!["'])/.test(source)) { + issues.push({ + sourcePath, + message: "uses dynamic require(); only statically analyzable imports are supported", + }); + } + + if (/\bimport\s*\(\s*(?!["'])/.test(source)) { + issues.push({ + sourcePath, + message: "uses dynamic import(); only statically analyzable imports are supported", + }); + } + + if (/["'][^"']+\.node(?:\?[^"']*)?["']/.test(source)) { + issues.push({ + sourcePath, + message: "references a native .node addon (native Node addons are not available in Workers)", + }); + } + + return issues; +} + +function getStaticModuleSpecifiers(source: string): string[] { + const patterns = [ + /\bimport\s+(?!type\b)[^'"]*?\s+from\s*["']([^"']+)["']/g, + /\bexport\s+(?!type\b)[^'"]*?\s+from\s*["']([^"']+)["']/g, + /\bimport\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\.resolve\s*\(\s*["']([^"']+)["']\s*\)/g, + ]; + + return patterns.flatMap((pattern) => + [...source.matchAll(pattern)].flatMap((match) => (match[1] ? [match[1]] : [])) + ); +}