@stayingupwind/adapter
v0.4.0
Published
Next.js deployment adapter: turns `next build` output into a content-addressed deployment bundle and the Functions that serve it.
Downloads
755
Readme
@stayingupwind/adapter
Bundles declare aiInference: 1 at the top level when their runtime supports publishing inference-only
AI service bindings. The marker is independent of the bundle version and resource manifest version.
A host must require it before publishing an AI-enabled deployment, including an uploaded bundle.
The marker itself does not bind AI or include a host's AI service code in application Functions.
next build calls this adapter with a description of the application, and it writes a deployment
bundle under <projectDir>/.arkor/ — bundle.json, the blobs it names, and the Functions (app,
middleware, and workflow for a build that uses the Workflow SDK) that run the application's code
under @stayingupwind/runtime. A build needs no credentials or service calls, and uploading the
bundle is the host's job.
Name the adapter in next.config:
import { createRequire } from 'node:module';
export default {
adapterPath: createRequire(import.meta.url).resolve('@stayingupwind/adapter'),
};or by environment, which is how a host builds an application without touching its config:
NEXT_ADAPTER_PATH=$(node -e "console.log(require.resolve('@stayingupwind/adapter'))") next buildThe adapter uses the stable Next.js Adapter API
(modifyConfig, onBuildComplete), whose shape changes only in a major release. This file is
the record of what it reads from that API, what it makes of it, and where it reaches past the
API into Next.js's own output. The tests under test/ hold each row to its word.
What the adapter reads
From onBuildComplete's context, and where each thing ends up. "Recorded" means the field is
carried into the bundle and nothing reads it yet — it is there so a later reader has it, and so
a diff of two bundles shows a change in it. A field that is neither read nor recorded is not in
the bundle at all, so no consumer can come to depend on it by accident.
| Context field | Bundle field | Read by |
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| nextVersion, buildId | nextVersion, buildId | control (deployment summary); runtime (buildId on a resume) |
| projectDir, repoRoot | projectDir (relative) | recorded |
| distDir | — | the adapter itself: manifests, chunks, the instrumentation hook, images-manifest.json |
| config.output | — | the adapter itself: 'export' is a build with no server, bundled as described below |
| config.basePath, trailingSlash, skipTrailingSlashRedirect, skipProxyUrlNormalize, poweredByHeader, i18n | config.* | runtime (basePath, i18n for routing); control (basePath and i18n decide whether a dynamic shell is served from the edge) |
| config.images, as <distDir>/images-manifest.json has it (source patterns compiled to regular expressions), when the default loader is in use | config.images | edge (what /_next/image enforces); control (carried to the manifest); runtime (the source, when the edge has nothing to serve) |
| routing.* (every phase, shouldNormalizeNextData, rsc) | routing.*, verbatim | runtime (@next/routing, RSC headers); control (dynamic route matchers, reserved routes, header rules); the adapter itself (rsc's prefetch segment suffixes, to read a segment output's own pathname back) |
| outputs.appPages, appRoutes, pages, pagesApi: pathname, filePath, assets, runtime | entrypoints[] (id, kind, pathname, runtime when it is 'edge'); the module and its traced chunks go into the app Function | runtime (which module answers a route); control (an edge route's shell is not served from the edge, and the cache holds no generation of it) |
| outputs.appPages, appRoutes, pages, pagesApi: sourcePage | sourcePages[] (id, sourcePage), verbatim; left out of the Function's runtime manifest | the inspector (the application's own folders: a pathname keeps no route group or parallel slot) |
| outputs.*[].edgeRuntime (modulePath, entryKey, handlerExport), assets, config.env, for an output on the edge runtime | — | the adapter itself: the Function's edge bundle is built from them (see below) |
| outputs.*[].wasmAssets, for an output on the edge runtime | — | the adapter itself: each module travels as a CompiledWasm module of the Function, published under the global its key names |
| a .wasm among outputs.*[].assets, for an output on the Node.js runtime | — | the adapter itself: Next.js says nothing about it, so it is looked for; the wasm-loader patch reads it from the Function |
| outputs.middleware: filePath, assets, runtime, config.matchers | middleware.matchers[]; the module goes into both Functions | edge (when to run the middleware); runtime. On the edge runtime (middleware.ts) it goes into both Functions' edge bundles instead |
| outputs.prerenders[]: id, pathname, route, routeType, response, compute | same names | runtime (which shell answers a route); control (which prerenders the edge may serve) |
| outputs.prerenders[].fallback.filePath, postponedState | body, postponed (blobs) | runtime and control (shell and resume) |
| outputs.prerenders[].pathname, read against routing.rsc.prefetchSegmentDirSuffix and prefetchSegmentSuffix | segmentPath | the host (which router prefetches it can answer from its own storage, prefetchSegments) |
| outputs.prerenders[].fallback.initialStatus, initialHeaders | same names | runtime and control (the shell's status and headers) |
| outputs.prerenders[].parentOutputId, groupId, htmlSize, pprChain, config.renderingMode, allowQuery, bypassFor, partialFallback | same names | recorded |
| outputs.prerenders[].parentFallbackMode | same name | runtime (a member of a fallback: false route is resolved by its own name, since its dynamic matcher fires only for a draft) |
| outputs.prerenders[].fallback.initialRevalidate, initialExpiration; config.allowHeader | same names | the host (the lifetime it seeds a build's generation with); runtime (the headers a regeneration may see) |
| outputs.prerenders[].config.bypassToken | bypassToken, once for the build | runtime (a request whose __prerender_bypass carries it is in draft mode, and is rendered rather than answered from the build or the cache) |
| config.cacheComponents, partialPrefetching, expireTime, cacheLife (as resolved) | config.* | recorded (what a runtime lifetime by name means); the inspector shows them |
| config.cacheHandler, cacheHandlers (as required-server-files.json records them) | — | each module bundled into the Function, not a static export's, and loaded by the cache-handlers patch; the build warns (cache-handlers.ts, and below) |
| outputs.staticFiles[]: pathname, filePath, immutableHash | staticFiles[] (pathname, blob, immutable); the one named /index is offered under / too | edge and control (served from storage); runtime (the small ones the Function carries) |
| public/ (from projectDir) | staticFiles[], URL-encoded pathnames under the basePath | as above; not walked for a static export, whose out/ already holds it |
Both routers are built the same way. What the Pages Router adds is _next/data: next build
emits a data output per page whose props come from getServerSideProps (an entrypoint of its
own, named /_next/data/<buildId>/…json) or from getStaticProps (a prerender under the same
name), and the runtime serves those from the name the client asks for. Its home page is the one
output whose name is not its URL — normalizePagePath spells it /index — so the file is
offered under the application's root as well.
modifyConfig sets supportsImmutableAssets (content-addressed /_next/static/immutable/*).
Before 16.4 Next.js turns it off again for a static export, in finalizeConfig, after the hook has
run, and such an export's hashed names are read off the rule its own server answers them by.
next/image is left as the application configured it: the edge optimizes /_next/image.
The route table goes into the bundle as the build wrote it, collapsed or not. 16.4 collapses it by
default (experimental.collapseAdapterRoutes): a dynamic page and its .rsc and per-segment forms
become one entry whose destination ends in the suffix that matched (/blog/[slug]$2), and a run of
fallback shells one entry whose destination begins with the prefix that matched (/$1/[slug]).
The Functions route with it as it is. Where a page has to be named — the edge's shell for a dynamic
route, the Function a split route is in — @stayingupwind/core reads each such entry back into the
ones it replaced (pageRoutes), which name their pages as every release before 16.4 did.
The development server
modifyConfig is called for every phase, and in phase-development-server it does one thing: it
reserves /__upwind inside the dev server's own routing (src/dev-prefix.ts). upwind dev is the
front door and has already answered that prefix before Next.js is asked, so this is for the paths back
in — a middleware that rewrites to /__upwind/…, a request Next.js makes of itself — where a
catch-all route of the project's would otherwise answer for it. Two rules go at the head of
beforeFiles, ahead of the filesystem, pointing at the address UPWIND_DEV_ADDRESS names; a
project's own rewrites keep the list they were declared in.
Without that variable — a plain next dev, with no upwind in front — nothing is changed at all. There
would be no server to send the prefix to, and a rewrite to a port nothing listens on is worse than no
reservation.
What the project declares beside next.config
One thing the Adapter API has no notion of is a schedule, so the adapter reads it from a file of
the project's own (src/project-config.ts). Four names are looked for directly under
projectDir — upwind.config.ts, upwind.jsonc, upwind.json, vercel.json — and
the first that exists is the whole of the configuration; they are not merged, because merging
means deciding which file wins a key neither meant to share.
| Read | Bundle field | Held to |
| ----------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| crons[] | crons | Vercel's dialect, at build time: a path, and a five-field UTC expression (@stayingupwind/core/cron) |
| functions.split | (how the bundle is made) | false, true, or { maxMiB?, maxCodeMiB? }, which can only tighten the host's budgets (below). Never read from vercel.json, where functions is Vercel's per-file settings |
| anything else | — | ignored, so a vercel.json full of Vercel's own deployment configuration is not read twice, differently |
An upwind.config.ts is evaluated by Node itself — type stripping, no build step, erasable syntax
only — and may export a function, so what a project declares can be computed. A file that will not
parse, a key whose shape is wrong, or a schedule that cannot be run fails the build with the file
named: the alternative is a deployment whose jobs quietly never fire.
A large application, split across Functions
A Function has a size limit, and every byte of its code is compiled when it starts. An application
whose routes between them outgrow one Function can be built as several instead, each holding some of
the routes — only where the host asks for it (createAdapter({ functions: { split } })), since
a host then has to send each request to the Function its route is in.
When. The application is built as one Function first, exactly as it would be otherwise, and
weighed. Within the budgets — maxMiB, what a Function may weigh, and maxCodeMiB, how much of
that may be code — that Function is the build, and the bundle is the version-1 bundle it always
was. Past either, the routes are split — where they can be: an application whose routes are one
unit (see below), or whose plan comes to one Function, stays one Function over the budgets, and the
build says so. A host can split every application that is past the budgets
(projects: 'all', the default) or only those whose own configuration asks
(projects: 'opted-in'); a project can turn the split off with functions.split: false, or tighten
the budgets, never loosen them.
How the routes are placed (plan.ts). The unit is a module — entrypoints that share a built
file go together — except the Pages Router, which goes whole, since res.revalidate() renders any
of its pages in the Function that calls it. (An App Router page it asks for may be in another
Function, which the one asking can neither render nor reach; that page is invalidated instead, as
revalidatePath invalidates one, and its own Function renders it for the next request.) Every app
Function carries the same base: the middleware, the instrumentation hook, the not-found page and
the Pages Router's error pages, the manifests every route reads, and the same runtime.json, so
that each routes a request as Next.js would and knows where every route is. A route's own manifests
— its client reference manifest and its React Loadable manifest, which Next.js reads in that
route's module alone — go with the route, as Next.js's trace of the route names them; one that no
route's trace names (_global-error's, in a build with no output for that page) is in the base.
The units are then merged two at a time, always the pair whose union adds the least code to the
larger of them — routes that share a layout and its libraries first, routes that share nothing last
— while the result stays within both budgets. What remains is the plan: few Functions within the
budgets, each holding routes that share their code. Few, not the fewest: the merge is greedy, and a
pair taken early can leave two units that would each have fitted beside another without a partner
— finding the fewest is bin packing, which no build should wait on. Each piece is weighed with what
the one Function measured: a chunk at the bytes it put into that Function's code (the bundler's
per-module figures, scaled to the module they went into), a package linked from .next/node_modules
with the routes whose chunks import it, a prerendered body, a file a route reads, a route's own
manifest, a WebAssembly module at its size. Code and WebAssembly count against both budgets; the
rest against the size alone. What the middleware and the instrumentation hook reach — their code,
their WebAssembly, the files they read — is in every Function, and is weighed there, whichever
routes reach it too.
Three rules keep a plan from being worse than not splitting. A budget the base and the smallest unit
already pass is one no Function could meet, and is dropped rather than leave every route in a
Function of its own. A unit past a budget the others meet stands alone, as small as it can be —
but takes in a unit whose code it mostly has, one that adds no more code than the two share, since
leaving them apart would carry that code twice. And a plan is never more than MAX_APP_FUNCTIONS
Functions: past that, the cheapest merges go on whatever the budgets say.
What is built. Each Function of the plan with its own routes' chunks, WebAssembly, files,
manifests and prerendered bodies, and the base. The first — the one holding the most documents — is
app, as always; the others are app-2, app-3…, carried in functions.split, each route placed
in its Function on its entrypoint (function), and the bundle is version 2
(SPLIT_BUNDLE_VERSION), which a reader of version 1 refuses rather than taking app for the whole
application. A Function's code modules are named after it — app-2.cjs, edge-2.cjs — and so are
its source maps (app-2/app-2.cjs), so a stack frame says which Function it came from.
dependencies.json records each Function and the plan: its routes and what each Function was
expected to weigh.
What a host does with it. It sends each request to the Function its route is in. The manifest
says which, as far as a table can: functionFor reads the request the way Next.js routes — a
rewrite ahead of the filesystem, the exact pathnames, the dynamic routes — and answers undefined
where it cannot, for the first Function to take. A Function handed a request for a route it does not
hold answers 421 (MISDIRECTED_STATUS) with the name of the Function that does
(x-arkor-function), the request's own body, unread, and — where it routed the request before it
found that out — the routing it came to (x-arkor-routed). The host sends the request on, once,
with that body and that header; a resume or a regeneration, answered before any routing, goes on as
it was asked. The Function that receives it answers from where the routing left off, and the
response is the one a single Function would have given. A second 421 is the host's own mistake
and is not followed.
A static export
With output: 'export', next build writes out/ and then hands the adapter that directory and
nothing else: outputs.staticFiles alone is populated, and pages, appPages, pagesApi,
appRoutes, prerenders and middleware are all empty (Next.js, "Output Types"). The bundle is
the same one, with the server parts empty, and three things are decided here.
Where each file is served. Next.js hands an exported file's path from out/ with .html taken
off, so the root arrives as /index — a name no visitor types. What a visitor types is decided by
which file the build chose to write, and trailingSlash is what settles that. The root document is
out/index.html either way and is the site's root (/, or the spelling a basePath redirects the
other to). With trailingSlash, a page is its directory's index (out/about/index.html → /about/);
without it, a page sits beside its siblings (out/about.html → /about) and a nested
index.html is then not a directory index at all but a route whose own last segment is index
(app/blog/index/page.tsx → out/blog/index.html → /blog/index), which is kept as it is.
Everything that is not HTML — _next/static, the RSC payloads the client router fetches, public/
files — keeps the name it has.
Which files may be cached forever. A static export gets no immutableHash, so instead the rule
the build emits for its own _next/static (in routing.onMatch, naming the directories whose file
names carry a content hash or the build id) decides. Read as the edge reads a cache-control
(isImmutableCacheControl): the whole policy has to be long-lived, public and unwithheld, and
only a rule that holds for every request — the last unconditional one to set the header — can make
a file immutable at all. Without this every hashed chunk would go out must-revalidate.
What the Function is. It is still built, and it runs none of the application's code: no entry, no
Turbopack runtime to patch, no build manifest for one to read. It carries the error documents and
the routing tables, and the edge sends it only what is not a file — a miss, a redirect from
next.config, the trailing-slash normalization — which @next/routing answers as Next.js's own
router would. Every document is the edge's, from storage; which is also why a rewrite is not
followed, its destination being a file the Function does not carry (output: 'export' does not
support rewrites under Next.js either).
What the adapter reaches into
Beyond the API, the Function build depends on these files of Next.js's output and package. Each
dependency is one module under src/patches/, held to every Next.js in the supported range by
the two checks described under "Which Next.js" below, and recorded per build in
.arkor/dependencies.json.
| Where | What | Why, and what guards it |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| .next/server/chunks/[turbopack]_runtime.js, .next/server/chunks/ssr/[turbopack]_runtime.js | the two require(path.resolve(RUNTIME_ROOT, chunkPath)) sites become a static table (turbopack-runtime patch); a chunk whose code another chunk has is loaded from that one's file (same-chunks.ts) | The deployment runtime resolves only the names in the bundle; a table is what esbuild can follow. Exactly two sites per file, or the build fails. Only Turbopack builds are accepted. Turbopack writes the same chunk under several names, and the bundle carried each; two chunks are the same when their code is, once the map comment, the debugId and chunkId comments and the first-line statement recording that id are set aside |
| .next/server/chunks/[turbopack]_runtime.js, .next/server/chunks/ssr/[turbopack]_runtime.js | RUNTIME_ROOT and ABSOLUTE_ROOT, both path.resolve(__filename, …), are resolved from the runtime's own path under /bundle instead (turbopack-root patch) | bundled into the Function's module, __filename is at the top of the module tree, so both roots were /: a file a server module refers to by URL (new URL('./data.json', import.meta.url), which Turbopack copies to server/assets and the Function now carries) was read from /server/assets/…, where nothing is. One of each per file, and none left over, or the build fails |
| next/dist/server/lib/router-utils/instrumentation-globals.external.js | the computed require of the hook becomes the hook's file, or an empty module when the app has none (instrumentation patch) | one site, or the build fails |
| next/dist/server/load-manifest.external.js | readFileSync(path) + vm.runInNewContext become a JSON read (load-manifest patch); the JSON is what evaluateManifestScript produced at build time with the same vm evaluation Next.js would run, in a context holding process.env.NEXT_DEPLOYMENT_ID = the bundle's deployment id | four sites, no vm left, or the build fails. test/manifests.test.ts compares every manifest of every built app with Next.js's own evalManifest. A missing manifest still surfaces as Next.js's own invariant ("The manifests singleton was not initialized"), since Next.js swallows the read's failure |
| the Turbopack WebAssembly loader for the Node.js runtime, wherever the version keeps it: the server chunk it was moved into (16.3), or the Turbopack runtime itself (16.2) | its two entry points — compileModule/instantiate in the module, loadWebAssemblyModule/loadWebAssembly in the runtime — become reads of the module the Function carries, keyed by the .wasm path relative to distDir (wasm-loader and runtime-wasm-loader, both in patches/wasm-loader.ts) | Turbopack's loader reads the file off disk with createReadStream and compiles it with WebAssembly.compileStreaming: a Function has no file, and would compile it in every application instance. Only the registration is rewritten — the read itself is a named declaration or an inlined expression depending on how many ways the build imports a .wasm, and what is left of it is unreferenced. Neither file has a fixed name, so each is found by what only it carries — the module by its two marks together, the runtime by assigning a function to contextPrototype.w where 16.3 assigns the root — and the build asserts one of them fired when an entrypoint named WebAssembly |
| next/dist/server/app-render/cache-signal.js, and the copy inside each next/dist/compiled/next-server/*.runtime.prod.js | the signal arms its pending task through a generation guard, so cancelling it does not depend on the timer being cleared (cache-signal-timers patch) | under cacheComponents every dynamic import() is tracked on one signal per application instance, and a timer it scheduled in one request cannot be cleared from another ("Cannot perform I/O on behalf of a different request") — measured, and refused the same way whether or not that request has ended, so a timer that fires anyway would wake the listeners a round of the event loop early. With the guard the clear is only an optimization |
| next/dist/compiled/@vercel/og | Turbopack's externalImport of index.node.js becomes an import() of index.edge.js — dynamic, so the library is evaluated by the first request that renders an image and not at the Function's start (vercel-og patch), and that build's fallback-font fetch a read of the font the Function carries (vercel-og-font patch); its ./resvg.wasm?module imports resolve to the Function's own modules | the Node.js build is external — a module no bundler can follow and the Function does not have — and reads its font and resvg.wasm off disk before compiling the WebAssembly in every application instance. The edge build is the same library written for a runtime with neither, so next/og answers with an image instead of a 500 |
| next/dist/compiled/next-server/app-page*.runtime.prod.js | the one setTimeout a prerender's task group is scheduled with becomes the runtime's task scheduler, setTimeout staying as the fallback (task-timers patch); the warning that Cache Components "cannot be guaranteed" is silenced | a prerender with cacheComponents is a sequence of setTimeout(…, 0) tasks between which React's setImmediate work is drained by Next.js's patch of setImmediate and process.nextTick; the deployment runtime fires the timers first and has no nextTick queue, so the render was aborted empty and hung when decoded. packages/runtime/src/tasks.ts runs each task once the immediates before it have run. One timer and one warning, or the build fails |
| next/dist/server/node-environment-extensions/fast-set-immediate.external.js | a task of a prerender captures no immediates where the runtime's task scheduler is installed (fast-immediates patch); under next build, where none is, Next.js captures them as before | Next.js runs what a task captured once the task's microtasks are done, which it finds with a process.nextTick queued from a microtask; the deployment runtime's nextTick is a microtask of its own, so React's work ran two microtasks into the task. A prerender's server rendering started before the payload it renders had been read, reached a read of the time with the viewport unresolved, and failed. The scheduler already holds each task until the immediates scheduled before it have run. One site, or the build fails |
| cloudflare:node (handleAsNodeRequest) | not a patch: the runtime hands each entrypoint the IncomingMessage and ServerResponse of a real node:http server the deployment runtime runs, as every official adapter does from Node's own | requires workerd 1.20260917.1 or later: before it, the response emitted close when the request body ended, which Next.js's response pipe takes for a client that went away (see EXPERIMENTS.md, V-01) |
| require-in-the-middle, import-in-the-middle | resolved to an empty module | Node module-loader hooks (OpenTelemetry) with no loader to hook |
| the Turbopack runtime's externalImport(id), the server runtime's import(e) (next/dist/compiled/next-server/*.runtime.prod.js) | not a patch: left as they are, an import() of a module named at run time, and recorded | the loads Next.js makes with something other than one module's name, known to be reached only where they work (a Node built-in); a require or import() like it anywhere else fails the build |
| critters | resolved to a module whose constructor throws | required by the Pages Router runtime for experimental.optimizeCss, which reads built stylesheets off disk; an app that turns it on fails its first render in so many words rather than the bundle |
| next/dist/compiled/raw-body | resolved to the adapter's own copy (patches/raw-body.ts) | the compiled module bundles depd, which builds a function from a string; the deployment runtime refuses that at load, and the Pages Router's API body parser answered every request 400 Invalid body |
| next/dist/compiled/next-server/app-page-turbo*.runtime.prod.js, in the middleware Function | resolved to a module holding the React the runtime vendors for server components, vendored["react-rsc"].React — next/dist/compiled/react*/react.react-server.js, as the runtime hands it out — and nothing else (page-runtime-react.ts) | a Node.js middleware reaches the runtime only for that React, through NextResponse, the cookies, connection() and the tracking of dynamic rendering, and carried and evaluated all 664 KB of it as it loaded. What the middleware's chunks read of the runtime is read off them once the bundle is built: a read of anything more, a require that is not a chunk's external, or a chunk not written as this reads it, and the Function is bundled again with the runtime itself |
| next/dist/compiled/ws | resolved to a module that throws | Next.js requires it only from the WebSocket getter it installs where the runtime has none, and the deployment runtime has one |
| next/dist/compiled/jsonwebtoken, in a build with no Pages Router route | resolved to a module that throws | it verifies the preview data a Pages Router route signs into a cookie, and is required only inside the try that reads the cookie back; with no route to sign any, a failure to load it comes to what a token that does not verify already does. Asked of the build, not of a Function: a cookie one Function sets is sent to all |
| @opentelemetry/api, next/dist/compiled/@opentelemetry/api | each resolves to the app's own copy where it installed one, and to Next.js's where it did not (vendoredOtelPlugin); for Next.js's, the app's own is looked for from Next.js's package, whoever asks | Next.js prefers the app's and falls back to its own inside a try, which a bundler cannot see: bundling both carried the API twice, and bundling only the app's left an external the Function cannot load. The page runtime's own require of Next.js's copy (16.4) keeps nothing of what it returns. The chunks below ask for Next.js's copy from the build's output, where a strict install that has the API only as Next.js's peer has none to find |
| the OpenTelemetry API in .next/server/chunks/**: the copies Next.js's tracer, compiled into the chunks, requires in its try and its catch | each becomes an external of next/dist/compiled/@opentelemetry/api, as Turbopack writes one, where a chunk's table installs it under its id alone and it carries the key the API keeps its state under (otel-chunks.ts) | Turbopack compiles the tracer into the chunks of every layer that reaches it and bundles both requires there: each layer held a copy of each beside the one Next.js's server is bundled with, seven of the API in the minimal fixture's app Function. The API keeps its state on a global, so one copy answers as the copies did |
| next/dist/server/patch-error-inspect.js | its top-level require of next/dist/compiled/source-map becomes a read on first use (lazy-source-map patch) | the library maps an error's frames through a source map, and the Function carries none of its maps, yet it was evaluated at every start. Every read of the library has to be of SourceMapConsumer, or the build fails |
| the AsyncLocalStorage banner | globalThis.AsyncLocalStorage ??= require('node:async_hooks').AsyncLocalStorage is the first line of app.cjs, and of edge.cjs | Next.js's storages read the global once, at module evaluation, and settle for a fake without it; a bundle without the banner fails every render (test/node/broken-bundles.test.ts in the runtime) |
| .next/server/edge/chunks/**, the chunks of an entrypoint on the edge runtime | evaluated, in the order assets lists them, by the thunk in edge.cjs that the route's first request calls; the handler is then read from globalThis._ENTRIES[entryKey] | the documented way to invoke one (Adapters, "Invoking Entrypoints"). Their chunk loader reads no file at run time (loadChunkCached throws), so evaluating the chunks is the whole of loading an entry; their Turbopack runtime's import(), require.resolve and kept loader are recorded and allowed |
| Node built-ins | node:* and the bare names are left to the deployment runtime; a built-in outside the list in dependencies.ts fails the build | under nodejs_compat every built-in import resolves (the unimplemented ones as stubs that throw when used), so the list is what is known to be reached only where it works |
| *.json under .next/ and .next/server/, BUILD_ID, each route's react-loadable-manifest.json and *_client-reference-manifest.js (as .json) | shipped as text modules of the app Function under their .next/... names; the runtime mounts them at /bundle/.... The middleware Function carries none | excluded rather than listed (*.nft.json are the only files known to be of no use): a manifest Next.js reads without a fallback that a list left out fails every route. Only a route module reads one: the middleware module and its hook open none |
The app module (app.cjs) is bundled by Rolldown, as one CommonJS module with the patches applied
as each file is loaded — 5% smaller than esbuild made it from the same graph, and built sooner (see
EXPERIMENTS.md, V-03). The runtime module (index.mjs) is Rolldown's as well, for the workerd and
worker conditions, and so is the edge bundle (edge.cjs), where a build produced one. An import
that Rolldown cannot resolve, in the runtime or in the cache host it bundles, fails the build: the
Function would not have that module either. One inside a try — a require(), or an awaited
import() — is left to fail when it runs, for the fallback around it to catch. Rolldown
shakes the members of a namespace object a dependency builds, which esbuild keeps whole:
import { z } from 'zod' in a cache host kept all of zod in the runtime. All three are minified in
whitespace and syntax. The runtime's variables and parameters are renamed short as well, and so are
those of the CommonJS modules app.cjs bundles from Next.js's and React's own files — Next.js's
server, React, and React's server renderer (react-dom) — one module at a time
(mangle-packages.ts). No other package's code is renamed. Next.js and React are what the adapter's
tests and Next.js's deploy tests run, and nearly all of what renaming takes off. Any other package
would be renamed untested, where a mangler bug nobody has reported yet, or a package that reads its
own functions' parameter names, would break it unseen. No function or class is renamed anywhere, so
a stack trace names every frame as before, and nothing Turbopack or Next.js's own build minified is
renamed again: React reads a page's postponed state back by the names of the components that
rendered it, and with the whole of app.cjs renamed every resume fell back to client rendering. A
module's /*#__PURE__*/ annotations, which the minifier drops as it prints, are put back before the
calls they mark. A module with code the pinned mangler renames wrongly — a catch parameter taken
apart, a direct eval, a function declared in a block of sloppy code (oxc-project/oxc#24331,
#26485) — or one that marks a function #__NO_SIDE_EFFECTS__ is left as it was written, and so is a
runtime with any of that code. The runtime is renamed whole, the host's cache module among it
(below). In a fixture's app Function that took the runtime from 348,545 to 263,742 bytes and
app.cjs from 2,544,044 to 2,480,829; in its middleware Function, 141,809 to 109,476 and 550,583 to
489,274. A Pages Router fixture's pages render with the application's own react-dom, and its app
Function's app.cjs went from 2,447,874 to 2,163,473, 221,203 bytes of that react-dom's.
Two things the build wrote are rewritten on their way into the app module, as the patches rewrite
Next.js's files. With experimental.turbopackModuleFragments (Next.js 16.4) Turbopack registers
every export of a module with a setter, a const export's among them — e.s(["k",()=>b,e=>b=e]),
which nothing calls. Node.js runs such a chunk, since the assignment is only an error if it is
made, but Rolldown refuses to bundle it (ILLEGAL_REASSIGNMENT). Such a setter's body becomes the
TypeError running it would throw, and nothing else changes (const-export-setters.ts): which
names are const is read scope by scope, and one that cannot be read is left for Rolldown to say
what it says of it.
And the copies of the OpenTelemetry API that Turbopack bundled into the chunks, each layer's own
beside Next.js's, which Next.js's tracer requires in its try and its catch, become externals of
next/dist/compiled/@opentelemetry/api, and so the one copy Next.js's server is bundled with
(otel-chunks.ts). Which modules those are is read off the tracer before anything is bundled: its
try and its catch, each the require of one module by its id, then the six members it takes of
what it got. A module is rewritten where a chunk's table installs it under that id alone and it
carries the key the API keeps its state under; a build whose tracer reads otherwise keeps its
copies.
.arkor/dependencies.json records, per Function — and under edge, for its edge bundle — the
files bundled from the build output and from each package (with the bytes each puts in the
bundle), the built-ins left external, the stubs, the patches applied with their edit counts,
every use of the loader the bundler could not follow — a require or import() made with
something other than one module's name, a method of the loader called or kept (require.resolve,
`requi
