Skip to content

feat!: serve requests through the official Next.js adapter runtime - #271

Open
bestickley wants to merge 173 commits into
mainfrom
docs/adapter-runtime-plan
Open

bestickley wants to merge 173 commits into
mainfrom
docs/adapter-runtime-plan

Conversation

@bestickley

@bestickley bestickley commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Implements docs/plans/adapter-runtime-release.md: cdk-nextjs stops deploying
Next.js's output: "standalone" server and instead serves requests through the
official Next.js adapter API — onBuildComplete build outputs, @next/routing
for dispatch, and one runtime core behind two shells (Lambda and container).

docs/plans/adapter-runtime-progress.md is the per-step record: what landed, what
was decided, what was measured, and — in its "Final — exit criteria roll-up"
section — every plan exit criterion with its state.

Why

The standalone server was a black box we shipped and then worked around. The
adapter API gives us the build outputs directly, so routing, middleware, image
optimization and caching are things this library resolves rather than things it
proxies to a server it cannot see into. Concretely that bought:

  • Functions move from container images to zip Lambdas. Cold start init p50
    2061 ms → 342 ms; end-to-end cold path ~2.5 s → ~1.4 s (measured against the
    main-rgnl-fns oracle, read-only from CloudWatch Logs).
  • No dedicated image function, and no experimental flag for one. Image
    optimization is served by the same runtime core.
  • functionGroups — route-group splitting, as the documented answer to a
    250 MB Lambda size error.
  • Three pre-existing bugs fixed that the new coverage exposed. rgnl-fns is
    red on main for two of them (run 35591295859), so this branch repairs a
    standing failure rather than introducing one.

Breaking changes

See docs/breaking-changes.md. Summary:

  • CDK_NEXTJS_EXPERIMENTAL_DEDICATED_IMAGE_FUNCTION removed, along with the image
    construct it gated.
  • nextjsFunctionsProps.dockerImageFunctionProps → functionProps. The Docker
    image override keys are meaningless once Functions are zip Lambdas.
  • The Docker prerequisite now applies to the Containers types only.

Verification

Unit: pnpm compile 0 errors, pnpm eslint clean, 17 suites / 268 tests.

e2e (examples/e2e-tests) against four deployed stacks on this branch:

type result
glbl-fns (with the api split) 32 passed
glbl-cntnrs 29 passed, 3 skipped
rgnl-cntnrs 29 passed, 3 skipped
rgnl-fns 29 passed, 3 skipped

The skips are the function-groups tests, which need E2E_FUNCTION_GROUPS.

New coverage the plan called for: middleware intercepting _next/image, PPR under
cacheComponents (which also settled that no manual resume code is needed),
response compression on both Functions types, conditional GETs, and a split
configuration proving a grouped and an ungrouped route come from different
Lambdas.

Next.js's own e2e suite

scripts/e2e-warm.sh + scripts/e2e-deploy.sh / e2e-logs.sh /
e2e-cleanup.sh / e2e-sweep.sh implement the adapter testing
harness
contract, so vercel/next.js's e2e tests can run against a real
cdk-nextjs deployment. Nightly workflow_dispatch workflow, one shared stack,
serialized. scripts/e2e-harness/README.md is the guide.

Current state, stated plainly: two test files, both green on attempt 0, exit
code 0. That is the "plumbing + small slice" scope, not a broad slice.

Two deliberate deviations, both documented:

  • It runs on NextjsGlobalFunctions, not NextjsRegionalFunctions as the plan
    says. next.js's getFullUrl assigns parsedUrl.pathname outright, so the
    mandatory API Gateway /<stage> prefix is discarded and every absolute path
    404s. Not fixable from our side.
  • Fixtures using the edge runtime are excluded. assertNodeRuntimes throws
    during next build on any non-nodejs output, so such a fixture fails to build
    and a per-case skip cannot rescue it. Deliberate: the edge runtime is deprecated
    upstream and cdk-nextjs supports Next.js 16's Node-runtime proxy.ts. ~522 of
    next.js's e2e files are edge-free. Every exclusion has a reason in
    test/deploy-tests-manifest.json's excluded-notes.

Harness stacks are tag-gated (hrns-* and cdk-nextjs:harness=1, re-checked
immediately before each delete), age-floored, and dry-run unless --apply. The
shared stack from the final run was swept.

Known open items

One left, named rather than hidden; it wants its own change:

  1. deployment-skew's RSC content-type bug — an RSC: 1 request gets
    text/html; charset=utf-8 instead of text/x-component. Characterized, not
    yet diagnosed to edge vs. adapter. Excluded with a note saying it should come
    back as a regression test.

The other two are now fixed in this branch: healthCheckPath moved off
NextjsBaseProps onto the two Containers constructs (required where it is
actually read, a type error where it did nothing), and cache-handler
invalidation now prefixes the app's basePath onto the routes it derives, so a
Global type with a basePath invalidates the URI CloudFront cached.

Splits config.supportsImmutableAssets out of the adapter runtime plan into
its own branch and PR. No dependency on entrypoint invocation in either
direction; the only overlap is a one-line conflict in modifyConfig.
Rewrites the plan as an implementation script rather than a design argument:
adds a files-touched map, a commit order, and a concrete artifact contract
(staging layout plus AdapterManifest types) so the build, dispatch, and
runtime steps agree on filenames and path conventions.

Resolves the two open questions in place — delete the dedicated image Lambda
and its experimental flag, lazy-require entrypoints per dispatch — renumbers
the phases into build order, and moves the rationale that must not be
relitigated into a single Decisions section. Drops the duplicated packaging,
standalone-fallout, and minimalMode passages.
Adds an EnterWorktree hook that runs pnpm install, so a fresh worktree is
buildable without a manual step.
Next.js 16.3.5 no longer emits the root-params.d.ts reference. This file is
generated by next build, so the checked-in copy was drifting dirty on every
local build.
Step 1 of docs/plans/adapter-runtime-release.md.

`onBuildComplete` now writes `.next/cdk-nextjs-adapter/`: a `manifest.json`
holding `ctx.routing` verbatim, the route-template -> entrypoint map, config,
pathnames and middleware, plus an `app/` tree staging the deduped union of every
Node output's traced `assets` keyed by repo-root-relative path. That tree is the
replacement for `output: "standalone"`, which is still produced -- nothing reads
the manifest yet.

Also: throw on any non-nodejs output rather than only on edge middleware, use
`assetsHashes` to detect two outputs mapping one key to different content, and
warn on the route config cdk-nextjs does not honor (`maxDuration`,
`preferredRegion`).

Symlinks in `assets` are recreated as symlinks, matching Next's own
`copyTracedFiles`: under pnpm they are frequently directory symlinks into the
store, which `copyFile` cannot handle and which triple the tree if dereferenced.

`examples/pages-i18n` is a new minimal Pages Router app whose only purpose is to
produce the `outputs.pages`/`pagesApi`/`config.i18n` shapes for the test
fixtures; Next.js rejects `i18n` when an `app/` directory is present, so the
App Router playground cannot cover them.
Adds `src/runtime/dispatch.ts`: `Dispatcher` calls `@next/routing`'s
`resolveRoutes` and returns a `DispatchResult` discriminated union — entrypoint,
static file, image optimization, redirect, external rewrite, middleware
responded, direct response, not found. It is pure decision-making, so it is unit
tested against the manifests step 1 builds from real `onBuildComplete` captures.

Notable findings this encodes:

- `resolvedPathname` is both basePath- and locale-prefixed, matching
  `manifest.entrypoints` keys exactly. No normalization anywhere.
- `resolveRoutes` drops `MiddlewareResult.requestHeaders`, so dispatch captures
  them from its own `invokeMiddleware` wrapper. Otherwise
  `NextResponse.next({ request: { headers } })` breaks silently.
- Every redirect arrives as a bare `status` plus `location` in `resolvedHeaders`,
  never as `ResolveRoutesResult.redirect`. `toRedirect` normalizes both shapes.
- `/_next/image` is not an adapter output type, so it is always unresolved and is
  intercepted after middleware, which is where it has to run.

`buildAdapterManifest` now also maps dynamic *prerender* templates to their
owning route's entrypoint. Without them Pages Router ISR data URLs
(`/_next/data/<buildId>/fr/blog/hello.json`) 404, because the template they match
exists only as a prerender output. Concrete prerenders are deliberately excluded:
listing `/isr/1` makes it resolve to itself instead of `/isr/[id]` and loses the
`nxtPid` param.

The fixture capture script no longer trims dynamic prerender templates away, and
gains `--reuse-capture` to re-trim without a rebuild. All three fixtures
regenerated.
Adds MiddlewareRunner: the invokeMiddleware callback resolveRoutes calls,
and nothing more. It builds a Request, invokes the built middleware handler,
and hands the Response to @next/routing's responseToMiddlewareResult for the
x-middleware-* translation. No matcher evaluator (resolveRoutes gates from
routing.middlewareMatchers) and no hand-written header protocol.

Loading awaits the require() result: Turbopack emits middleware as an async
module whose module.exports is a Promise, so a synchronous require(...).handler
is undefined. Both shapes are covered and tested.

DispatchRequest gains a required method and MiddlewareInvoker takes it, because
MiddlewareContext carries none and middleware needs one.
Adds the request path: a synthesized `IncomingMessage`/`ServerResponse` pair, a
`ResponseSink` that owns gzip, and `NextjsRuntime.handle(request, sink)` that
dispatches through `@next/routing` and invokes built entrypoints as
`handler(req, res, { waitUntil, requestMeta })`.

Two shells wrap that one core and do nothing but translate: `lambda.mts`
(`awslambda.streamifyResponse`, both the Function URL and API Gateway REST event
shapes) and `server.mts` (a `node:http` server). Containers gets the *same*
synthesized request objects as Lambda, so its e2e suite exercises the code Lambda
runs.

Also pins `.mts` under type-checking for the first time: the jsii `include` is
`src/**/*.ts`, so both shells would otherwise be unchecked. `pnpm compile` now
also runs `tsc -p tsconfig.esm.json`.

Nothing is wired into a construct yet — step 5.
`next` is external to both shell bundles, so esbuild turned every
`import ... from "next/dist/..."` in `static-files.ts` and `image.ts` into a
hoisted static import inside `cdk-nextjs-runtime/{lambda,server}.mjs`. Node
resolves those by walking `cdk-nextjs-runtime/node_modules` and then
`<deploymentRoot>/node_modules`, and under pnpm neither holds `next`: the only
`next` in the staged tree is the relative symlink at
`<projectDir>/node_modules/next`, one directory below the deployment root.
Because the imports are hoisted, this was not a degraded image route — the whole
shell would have failed to load.

`src/runtime/next-modules.ts` requires those modules through a `createRequire`
anchored inside the staged project dir, which is the same walk the traced
entrypoints do, for any package manager and any layout. `loadRuntime` points it
at the project dir right after it chdirs there. `handler-utils.ts` takes its two
`next` values as parameters so the dedicated image Lambda, which bundles `next`
in, keeps working from the same shared code.

`core.test.ts` now links this repo's `node_modules` into the staged project dir,
so `serveStatic` in the test resolves by the deployed walk rather than a
different one. Verified: no `import "next/..."` remains in either bundle.
`sendNotFound` pointed `req.url` at the not-found entrypoint's own pathname
before invoking it. The App Router serializes the canonical URL into the RSC
payload, so the client hydrates off a payload claiming it is at `/_not-found`:
`usePathname()` returns the wrong value and the history entry is wrong. Render
the module against the URL that was asked for instead, which is what
`next start` does.

`render404()` called from inside a route keeps `req.url` as it is — it is
already the path that gave up, which is the path Next.js renders that 404 for.
`@next/routing` echoes the rewrite it followed back in `resolvedHeaders` as
`x-middleware-rewrite`; Next's own router treats that as an internal signal and
strips it. Forwarding it leaked the app's post-middleware paths to clients.

`resolveRoutes` routes a middleware rewrite internally but never reports the
rewritten URL, and `/_next/image` is not an adapter output type so it can never
appear in `manifest.pathnames`. Capture the rewrite from that header and match
`/_next/image` against it: the API Gateway examples rewrite `/_next/image` to
`/<stage>/_next/image` to line `basePath` up, and comparing the URL as received
404s every optimized image behind such a rewrite.
All four deployment types now serve requests out of the deployment root the
adapter stages during `next build`, with cdk-nextjs's own shell as the
entrypoint. `output: "standalone"` is still set — it goes away in the next step
— but nothing reads `.next/standalone` anymore.

`NextjsBuild` reads and validates the adapter manifest (a missing one is now an
error that names its own cause: `next.config` has no adapter registered),
exposes `deploymentRootPath` and `relativeProjectDir`, and stages the one shell
the deployment type runs into `cdk-nextjs-runtime/` beside a copy of the
manifest. `sharp` handling moves with it: the staged tree is keyed by
repo-root-relative path, so both the darwin-binary strip and the target install
search by directory name rather than assuming one top-level `node_modules`, and
the target is glibc for Functions, musl for Containers.

`NextjsFunctions` drops `DockerImageFunction` for a zip `Function`. No Docker
image, no Lambda Web Adapter; streaming is `awslambda.streamifyResponse` in the
shell. `overrides.dockerImageFunctionProps` and `overrides.assetImageCodeProps`
give way to `overrides.functionProps`, and `functions.Dockerfile` is deleted.
The regional example no longer needs `ResponseTransferMode.BUFFERED`: API
Gateway does not compress a streamed response, so the runtime gzips it itself.

`NextjsContainers` and both container Dockerfiles `COPY` the deployment root and
run `node cdk-nextjs-runtime/server.mjs`. A generated Dockerfile is now
cdk-nextjs's to replace: every existing consumer has one that runs
`node server.js`, which this version no longer produces, so keeping it makes the
container crash-loop on MODULE_NOT_FOUND with nothing pointing at the cause. The
`# ~~ Generated by cdk-nextjs ~~` first line marks ours, and says that deleting
it takes ownership.

Two adapter-side fixes this needed:

- `hoistStoreOnlyPackages` picks the version with staged *code*, not the first
  by staging key. The trace stages `semver@6.3.1/package.json` for its metadata
  and the whole of `semver@7.8.5`; `6.3.1` sorted first, so hoisted
  `node_modules/semver` held one `package.json` and no modules, `sharp` failed
  to load, and every external `/_next/image` request 200ed with the unoptimized
  original — under next's misleading "Module `sharp` not found".
- `addRuntimeNextClosure` traces what the image optimizer reaches. `next build`
  only traces what the app reaches, and no app reaches
  `next/dist/server/image-optimizer.js`, so `/_next/image` 500ed. Uses next's
  own vendored `@vercel/nft` rather than a new dependency or bundling, which
  would break the "always use the app's own next" invariant.

Verified on AWS across all four deployment types against the reference stacks:
byte-identical responses for App Router pages, ISR, SSR, SSG, route handlers,
streaming, static files off disk, image optimization on both glibc and musl, and
middleware rewrites. Packaging is 49 MB unzipped / 21 MB zipped for one function
serving every route, 20% of the Lambda cap. Details in
docs/plans/adapter-runtime-progress.md.
`removeExistingSharpBinaries` strips the traced host-platform `sharp` binaries
out of the staged tree before installing the target's. It matched on `sharp-`
and removed everything it found with `rmSync(path, { recursive: true, force:
true })`, in one pass, in `readdirSync` order.

pnpm's store directory names match that same substring
(`@img+sharp-darwin-arm64@0.35.4`) and several links point at one store
directory, so the walk regularly reached a store directory before the links
into it. That leaves dangling symlinks — and `rmSync` with `force` *silently
no-ops* on one, because `force` swallows the ENOENT its internal `rmdir` gets.
The second pass reported success and the link stayed. Four dangling
`@img/sharp-darwin-arm64` entries shipped in the deployment asset, which is a
latent ENOENT in anything that dereferences the tree — `cdk-assets` does, when
it zips.

Collect symlinks and directories separately and `unlinkSync` every symlink
first.
`onBuildComplete` stages the deployment root from the same NFT traces
`writeStandaloneDirectory` would have used, so standalone and the adapter are
alternatives, not layers. `next build` says as much itself, immediately above
the `onBuildComplete` call: "in the future `output: standalone` might not be
allowed if an adapter with `onBuildComplete` is configured". Since step 5
nothing read `.next/standalone`; this stops asking for it, and deletes the code
that only existed to serve that layout:

- `validateNextBuildOutput`, `findRelativePathToServerJs` and
  `relativePathToPackage`, all keyed to `.next/standalone/server.js`.
- The dedicated image optimization Lambda — handler, construct, experimental
  flag, `OptionalDockerImageFunctionProps`, the `imageFunctionUrl` /
  `imageFunction` props and the CloudFront origin and API Gateway integration
  built from them, plus `prepareImageOptimizationAssets` and its 19 MB second
  asset. `/_next/image` is served by the runtime core, which is not a size
  optimization: dispatch classifies a request as image optimization only after
  middleware has had it, so `NextResponse.rewrite()` onto an image works now and
  could not have worked against a separate function behind its own origin.

`_next/image*` keeps its own CloudFront behavior — its cache policy wants
`queryStringBehavior: all()` and `accept` in the key, which is wrong for
everything else — but points at the same origin as the rest. On API Gateway it
has no resource at all and falls through to `{proxy+}`.

`src/image-optimization/handler-utils.{ts,test.ts}` move to
`src/runtime/image-utils.{ts,test.ts}`; the directory is gone.

Verified on all four deployment types against the oracle stacks with
`.next/standalone` confirmed absent: byte-identical responses everywhere except
the oracle's own stale-canonical-URL 404 bug.
Adds an opt-in `functionGroups` prop to the two Functions root constructs.
Its only purpose is staying under Lambda's 250 MB unzipped cap, which is now
measured per group at synth rather than surfacing from CloudFormation minutes
into a deploy.

The split has to be decided twice in two processes — `onBuildComplete` stages
one tree per group while `next build` is running, synth turns the same patterns
into CloudFront behaviors or API Gateway resources — and a disagreement means
CloudFront routing a request to a function whose zip lacks the entrypoint. So the
rules live in one pure module both sides import, the resolved groups travel via
`CDK_NEXTJS_FUNCTION_GROUPS`, and synth reads the assignment back off the
manifest instead of recomputing it.

Patterns are an exact path or a subtree and nothing else: CloudFront supports
only `*`/`?`, so a dynamic segment could only deploy as a wildcard that also
captures its siblings.

Default behavior is unchanged — no prop, one function, one deployment root.
`fetchFromS3` used the app's `basePath` both to strip the href and to build
the S3 key. Those are different things: `basePath` prefixes the *URL* the app
is served at, while the key prefix is where `NextjsStaticAssets` uploaded the
bytes. On the API Gateway deployment types the first is the stage name
(`/prod`) and the second is empty, so every local `<Image>` asked S3 for
`prod/_next/static/...`, got `NoSuchKey`, and 400'd with "The requested
resource isn't a valid image."

`NextjsStaticAssets.keyPrefix` is now a field with one definition, threaded
through the compute props to `CDK_NEXTJS_STATIC_ASSETS_KEY_PREFIX` and read by
the runtime image optimizer. `fetchFromS3` takes both values separately.
The S3 static integration neither forwarded `If-None-Match` /
`If-Modified-Since` nor mapped a 304 response, so a client revalidating an
immutable `_next/static` chunk re-downloaded it with a 200 every time. Both
headers are now declared optional on the method and mapped into the
integration request, and a `304` integration/method response pair passes back
`Cache-Control`, `ETag` and `Last-Modified` with no body.
`ctx.outputs.prerenders[].pathname` carries the app's `basePath`, so with
`basePath: "/prod"` the build seeded `<buildId>/prod/ssg/1.json` while the
server - which sees the path only after Next.js has stripped `basePath` - asked
for `<buildId>/ssg/1.json`. Every build-time prerender was a MISS that
re-rendered on the first request and only became static from the second visit,
which is what `ssg.test.ts` was catching on the API Gateway deployment types.

`prerenderPathToCacheKey` strips `basePath` on a path boundary and maps the
resulting root to `index`.
Step 8 of docs/plans/adapter-runtime-release.md.

- `function-groups.test.ts` proves a grouped and an ungrouped route are served
  by different Lambdas, via a `/runtime-identity` page and API route that report
  the function name. `examples/global-functions` now declares a `/api/**` group
  permanently, so the split is exercised locally and in CI.
- `middleware.test.ts` covers the plan's "middleware e2e proving interception on
  `_next/image`": `proxy.ts` 403s one committed fixture image and the test
  asserts the 403, which is only reachable if middleware runs for image
  requests.
- `headers.test.ts` covers ETag shape, compression on and off, and a conditional
  GET. The compression pair is what replaces the LWA verification, since API
  Gateway does not compress a streamed response.
- `isr` and `revalidation` now settle on a timestamp instead of trusting the
  first load after an invalidation, which could legitimately serve the expired
  entry while kicking off the background re-render.

Docs: README gains a `functionGroups` section and corrects the Docker
prerequisite to Containers-only; `breaking-changes.md` covers the adapter
runtime, zip Lambdas and the removed image function; `next-build-output-guide.md`
described standalone output and now describes the staged deployment root.

All four deployment types are green: glbl-fns 32 passed, the other three 29
passed / 3 skipped (the split tests). `rgnl-fns` is red on `main` for causes the
three preceding `fix:` commits repair.
Migrate `app-playground` to `cacheComponents: true` - what Next.js 16.3 folded
`experimental.ppr` into - and add `ppr.test.ts`, which asserts through a real
deployment that the prerendered shell goes out first and the request-dependent
half is resumed onto it in the same response.

Every prerender failure the flag surfaced fell into one of the three buckets
Next.js names in its own error message:

- `[cache]`: `'use cache'` on the data functions in `getCategories.ts` and
  `getReviews.ts`, with `notFound()` and the throw to `error.js` left outside so
  one upstream blip cannot be cached as a 404.
- `[stream]`: a `<Suspense>` boundary as deep as possible around anything that
  reads the request - the client URL hooks, which suspend under
  `cacheComponents`, and `params` in a dynamic route with no
  `generateStaticParams`, which `'use cache'` cannot legalize. Two new shared
  components, `ui/category-tab-group.tsx` and `ui/category-content.tsx`, carry
  that for seven layouts and fourteen pages.
- `[block]`: `export const instant = false`, only in the streaming demo, whose
  point is reading the cart cookie before there is anything to prerender.

`/runtime-identity` awaits `connection()` in place of `dynamic =
'force-dynamic'`, which `cacheComponents` rejects; without it the response is
prerendered and every function reports the same empty identity.

`/isr/[id]` keeps ISR by overriding only `cacheLife({ revalidate: 10 })`. A
`stale` shorter than the shell's own stale time cannot be baked into the shell,
so the build postponed the route and served it `private, no-store` with no
`x-nextjs-cache` - CDN caching and the `isr` suite's assertions both gone.

No runtime change was needed: with `minimalMode` unset, the per-route entrypoint
resumes the postponed render in-process and pipes it onto the shell, exactly as
`next start` does.
A dynamic app-page render in an app with no middleware crashed the Lambda with
"Invariant: AsyncLocalStorage accessed in runtime where it is not available".

`next/dist/server/app-render/async-local-storage.js` reads
`globalThis.AsyncLocalStorage` at module scope and keeps whatever it saw, so if
the global is not set yet every app-render storage in the process becomes Next's
`FakeAsyncLocalStorage`, whose methods throw. Inside the precompiled
`app-page-turbo.runtime.prod.js` the load order is against us:
`work-async-storage.external.js` is required before `route-module.js` gets to
the bootstrap that sets the global.

Apps with middleware were accidentally fine, because
`next/dist/build/templates/middleware.js` requires the same bootstrap and the
runtime runs middleware before it loads a page entrypoint. Every example app has
middleware, which is why no e2e caught this.

`loadRuntime` now requires `next/dist/build/adapter/setup-node-env.external.js`
right after pointing `next` resolution at the staged app. That is the bootstrap
Next.js publishes for adapters; besides the globals it installs the
`react`/`react-dom` require hook and the crypto polyfill, which were also
reaching us only via middleware.
Adds the plumbing for running vercel/next.js's own e2e suite against a real
cdk-nextjs deployment, plus an explicit three-file slice to run it on:
app-static, app-action and middleware-rewrites.

- scripts/e2e-deploy.sh, e2e-logs.sh, e2e-cleanup.sh implement the three
  NEXT_TEST_*_SCRIPT_PATH hooks; e2e-sweep.sh cleans up orphans.
- scripts/e2e-harness/app.js deploys NextjsRegionalFunctions and reports a
  Lambda Function URL. The harness's getFullUrl discards any prefix in the
  deployment URL, so API Gateway's mandatory stage prefix cannot be used: every
  absolute path would miss /prod and 404. Same Lambda, same adapter output,
  different front door.
- scripts/e2e-harness/stage-static.js copies _next/static and public/ into the
  deployment package, which the product deliberately leaves out because
  CloudFront and API Gateway route those prefixes to S3. A bare Function URL has
  no S3 integration in front of it.
- test/deploy-tests-manifest.json is a v2 filter manifest; its per-file excluded
  cases are copied from next.js's own manifest at v16.3.5, i.e. cases that fail
  on Vercel too.
- .github/workflows/e2e-harness.yml runs it nightly and on demand, then sweeps.

Every harness stack carries a cdk-nextjs:harness tag and an hrns- prefix, and
both delete paths refuse a stack without the tag. The sweeper additionally
requires the stack to be older than HARNESS_SWEEP_MAX_AGE_HOURS (default 6) so
it cannot delete a running test's stack.

The official test files have not been run: that needs a built vercel/next.js
checkout, and installing one was refused in the authoring environment. The
scripts are proven end to end against real AWS with a hand-written fixture
shaped like a harness temp app. Details in docs/plans/adapter-runtime-progress.md.
The compatibility harness previously stood up a NextjsRegionalFunctions
stack per test file and exposed it with a FunctionUrlAuthType.NONE
Function URL. That URL was world accessible, which Palisade flagged and
Epoxy auto-mitigated. Nothing in the published library was affected --
the Function URL was created by harness code only.

Deploy NextjsGlobalFunctions instead: CloudFront in front of the
product's own AWS_IAM Function URL with OAC, so there is no
unauthenticated endpoint and `_next/static` / `public/` are served from
the NextjsStaticAssets bucket exactly as in production. That also lets
stage-static.js go away.

All test files now share one stack (`hrns-shared`) deployed with
`--hotswap-fallback`, so only the first file of a run pays ~12 minutes
for a distribution. Measured against two built fixtures, five resources
differ and two of them are not hotswappable (the cache bucket's
`aws-cdk:cr-owned` tag, and the distribution's per-`public/` cache
behaviors), so most files take the CloudFormation fallback -- still a
couple of minutes rather than ~13. See scripts/e2e-harness/README.md.

Sharing the stack requires: `-c 1` (concurrent deploys into one stack
would race), e2e-cleanup.sh keeping the stack for the next file with
e2e-sweep.sh deleting it after the run, and e2e-deploy.sh invalidating
the distribution itself -- a hotswap never runs CloudFormation, so the
post-deploy custom resource never fires. Its properties are pinned in
app.js for that same reason.
`EnterWorktree` is not a hook event, so Claude Code ignored the whole
block and the `pnpm install` never ran on worktree creation. The valid
event is `WorktreeCreate`.

Also drops `matcher`, which only filters tool-scoped events
(PreToolUse/PostToolUse) by tool name and matches nothing here.
`assertNodeRuntimes` folded middleware in with the routes, and
middleware's `sourcePage` is `/`. An app whose only edge artifact was a
legacy `middleware.ts` was therefore told to remove
`export const runtime = "edge"` from `/` -- a page that does not set it
and need not exist.

Report the two separately, naming middleware by its file path and
pointing at Next.js 16's Node-runtime `proxy.ts`.

Found by running vercel/next.js's own e2e suite through the
compatibility harness: three of its fixtures use legacy edge middleware.
First step in which vercel/next.js's own e2e suite actually ran against a
cdk-nextjs deployment, which is step 8's exit criterion 2. Both manifest
files pass on attempt 0, exit code 0.

Three things that only showed up by running it:

1. All three files in the previous manifest were unbuildable, not
   merely failing. `assertNodeRuntimes` throws during `next build` on any
   output whose runtime is not `nodejs`, and two fixtures ship a legacy
   edge `middleware.js` while app-static has ~10 `*-edge` routes. Edge is
   a deliberate non-goal -- deprecated upstream, and cdk-nextjs supports
   Next 16's Node-runtime `proxy.ts` -- so they are excluded with reasons
   in `excluded-notes`. ~522 e2e files are edge-free. Replacements were
   chosen by running them, which the manifest now states as a rule.

2. The first test file of every run failed and only passed on retry: a
   cold stack create is ~240s, `createNext` runs in jest's `beforeAll`,
   and NEXT_E2E_TEST_TIMEOUT is 240000. `scripts/e2e-warm.sh` creates the
   stack up front through `e2e-deploy.sh` itself, so a successful warm-up
   proves the path the test files take. Verified cold.

3. `deployment-skew` fails for real: an `RSC: 1` request gets
   `text/html` rather than `text/x-component`. Not the header-quota
   caveat -- `rsc` is allowlisted. Excluded pending its own fix, recorded
   as a bug rather than an inapplicable test.

Also corrects every '~12 minutes' distribution-create claim to the ~240s
actually measured.
Final entry in the progress log: every exit criterion from
docs/plans/adapter-runtime-release.md with its state, the one documented
deviation (the harness runs on NextjsGlobalFunctions, not Regional, because
next.js's getFullUrl drops the API Gateway stage prefix), and the five items
carried past the PR.
…plan

# Conflicts:
#	README.md
#	src/image-optimization/handler.mts
#	src/nextjs-build/nextjs-build.ts
#	src/nextjs-compute/nextjs-image-function.ts
#	src/nextjs-static-assets.ts
#	src/root-constructs/nextjs-base-construct.ts
#	src/root-constructs/nextjs-global-functions.ts
#	src/root-constructs/nextjs-regional-functions.ts
#	src/runtime/image-utils.test.ts
#	src/runtime/image-utils.ts
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 3 package(s) with unknown licenses.
See the Details below.

License Issues

.github/workflows/zero-config-build.yml

PackageVersionLicenseIssue Type
actions/checkout4.*.*NullUnknown License
actions/setup-node4.*.*NullUnknown License
pnpm/action-setup4.*.*NullUnknown License

OpenSSF Scorecard

Scorecard details
PackageVersionScoreDetails
actions/actions/checkout 4.*.* 🟢 6.6
Details
CheckScoreReason
Maintained🟢 79 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 7
Code-Review🟢 10all changesets reviewed
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Packaging⚠️ -1packaging workflow not detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 10SAST tool is run on all commits
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
actions/actions/setup-node 4.*.* 🟢 6.4
Details
CheckScoreReason
Binary-Artifacts🟢 9binaries present in source code
Code-Review🟢 10all changesets reviewed
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Maintained🟢 1018 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Packaging⚠️ -1packaging workflow not detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies🟢 5dependency not pinned by hash detected -- score normalized to 5
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
SAST🟢 10SAST tool is run on all commits
actions/pnpm/action-setup 4.*.* 🟢 5.1
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Maintained🟢 76 commit(s) and 3 issue activity found in the last 90 days -- score normalized to 7
Packaging⚠️ -1packaging workflow not detected
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 5Found 16/30 approved changesets -- score normalized to 5
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies🟢 7dependency not pinned by hash detected -- score normalized to 7
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
License🟢 10license file detected
Fuzzing⚠️ 0project is not fuzzed
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
Signed-Releases⚠️ -1no releases found
Security-Policy⚠️ 0security policy file not detected
SAST⚠️ 1SAST tool is not run on all commits -- score normalized to 1
npm/@tsconfig/next catalog: UnknownUnknown
npm/@types/node catalog: UnknownUnknown
npm/@types/react catalog: UnknownUnknown
npm/@types/react-dom catalog: UnknownUnknown
npm/cdk-nextjs UnknownUnknown
npm/next catalog: UnknownUnknown
npm/prettier catalog: UnknownUnknown
npm/react catalog: UnknownUnknown
npm/react-dom catalog: UnknownUnknown
npm/typescript catalog: UnknownUnknown
npm/@next/routing 16.3.5 🟢 6.4
Details
CheckScoreReason
Code-Review🟢 10all changesets reviewed
Maintained🟢 1030 commit(s) and 6 issue activity found in the last 90 days -- score normalized to 10
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
License🟢 10license file detected
Security-Policy🟢 10security policy file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: githubv4.Query: Resource not accessible by integration
Binary-Artifacts⚠️ 0binaries present in source code
Packaging🟢 10packaging workflow detected
Pinned-Dependencies🟢 4dependency not pinned by hash detected -- score normalized to 4
Fuzzing🟢 10project is fuzzed
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0
npm/aws-cdk 2.1142.0 UnknownUnknown

Scanned Files

  • .github/workflows/zero-config-build.yml
  • examples/pages-i18n/package.json
  • pnpm-lock.yaml

bestickley and others added 30 commits September 24, 2026 11:42
A top-level `public/` entry is matched by name rather than by an existing
wildcard, so on the Global types it becomes its own CloudFront behaviour -
whose path pattern `toPathPattern` has to write with `?` for the space -
and on `NextjsRegionalFunctions` it is skipped by design, because an API
Gateway resource path cannot express it. The spec asserts both: 200 on
three types, 404 on API Gateway.

Its own commit because the first push into an existing Global stack adds
a CloudFront behaviour, which should show up in CI timings on its own.
…xpansion

docs/adapter-runtime-plan took defect numbers 34 and 35 while this branch
was open, so the redirect-drops-the-API-Gateway-stage defect is renumbered
to 36, in docs/harness-coverage.md and in the url-normalization gates. It
now sits under a "Bug — not yet fixed" section after 35, since the target
moved the path-traversal entry that section used to hold to Upstream.
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…e-Control

The dynamic cache policy inherited CDK's one-day default TTL, so a route
handler without Cache-Control was cached at the edge for 24 hours.
Listing public/ in the manifest at onBuildComplete missed postbuild output,
keyed names only in their encodeURIComponent spelling, never matched in an
i18n app, dropped symlinks, lost to app routes at the same path, and bloated
every Lambda manifest. The container runtime now lists public/ itself and the
Dispatcher builds its routing table once per cold start.

Also: call send with an encoded path under a root (files with % in the name),
pass generateEtags through, answer 405 to non-GET/HEAD for public/ and
_next/static, match _next/static chunks requested percent-encoded, and render
a missing static file's 404 with middleware's request headers.
…: 0 deployable

A top-level public/ name CloudFront cannot spell became a pattern of wildcards
alone that captured app routes of the same length; it now gets no behavior and
a synth warning. The 255-character limit is checked on the final pattern, and a
dynamic cache policy with maxTtl 0 drops its cache key, which CloudFront
requires.
…r header it was cached with

Since dynamic responses stopped being cached by default, /api/revalidate reaches
the app, so the baseline is the blocking REVALIDATED render. CloudFront caches it
for s-maxage=10 and serves it stale under stale-while-revalidate, and the
11-second reload saw that cached header. An edge hit is not a re-render, which is
what the assertion exists to catch.
…ve text

/[0-2]s ago/ also matched "12s ago" and "20s ago", and the text ticks
client-side. The render timestamp in the title attribute is what the cache
actually keeps.
The working logs were stale on arrival. adapter-runtime.md keeps the map: build
and request paths, invariants, knowing divergences from next start, and per-type
limitations. harness-coverage.md shrinks to verdicts, a defect index and
debugging technique. The README no longer claims revalidation leaves CloudFront
stale on the Global types; it invalidates for you.
…drain on shutdown

- treat each Set-Cookie entry as one cookie instead of comma-splitting
- drop the failed render's Content-Length/ETag before every error fallback
- send no-store on a 404 for a manifest chunk missing from disk
- wait for in-flight handle()/waitUntil work on SIGTERM
- respect backpressure on proxied web responses; stop reading on disconnect
- forward x-forwarded-host/proto on in-process revalidation
- check segment boundaries when stripping basePath
…m the rendering output

- stage /_not-found, /404, /500 and /_error in every function group
- map /index to / only for Pages Router outputs
- key group ownership on the loaded file so Pages data routes follow their page
- take each prerender's cache kind from its parent output (catch-alls, Pages home)
- reject /_next group patterns and non-string routes
- read staged file links with bounded concurrency
…d group routes

- only drop the cache key when every resolved TTL is 0
- route method-less group parent resources to the default function
- reject group routes API Gateway path parts cannot hold, naming the group
- throw when a basePath prop repeats the stage; warn on non-root domain mappings
- back up a customized generated Dockerfile before replacing it
- remove only @img/sharp-* binaries; find staged sharp in the cleanup walk
- prune only <prefix>/_next/ so apps sharing a bucket are left alone
- replace Partial<CachePolicyProps> with an explicit interface
…idation

- validate memory hits against DynamoDB markers so other instances' revalidations apply
- send a single CloudFront invalidation per revalidateTag, collapsing to /* past quota
- invalidate the whole app when the tag-mapping query is truncated
- honor revalidateTag durations (stale-while-revalidate via Next's tag manifest)
- ignore mapping rows from tags that only share a # prefix
- stop deleting revalidated fetch entries on get
- skip mapping rows without a distribution; batch marker reads
- round init-cache ephemeral storage so handler identity is stable; keep staging dir when staging is disabled

This branch has not been deployed

No deployments
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.

1 participant