Skip to content

feat: support Node.js middleware (proxy.ts) - #1309

Open
ScienHAC wants to merge 7 commits into
opennextjs:mainfrom
ScienHAC:feat/proxy-node-middleware
Open

feat: support Node.js middleware (proxy.ts)#1309
ScienHAC wants to merge 7 commits into
opennextjs:mainfrom
ScienHAC:feat/proxy-node-middleware

Conversation

@ScienHAC

@ScienHAC ScienHAC commented Jul 13, 2026

Copy link
Copy Markdown

Closes #1277
Related: #617

What

Adds support for Node.js middleware, which is what Next.js 16 proxy.ts compiles to. Previously the build refused to continue with Node.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.ts app 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/aws builds external Node middleware for a Node server:

  • core/nodeMiddlewareHandler.ts does await import("./.next/server/middleware.js") at request time
  • esbuild marks ./.next/* as external
  • the OpenNext config manifests are read from disk on startup

None of that works in workerd: there is no filesystem and no runtime module loading, so the emitted middleware/handler.mjs cannot run on Cloudflare.

Approach

After createMiddleware() produces the aws output, re-bundle middleware/handler.mjs on the Cloudflare side so it is fully self-contained (packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts):

  • keeps OpenNext's routing layer in charge — the entry point is still adapters/middleware.js + core/nodeMiddlewareHandler.js, so matchers, cookies, rewrites, redirects and NextResponse.next() are handled by OpenNext, not reimplemented here
  • statically bundles the compiled middleware from the copyTracedFiles output via an esbuild plugin, replacing the dynamic import()
  • inlines the config manifests with the existing openNextEdgePlugins
  • defines process.env.NEXT_RUNTIME = "edge" so setup-node-env.external.js (which patches read-only workerd globals) is skipped
  • aliases @opentelemetry/api to next/dist/compiled/@opentelemetry/api, because Next's tracer requires it without a try/catch on that branch
  • inlines chunks for both bundlers: patchWebpackRuntime for webpack, and the existing Turbopack runtime patch (now reused as patchTurbopackRuntimeCode) for Turbopack, which is the default in Next 16 create-next-app
  • reuses setWranglerExternal() so .wasm/.bin assets are bundled by wrangler rather than esbuild

No 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_compat is still required, and a warning is logged that the support is experimental.

Tests

  • examples/e2e/experimental now uses src/proxy.ts instead of src/middleware.ts, and the four previously skipped tests in e2e/nodeMiddleware.test.ts are enabled.
  • All existing unit tests pass (342/342).
  • Verified end to end on a real Cloudflare Worker (see screenshots) with a proxy.ts exercising node:crypto (randomUUID, createHash, createHmac), Buffer, node:path, a direct short-circuit response, redirect, rewrite, cookie read/write, query and header access, await inside the middleware, request-header override via NextResponse.next({ request }), response headers, and a config.matcher — 18/18 live assertions pass, on a Turbopack build.

Known limits

  • Support is experimental and requires nodejs_compat.
  • The middleware bundle increases worker size, since it is now bundled instead of loaded at runtime.
  • Wasm bindings inside middleware are untested.

Screenshots


Open in Devin Review

1. Build

what main does today (the build refuses to continue), then what this PR does

Build fails on main branch Build succeeds on this PR

2. Live production

18/18 assertions pass; "runtime":"nodejs" confirms it runs on the Node.js runtime

Live production 18/18 assertions pass Node.js runtime confirmation

3. Tests

unit tests, code checks, and the four Node middleware e2e tests this PR enables

Passed tests and code checks

ScienHAC added 2 commits July 13, 2026 14:34
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.
Copilot AI review requested due to automatic review settings July 13, 2026 10:12
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0762b49

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@opennextjs/cloudflare Minor

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/server directory path and reuse it for both server and middleware outputs; re-enable the e2e Node middleware tests and switch the experimental example to proxy.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.
@ScienHAC

Copy link
Copy Markdown
Author

@conico974 any update ??

@conico974

Copy link
Copy Markdown
Collaborator

I'll have a look this week when I get time.

@evanlong-me

Copy link
Copy Markdown

Rebased this onto current main (1.20.2) and re-verified build + local runtime end-to-end (including node:crypto in proxy.ts).

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.

aquadeskonlinesolutions added a commit to aquadeskonlinesolutions/aquadesk-app that referenced this pull request Aug 1, 2026
…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>
Comment thread packages/cloudflare/src/cli/build/patches/plugins/turbopack.ts
Comment thread packages/cloudflare/src/cli/build/bundle-server.ts
return {
name: "compiled-middleware",
setup(build) {
build.onResolve({ filter: getCrossPlatformPathRegex("./.next/server/middleware.js") }, () => ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this reached ? We should never in theory reach this...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop it please. If we reach it it means something else is broken, and we should do something about it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts Outdated
Comment thread packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts
Comment thread packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts Outdated
Comment thread packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts Outdated
Comment thread packages/cloudflare/src/cli/build/open-next/bundle-node-middleware.ts Outdated
- 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.
@ScienHAC

ScienHAC commented Aug 3, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review — I have pushed fixes for all of it:

  • @opentelemetry/api alias → now conditional; only used when the app has not installed the real package (9c11f4f)
  • broad esbuild filters → the node-builtins plugin now filters on actual builtins only, not every module (9c11f4f)
  • __import_unsupported comment → reworded; the guard was already defensive (9c11f4f)
  • .wasm traced files / Object.defineProperty return → from the earlier bot review (32ef5fc)
  • experimental / unsupported note → added at the top of the file as requested (f3bdab8)

The refactors you flagged (patchTurbopackRuntimeCode, patchWebpackRuntime signature) only exist to reuse the chunk-inlining logic for the middleware instead of duplicating it — I left inline replies offering to revert those to keep the shared helpers untouched if you prefer.

I also see you are adding Node middleware in adapters-api#38. Since the published @opennextjs/cloudflare still builds from this repo and Next 16 apps currently fail on release, I opened this as a stopgap until that lands. Happy to close in favour of #38 whenever it publishes — whichever you prefer.

@ScienHAC

ScienHAC commented Aug 3, 2026

Copy link
Copy Markdown
Author

I want to be honest about my motivation: I'm not attached to this being "my" PR. I just need proxy.ts / Node.js middleware to work on Cloudflare for my own app, and right now Next 16 apps break on every release. So whoever lands it — this PR or your work in adapters-api#38 — I'm genuinely grateful either way.

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 #38 is the intended home and this PR is better off closed, I'm happy to close it.

@ScienHAC

ScienHAC commented Aug 3, 2026

Copy link
Copy Markdown
Author

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. patchTurbopackRuntimeCode (turbopack.ts) — I only extracted the existing body into a function so the middleware can call the same code. The patchTurbopackRuntime CodePatcher still exists and behaves identically; it now delegates to it:

patchCode: async ({ code, tracedFiles, filePath }) =>
  patchTurbopackRuntimeCode({ code, filePath, tracedFiles }),

2. patchWebpackRuntime (webpack-runtime.ts + bundle-server.ts) — changed to take the .next/server directory instead of BuildOptions, so the same helper works for the server and the middleware. The logic did not move, only the argument:

// 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.

@pkg-pr-new

pkg-pr-new Bot commented Aug 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@opennextjs/cloudflare@1309

commit: f3bdab8

@conico974 conico974 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just 2 little changes, but after that it should be good

Comment thread packages/cloudflare/src/cli/build/build.ts Outdated
Co-authored-by: conico974 <nicodorseuil@yahoo.fr>
@ScienHAC

ScienHAC commented Aug 9, 2026

Copy link
Copy Markdown
Author

Both addressed:

  1. Warning message — updated to your wording. ✅

  2. Dropping the compiled-middleware resolver — I tried, and the build fails:

✘ Could not resolve "./.next/server/middleware.js"
  node_modules/@opennextjs/aws/dist/core/nodeMiddlewareHandler.js:13

It is not a dead path: the aws handler hardcodes that import (@ts-expect-error - This file should be bundled with esbuild) and openNextExternalMiddlewarePlugin only resolves ./middleware.mjs, so the adapter has to resolve it. Kept it so the build stays green — happy to switch to an aws-side import if you prefer (details in the inline thread).

Tests: 342/342 unit pass; 4/4 Node-middleware e2e pass on a real workerd (headers, JSON, redirect, rewrite).

everestmarketapp pushed a commit to everestmarketapp/everestmarket-landing-website that referenced this pull request Aug 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] No support for proxy.js in Cloudflare Workers

4 participants