feat: support Node.js middleware (proxy.ts) - #1309
Conversation
Next.js 16 replaces `middleware.ts` with `proxy.ts` which always runs on
the Node.js runtime. The build currently rejects such apps.
`@opennextjs/aws` compiles the external middleware for a Node.js server:
the OpenNext config manifests are read from the filesystem at runtime and
the middleware compiled by Next.js is loaded with a dynamic
`import("./.next/server/middleware.js")` (excluded from the bundle via
`external: ["./.next/*"]`). workerd can not access the filesystem nor load
modules at runtime, so that handler can not work on Cloudflare.
The Node.js middleware is now bundled on the Cloudflare side into a fully
self-contained `middleware/handler.mjs`, reusing the OpenNext machinery:
- the OpenNext routing layer stays in charge of running the middleware
(`adapters/middleware.js` + `nodeMiddlewareHandler.js` from
`@opennextjs/aws`)
- the config manifests are inlined at build time by `openNextEdgePlugins`,
exactly as for the edge middleware
- the middleware compiled by Next.js is statically bundled from the traced
files copied by `copyTracedFiles`, with the webpack runtime patched to
inline its dynamic chunk requires
- `NEXT_RUNTIME` is defined to "edge" so that the runtime agnostic
middleware base skips `setup-node-env.external.js` which patches globals
that are read-only in workerd; Node.js builtins used by the middleware
are provided by workerd via `nodejs_compat`
- `@opentelemetry/api` is aliased to the copy compiled in Next.js: it is an
optional dependency that most apps do not install, and the edge runtime
branch of the Next.js tracer requires it without a fallback
The experimental example now uses a `proxy.ts` (with a `node:crypto` call)
and the previously skipped Node middleware e2e tests are enabled.
Fixes opennextjs#617
Fixes opennextjs#1277
Next.js 16 builds with Turbopack by default. The Turbopack runtime resolves the chunks of the middleware at runtime, which workerd does not support: the worker was failing with a `ChunkLoadError` on the first request. The Turbopack runtime of the middleware is now patched with the same code as the server (`patchTurbopackRuntimeCode`) to statically inline the chunks. Inlining the chunks pulls in the `.wasm` files of the optional `@vercel/og` dependency. They are marked as external with `setWranglerExternal` - as for the server - because wrangler is the one bundling them.
🦋 Changeset detectedLatest commit: 0762b49 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
This PR adds Cloudflare-specific build support for Next.js 16 Node.js middleware (proxy.ts) by re-bundling OpenNext’s Node middleware handler into a fully self-contained middleware/handler.mjs suitable for workerd (no filesystem, no runtime module loading).
Changes:
- Add a new Cloudflare-side bundling step to produce a self-contained Node middleware handler and wire it into the build when Node middleware is detected.
- Refactor Turbopack runtime patching to expose a reusable
patchTurbopackRuntimeCode()helper. - Update webpack runtime patching to take an explicit
.next/serverdirectory path and reuse it for both server and middleware outputs; re-enable the e2e Node middleware tests and switch the experimental example toproxy.ts.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cloudflare/src/cli/build/patches/plugins/turbopack.ts | Extracts reusable Turbopack runtime patching helper used by both server and Node middleware bundling. |
| packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts | Changes patchWebpackRuntime to accept a .next/server path directly and makes chunk discovery tolerant of missing chunks/. |
| packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts | New build step that bundles Node middleware into middleware/handler.mjs with required runtime/config inlining and polyfills. |
| packages/cloudflare/src/cli/build/bundle-server.ts | Updates server bundling to call patchWebpackRuntime with an explicit .next/server path. |
| packages/cloudflare/src/cli/build/build.ts | Replaces the hard failure on Node middleware with an experimental warning + invokes the new bundling step. |
| examples/e2e/experimental/src/proxy.ts | Updates the experimental example to use proxy.ts and exercise Node APIs. |
| examples/e2e/experimental/e2e/nodeMiddleware.test.ts | Re-enables Node middleware e2e coverage now that support is implemented. |
| .changeset/proxy-node-middleware.md | Adds a minor changeset documenting experimental Node middleware support and nodejs_compat requirement. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- `Object.defineProperty` returns the object it was passed. The override skipping the duplicate `__import_unsupported` definition returned `undefined`, breaking callers relying on the return value. - The Turbopack runtime inlines the `.wasm` chunks with static imports generated from the traced files. Only the `.js` files were collected so the generated `loadWasmChunk` would throw for a middleware using wasm.
|
@conico974 any update ?? |
|
I'll have a look this week when I get time. |
|
Rebased this onto current Opened a merge-ready PR so it can ship from the publishing repo without waiting on the adapters-api stack: → #1320 Full credit remains yours — 1320 is just a rebase + verification so maintainers have a clean branch to land. Happy to close 1320 in favor of updating this PR if you prefer to push the rebase here instead. |
…itizer for Workers runtime
Adds wrangler.jsonc/open-next.config.ts and the cf:build/cf:preview/
cf:deploy npm scripts to deploy this app to Cloudflare Workers via the
OpenNext adapter, verified against a live pre-prod deployment.
Two real, Workers-specific issues found and fixed during that
deployment:
- isomorphic-dompurify (used for waiver-HTML sanitization) depends on
jsdom, which fails outright under the Workers runtime ("Failed to
load external module jsdom...no such file or directory") — a known
upstream limitation, not something fixable via config. Split into
two sanitizers: sanitizeWaiverHtmlServer.ts (new, sanitize-html +
htmlparser2, no jsdom) for the one real server-side call site
(settings/waiver/actions.ts's save action), and sanitizeWaiverHtml.ts
(now plain dompurify instead of isomorphic-dompurify) for the two
client-only call sites (RegistrationWizard.tsx,
WaiverEditorSection.tsx), which only ever run in a real browser.
Removing jsdom entirely (rather than just routing around it) also
fixed a second problem this surfaced: jsdom's 11MB was already being
traced into the server bundle via Next's SSR module tracing even
before this fix, pushing the Worker right up against Cloudflare's
free-tier 3MiB gzip size limit — removing it dropped the deployed
bundle from 3004 KiB to 1788 KiB gzipped.
- .open-next/ (the OpenNext build output) needed excluding from
ESLint's scope — linting it was both pointless (generated code) and
caused a real out-of-memory crash on this machine.
proxy.ts (this Next.js version's middleware-equivalent) isn't
supported by the installed OpenNext adapter version yet (an open
upstream issue, next.js 16's proxy.ts always compiles to Node.js
middleware, which isn't supported there) — deploying currently
requires temporarily excluding it from just the Cloudflare build
artifact. Verified this is safe: every real protected route already
does its own independent auth check (AppLayout's getCurrentUser(),
/office's getCurrentPlatformAdmin(), account/password's setPassword
action), confirmed live that a logged-out visit to a protected route
still correctly redirects to /login without proxy.ts. Revisit once
opennextjs/opennextjs-cloudflare#1309 (Node.js middleware support)
ships.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| return { | ||
| name: "compiled-middleware", | ||
| setup(build) { | ||
| build.onResolve({ filter: getCrossPlatformPathRegex("./.next/server/middleware.js") }, () => ({ |
There was a problem hiding this comment.
Where is this reached ? We should never in theory reach this...
There was a problem hiding this comment.
Agreed — in practice it is not reached because the compiled middleware is statically bundled, so ./.next/server/middleware.js is resolved before this. It is a safety net so the build fails clearly rather than emitting a broken dynamic import if that ever changes. I can drop it if you would rather not carry the dead path.
There was a problem hiding this comment.
You are right that it should not be reached in normal builds — the compiled middleware is statically bundled, so ./.next/server/middleware.js is resolved by setCompiledMiddlewarePlugin before this. This resolver is only a safety net so the build fails clearly instead of emitting a broken dynamic import() if that ever stops matching:
build.onResolve({ filter: getCrossPlatformPathRegex("./.next/server/middleware.js") }, () => ({
path: compiledMiddlewarePath,
}));Happy to drop it entirely if you would rather not carry the unused path — I will not remove it until you confirm.
There was a problem hiding this comment.
Drop it please. If we reach it it means something else is broken, and we should do something about it
There was a problem hiding this comment.
I tried dropping it and the build fails:
✘ [ERROR] Could not resolve "./.next/server/middleware.js"
node_modules/@opennextjs/aws/dist/core/nodeMiddlewareHandler.js:13:38
It turns out this is not an error path — it is the normal resolution. nodeMiddlewareHandler.js does await import("./.next/server/middleware.js"), and esbuild resolves that relative to the handler's own location (node_modules/@opennextjs/aws/dist/core/), where the traced middleware is not. This plugin redirects it to the copy copyTracedFiles puts under middleware/<pkg>/.next/server/middleware.js.
So without it there is no way for esbuild to find the compiled middleware. I can keep it (as you were fine doing for the sibling comment), or if you would prefer the handler resolve it some other way I am happy to follow — but it cannot simply be removed without breaking the build. Let me know which you prefer.
There was a problem hiding this comment.
To be concrete about why it is reached — the import is hardcoded in the aws handler (@opennextjs/aws 4.0.2), core/nodeMiddlewareHandler.js:
//@ts-expect-error - This file should be bundled with esbuild
_module = await (await import("./.next/server/middleware.js")).default;and openNextExternalMiddlewarePlugin only resolves ./middleware.mjs, not ./.next/server/middleware.js. So this specifier is always emitted and nothing on the aws side resolves it — the @ts-expect-error "should be bundled with esbuild" note is essentially delegating that to the adapter. That is what this plugin does.
If the cleaner fix is for the aws handler to import a path the adapter can resolve without a redirect, that would be an aws-side change. For this PR I think keeping the resolver is the minimal way to keep it building — but happy to do whatever you prefer.
- Narrow the esbuild filters of the node builtins plugin so the callbacks no longer run on every module, only on Node.js builtins. - Only alias `@opentelemetry/api` to the copy compiled in Next.js when the app has not installed the real package, so a real dependency still works. - Clarify the `__import_unsupported` comment: the guard is defensive and does not assume the middleware and the server always share a Worker.
|
Thanks for the detailed review — I have pushed fixes for all of it:
The refactors you flagged ( I also see you are adding Node middleware in adapters-api#38. Since the published |
|
I want to be honest about my motivation: I'm not attached to this being "my" PR. I just need If there's anything else you'd like changed here, tell me and I'll do it. The two refactors you questioned only exist to reuse the chunk-inlining logic instead of duplicating it — if you'd prefer I revert them and keep the shared helpers untouched, just say the word and I'll push that. And if |
|
A bit more detail on the two refactors you questioned, since they are the only changes I have not already updated — I wanted to explain the logic rather than just leave them: 1. patchCode: async ({ code, tracedFiles, filePath }) =>
patchTurbopackRuntimeCode({ code, filePath, tracedFiles }),2. // bundle-server.ts (server, unchanged behaviour)
await patchWebpackRuntime(path.join(dotNextPath, "server"));
// bundle-node-middleware.ts (middleware, reuses the same helper)
await patchWebpackRuntime(dotNextServerDir);The intent was to reuse the chunk-inlining logic instead of duplicating it across the server and the middleware. If you would rather I keep those shared helpers untouched, I can revert both and duplicate the small part the middleware needs — I have not changed them and will not until you confirm which you prefer. |
commit: |
conico974
left a comment
There was a problem hiding this comment.
Just 2 little changes, but after that it should be good
Co-authored-by: conico974 <nicodorseuil@yahoo.fr>
|
Both addressed:
It is not a dead path: the aws handler hardcodes that import ( Tests: 342/342 unit pass; 4/4 Node-middleware e2e pass on a real workerd (headers, JSON, redirect, rewrite). |
Cloudflare's OpenNext adapter (@opennextjs/cloudflare) doesn't yet support Next.js 16's Node.js-runtime-only proxy.ts convention (open upstream issue opennextjs/opennextjs-cloudflare#1309), which is why the deploy stage failed with "Node.js middleware is not currently supported" even though the build succeeded. Rename proxy.ts back to the legacy middleware.ts filename Next.js 16 still supports — same next-intl locale-routing logic, unchanged behavior, but it compiles to classic Edge middleware (bundled into server/edge/chunks/*, matching functions-config-manifest.json) instead of the new Node.js-runtime proxy. Verified via a clean build that functions-config-manifest.json no longer lists a nodejs runtime entry. Also add the actual OpenNext/Cloudflare deployment setup, which the project was missing entirely (Cloudflare's dashboard was running plain `npm run build` + `npx wrangler deploy` with no wrangler.jsonc/ open-next.config.ts, so wrangler had nothing to deploy): - wrangler.jsonc (main: .open-next/worker.js, nodejs_compat, assets binding) - open-next.config.ts (defineCloudflareConfig) - package.json cf:build / cf:deploy / cf:preview scripts - @opennextjs/cloudflare + wrangler as dependencies - .open-next/ and .wrangler/ gitignored and excluded from eslint Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes #1277
Related: #617
What
Adds support for Node.js middleware, which is what Next.js 16
proxy.tscompiles to. Previously the build refused to continue withNode.js middleware is not currently supported. Consider switching to Edge Middleware..Why the previous attempt (#1280) was wrong
I opened #1280 and it was rightly closed: it only removed the guard, so a
proxy.tsapp would build and then crash at runtime. This PR implements the missing capability instead, and the guard now only relaxes because the code behind it exists.Root cause
@opennextjs/awsbuilds external Node middleware for a Node server:core/nodeMiddlewareHandler.tsdoesawait import("./.next/server/middleware.js")at request time./.next/*as externalNone of that works in workerd: there is no filesystem and no runtime module loading, so the emitted
middleware/handler.mjscannot run on Cloudflare.Approach
After
createMiddleware()produces the aws output, re-bundlemiddleware/handler.mjson the Cloudflare side so it is fully self-contained (packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts):adapters/middleware.js+core/nodeMiddlewareHandler.js, so matchers, cookies, rewrites, redirects andNextResponse.next()are handled by OpenNext, not reimplemented herecopyTracedFilesoutput via an esbuild plugin, replacing the dynamicimport()openNextEdgePluginsprocess.env.NEXT_RUNTIME = "edge"sosetup-node-env.external.js(which patches read-only workerd globals) is skipped@opentelemetry/apitonext/dist/compiled/@opentelemetry/api, because Next's tracerrequires it without a try/catch on that branchpatchWebpackRuntimefor webpack, and the existing Turbopack runtime patch (now reused aspatchTurbopackRuntimeCode) for Turbopack, which is the default in Next 16create-next-appsetWranglerExternal()so.wasm/.binassets are bundled by wrangler rather than esbuildNo existing behaviour changes when there is no Node middleware.
Safety
The build still fails loudly rather than deferring a crash to production: it throws if the compiled middleware is missing from the traced output, and if the Turbopack runtime chunk cannot be found.
nodejs_compatis still required, and a warning is logged that the support is experimental.Tests
examples/e2e/experimentalnow usessrc/proxy.tsinstead ofsrc/middleware.ts, and the four previously skipped tests ine2e/nodeMiddleware.test.tsare enabled.proxy.tsexercisingnode:crypto(randomUUID,createHash,createHmac),Buffer,node:path, a direct short-circuit response, redirect, rewrite, cookie read/write, query and header access,awaitinside the middleware, request-header override viaNextResponse.next({ request }), response headers, and aconfig.matcher— 18/18 live assertions pass, on a Turbopack build.Known limits
nodejs_compat.Screenshots
1. Build
what main does today (the build refuses to continue), then what this PR does
2. Live production
18/18 assertions pass; "runtime":"nodejs" confirms it runs on the Node.js runtime
3. Tests
unit tests, code checks, and the four Node middleware e2e tests this PR enables