Add Next.js 16.2.x compatibility patches (perf_hooks shim, fast-set-immediate shim, Turbopack handler delegation)#1262
Conversation
Next.js 16.2.x bundles gc-observer.js (which imports PerformanceObserver
from perf_hooks) and @vercel/otel (which uses
require("perf_hooks").performance) into the instrumentation hook loading
path.
esbuild normalises bare "perf_hooks" to "node:perf_hooks" on the node
platform, and Cloudflare Workers does not expose this module even with
nodejs_compat.
Add a perf-hooks shim that:
- re-exports performance from globalThis.performance (available in Workers)
- provides a no-op PerformanceObserver (GC observation is Node.js-specific)
Resolve "perf_hooks" and "node:perf_hooks" to the shim via a new
shim-node-modules onResolve plugin (not esbuild's alias config) so the
shim is bundled rather than externalized by platform:"node".
Related: opennextjs#1258
Next.js 16.2's app-render-scheduling imports `next/dist/server/node-environment-extensions/fast-set-immediate.external`, whose top-level install() runs `nodeTimers.setImmediate = patchedSetImmediate` to install a custom immediate scheduler. Under `nodejs_compat`, the `node:timers` module namespace is a frozen ESM module, so the assignment throws "Cannot assign to read only property 'setImmediate' of object '[object Module]'" on the first request, crashing every page render. The fast-immediate scheduler is an optimization, not a correctness requirement. Replace the module with a shim that exports the same names (DANGEROUSLY_runPendingImmediatesAfterCurrentTask, expectNoPendingImmediates, unpatchedSetImmediate) as no-ops and surfaces globalThis.setImmediate directly. Pages render correctly without the patched scheduling in Workers. Also expose AsyncLocalStorage on globalThis in the banner so Next's async-local-storage.js detects the real implementation rather than falling back to the throwing FakeAsyncLocalStorage shim — the fallback cascades into a confusing "Cannot read properties of undefined (reading 'require')" when Turbopack loads app-page-turbo.runtime.prod.js as an external module. Related: opennextjs#1258
🦋 Changeset detectedLatest commit: 17ee18e 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 |
Next.js 16.2.x Turbopack app-page templates set up exports on a child module via `esmExport(bindings, childId)`, then re-call esmExport for the entry module's own id. Each page chunk then does: R.m(<entryId>) module.exports = R.m(<entryId>).exports `R.m(<entryId>).exports` only carries the vendored bindings from the second esmExport call — `handler` lives on the child module. At request time Next reads `components.ComponentMod.handler` and gets undefined, crashing with "ComponentMod.handler is not a function" on every app route. Fix at build time: 1. Scan the SSR `app-page` template chunks under `.next/server/chunks/ssr/` to build an entry->child module-id map by reading the `module.exports = [<entryId>` and `a.s([...,"handler",...], <childId>)` anchors. 2. Add an esbuild onLoad plugin that matches each `.next/server/app/.../page.js` and `.next/server/app/.../route.js`. When the entry id has a recorded child, replace the terminator with a merge expression: copy the entry's own-property descriptors first, then layer the child's descriptors on top for keys the entry doesn't define. The merged object exposes `handler` alongside the Turbopack bindings while preserving lazy getters. The patch only touches page.js / route.js — it does not modify the Turbopack runtime, `esmExport`, or `instantiateModule`, so it can't regress instrumentation hook loading or app-page-turbo external loading. Related: opennextjs#1258
76f3e2c to
17ee18e
Compare
|
Thanks for the PR @chanceaclark
That would really be helpful please - please include a repro of the failure (link to a GH repo with instruction) for each of them. Some comments/thoughts about your points above:
There used to be a bug in workerd. It has been fixed months ago. Here again a recent compatibility date might be enough to fix the issue. So before splitting this PR into multiple issues, it would be nice to try and bump the compatibility_date to something recent i.e. 2026-04-15 and check if it is enough to fix at least 1 and 2. We have recently added a build time warning for older compat date. We should probably turn this into a build error. This might also explain why I was not able to repro #1258. |
I could only reproduce 3. on older versions of Closing this PR. |
What
Three build-time patches that make Next.js 16.2.x apps work on Cloudflare Workers. Without these, every app-router request crashes on cold start.
1.
perf_hooksshim (f61f7c4)Next 16.2.x bundles
gc-observer.js(importsPerformanceObserverfromperf_hooks) and@vercel/otel(usesrequire("perf_hooks").performance) into the instrumentation hook loading path. esbuild normalizes bareperf_hookstonode:perf_hookson the node platform, and Workers does not expose this module even withnodejs_compat.A new shim re-exports
globalThis.performanceand provides a no-opPerformanceObserver. Resolved via a newshimNodeModulesonResolveplugin (notalias) so the shim is bundled instead of being externalized byplatform: "node".2.
fast-set-immediateshim (b42ef6d)Next 16.2's app-render-scheduling imports
next/dist/server/node-environment-extensions/fast-set-immediate.external, whose top-levelinstall()runs:Under
nodejs_compatthenode:timersnamespace is a frozen ESM module, sonodeTimers.setImmediate = ...throwsCannot assign to read only property 'setImmediate' of object '[object Module]'on every request, before any user code runs.The fast-immediate scheduler is an optimization, not a correctness requirement. A new shim exports the same names (
DANGEROUSLY_runPendingImmediatesAfterCurrentTask,expectNoPendingImmediates,unpatchedSetImmediate) as no-ops and surfacesglobalThis.setImmediatedirectly.This commit also extends the bundle banner to expose
AsyncLocalStorageonglobalThisso Next'sasync-local-storage.jspicks up the real implementation rather than falling back toFakeAsyncLocalStorage(which throws on.run()/.exit()and cascades into a confusingCannot read properties of undefined (reading 'require')when Turbopack loadsapp-page-turbo.runtime.prod.js).3.
patchPageExportsplugin (39ab69b)Next 16.2.x Turbopack app-page templates set up exports on a child module via
esmExport(bindings, childId), then re-callesmExportfor the entry module's own id. Each page chunk then does:R.m(<entryId>).exportsonly carries the second call's vendored bindings —handlerlives on the child module. At request time Next readscomponents.ComponentMod.handler, getsundefined, and crashes withComponentMod.handler is not a functionon every app route.The fix:
app-pagetemplate chunks under.next/server/chunks/ssr/to build an entry→child module-id map by anchoring onmodule.exports = [<entryId>anda.s([…,"handler",…], <childId>).onLoadplugin that matches.next/server/app/**/page.jsand.next/server/app/**/route.js. When the entry id has a recorded child, replace the terminator with a merge expression that copies the entry's own-property descriptors first, then layers the child's descriptors for keys the entry doesn't define. The merged object exposeshandleralongside the Turbopack bindings while preserving lazy getters.The patch only touches
page.js/route.js. It does not modify the Turbopack runtime,esmExport, orinstantiateModule, so it cannot regress instrumentation hook loading or app-page-turbo external loading.Why
Without these three patches, Next.js 16.2.x on Cloudflare Workers fails on every request:
gc-observer.jsevaluation (perf_hooks)install()(node:timers mutation)ComponentMod.handler is not a functionTogether they make a real Next 16.2.6 app-router storefront return 200s across homepage, search, category, product, brand, and blog routes.
Testing
Verified locally with a Next.js 16.2.6 app-router site running under
[email protected] dev. Without the patches: 500 on every request. With the patches: 200 across the homepage, search, compare, category, product, brand, and blog routes.tsc --noEmitandeslintare clean.Notes
ComponentMod.handler is not a function(and a leading-slash dynamic require of/.next/server/middleware-manifest.json) #1258.patch) covers all three fixes.