Skip to content

feat!: require Node.js 22.2+ and drop the Node 20 workarounds - #318

Open
bestickley wants to merge 9 commits into
nextfrom
feat/require-node-22
Open

bestickley wants to merge 9 commits into
nextfrom
feat/require-node-22

Conversation

@bestickley

@bestickley bestickley commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #305.

  • .projenrc.ts: minNodeVersion: "22.2.0" (replacing setNodeEngineVersion: false), so package.json gets engines.node: ">= 22.2.0". 22.2 is the first 22.x with zlib.crc32. CI workflows are unchanged.
  • src/utils/zip-directory.ts: use zlib.crc32; the hand-written table and its test are gone.
  • src/nextjs-cache.ts: drop the entry.parentPath ?? entry.path fallback.
  • src/nextjs-static-assets.ts: drop the Node 20.12 mention from listFiles's comment (the explicit walk stays for symlinks).
  • Bundles loaded by the user's own next build (adapter, cache-handler, use-cache handlers) target node${minNodeVersion} instead of node24; the Lambda runtime shells stay on node24.
  • README prerequisites and docs/breaking-changes.md (under 0.7.14) state the requirement.

Not !: Node 20 is EOL, and engines is advisory for a library (npm warns; pnpm errors only with engine-strict).

Verified: pnpm test (47 suites, 1175 tests) and pnpm eslint pass.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
npm/handlebars 4.7.10 🟢 5.1
Details
CheckScoreReason
Code-Review🟢 8Found 14/17 approved changesets -- score normalized to 8
Packaging⚠️ -1packaging workflow not detected
Security-Policy🟢 10security policy file detected
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Maintained🟢 30 commit(s) and 4 issue activity found in the last 90 days -- score normalized to 3
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
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
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0

Scanned Files

  • pnpm-lock.yaml

@bestickley
bestickley marked this pull request as ready for review October 15, 2026 20:34
@bestickley bestickley changed the title feat: require Node.js 22.2+ and drop the Node 20 workarounds feat!: require Node.js 22.2+ and drop the Node 20 workarounds Oct 16, 2026
@bestickley
bestickley changed the base branch from main to next October 16, 2026 15:00
auto-merge was automatically disabled October 16, 2026 15:01

Merge commits are not allowed on this repository

bestickley and others added 8 commits October 16, 2026 11:44
Fixes #321.

On a gzipped streamed response, a `<Suspense>` fallback arrived only
with
the slow boundary it stood in for. `pipeToSink` forwarded Next.js's
per-chunk `res.flush()` to `compressor.flush(Z_SYNC_FLUSH)`, but under
backpressure the chunk Next.js had just written was still in
`ShimServerResponse`'s buffer, so the flush was queued in zlib *ahead
of*
it. The last chunk before a pause then sat in zlib until the next write.
With ~100 KB written before a pause, the client could decode only 70-80
KB,
matching the 73 KB vs ~100 KB (identity) in the issue.

The gzip stage now uses zlib's per-write `flush: Z_SYNC_FLUSH`, which is
ordered with the data. `ShimServerResponse.flush` and the matching call
in
`sendWebResponse` are removed: nothing needs them now, and without the
property Next.js skips its own flush call.

The new `sink.test.ts` case runs Next.js's write → flush →
wait-for-drain
loop against a slow sink. It fails on the old forwarding and passes
here.
Follow-up to #323, from its review.

#323 removed `ShimServerResponse.flush`, so app code that calls
`res.flush()` now throws `TypeError: res.flush is not a function`. An
example is `res.write(event); res.flush()` in a Pages API route, which
the
`compression` middleware supports under `next start`. The method is back
as
a no-op: the sink's gzip stage already sync-flushes every chunk, so
there is
nothing left for it to do. Next.js's own `pipe-readable` now calls it
after
every chunk, which is harmless.

Also:

- The backpressure test's comment now says how hex actually compresses
  (about 2:1). What makes each write meet backpressure is `res`'s own
  16 KiB buffer.
- In `sendWebResponse`, the drain comment is back next to the drain
check.
- The `res.flush()` entry in `docs/nextjs-private-apis.md` is updated.

Accepted trade-off from #323, noted here for the record: every write to
a
gzipped response is now sync-flushed. The Next.js render path already
flushed every chunk, and 64 KB static-file chunks barely change. Many
tiny
writes without a flush cost more: 10,000 writes of 40 bytes grow from
59.5 KB to 161 KB. Delaying each flush until the buffered bytes reach
zlib
would avoid that, but it needs byte accounting across Transform buffers,
which risks reintroducing #321's ordering bug.
)

## Summary

- **Container images:** the generated Global/Regional Containers
Dockerfiles build on `node:24-alpine` but never upgrade OS packages. The
current upstream image (Alpine 3.24.2) still ships `zlib-1.3.2-r0`
(CVE-2026-85091), while `1.3.2-r1` is already in the Alpine repo. Both
Dockerfiles now run `apk upgrade --no-cache` before `apk add`, so images
pick up security fixes released after the base image was published. User
Dockerfiles carrying the generated header are rewritten automatically.
- **Dependabot:** adds a `handlebars: ^4.7.10` pnpm override (via
`.projenrc.ts`) to resolve alerts #436, #437, #438. handlebars is
transitive (ts-jest, conventional-changelog-writer); the lockfile diff
touches only handlebars.

## Testing

- `docker run node:24-alpine sh -c 'apk upgrade --no-cache && apk add
--no-cache libc6-compat && apk info -v | grep ^zlib'` → `zlib-1.3.2-r1`,
node v24.22.0.
- `pnpm compile`, `pnpm projen`, `pnpm jest
src/nextjs-build/nextjs-build.test.ts` (52 passed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
… hanging, and extract pure helpers out of the constructs (#319)

Closes #313, and extends it to the other constructs.

## Extracted (internal, none exported from `src/index.ts`, each with
unit tests)
| Module | From | What |
|---|---|---|
| `utils/path-patterns.ts` | NextjsDistribution | `public/` path
patterns (UTF-8 `?` rule, 255-char limit after basePath),
`getPathPattern`, public/group collisions |
| `utils/behavior-budget.ts` | NextjsDistribution |
`assertBehaviorBudget`, `DEFAULT_MAX_CACHE_BEHAVIORS` |
| `utils/cache-keys.ts` | NextjsDistribution | `isCachingDisabled`,
dynamic/disabled cache keys |
| `utils/viewer-request-functions.ts` | NextjsDistribution |
`checkFunctionCodeSize`, `assertViewerRequestUnclaimed` |
| `utils/api-resource-paths.ts` | NextjsApi | function-group resource
paths (`{proxy+}`, `_next/data/{buildId}`), parent ownership,
routability and `public/` collision checks |
| `utils/cache-deployment-sizing.ts` | NextjsCache | deployment Lambda
storage steps, cap and memory |
| `utils/dockerfile.ts` | NextjsContainers | keep / unchanged / create /
replace decision |
| `utils/api-gateway-prefix.ts`, `assertStaticAssetsKeyPrefix` in
`utils/base-path.ts` | NextjsBaseConstruct | API Gateway stage/domain
prefix, static assets key prefix check |
| `utils/remove-staging-dirs.ts`, `utils/secure-bucket-props.ts` |
NextjsCache + NextjsStaticAssets | shared staging cleanup and bucket
defaults |
| `lambdaSizeError`, `isClientEntrypointChunk` in `nextjs-build.ts` |
NextjsBuild | 250 MB error, patch-fetch chunk filter |
| `splitGroupRoute`, `assertKnownFunctionGroups` in
`adapter/function-groups.ts` | NextjsApi + NextjsDistribution | the
route split was written out three times; the unknown-group check was in
both |
| `isFunctionsType`, `isGlobalType` in `constants.ts` | several |
replace hand-written type checks |

In the distribution, the four response headers policies (static,
dynamic, image, service worker) share one builder, and `getComment(x)`
reads the stack name itself. `nextjs-distribution.ts` went from 1271 to
966 lines, and `nextjs-api.ts` from 832 to 718.

## Behaviour changes
- **fix:** the fetch patch was also prepended to `main-app-*.js.map`
when browser source maps are on.
- **fix:** sizing the init cache used `readdirSync({ recursive: true
})`, which never returns on a symlink cycle under Node 24. It now walks
the tree with `listTree`.
- **fix:** the cache bucket now denies requests below TLS 1.2, as the
static assets bucket already did. This is the only template change in
normal configurations.
- A CloudFront comment of exactly 128 characters now keeps the stack
name (`<` became `<=`).
- Containers derive their CPU architecture from
`getLambdaArchitecture()`. x64 and arm64 hosts are unchanged. A 32-bit
`arm` host now gets ARM64, matching what NextjsBuild stages. Other
unusual hosts now set X86_64 explicitly instead of leaving it to
Fargate's default, which is also X86_64.

## Verification
- `pnpm compile` and `pnpm test` (eslint included) pass: 56 suites, 1302
tests, rebased on main at #324. This branch doesn't change
`src/nextjs-distribution.test.ts` or `src/nextjs-api.test.ts`; the tests
#320 added to them pass against the refactor.
- Rebased on main: #320's `Service-Worker-Allowed` behavior is ported
into the refactor. Its policy-name constants stay in
`nextjs-distribution.ts`, and `assertBehaviorBudget` counts the
service-worker behavior (`serviceWorker`), with a unit test.
- I compared templates before and after with throwaway harnesses
(`Template.fromStack(...).toJSON()` plus warnings and error messages):
- NextjsDistribution, 7 configs (basePath, assetPrefix, functionGroups,
unicode `public/`, viewerRequestFunctionCode, containers, long stack
name): identical.
  - NextjsApi, 4 configs and 6 error paths: identical.
  - Root constructs, 9 configs across all four types: identical.
  - NextjsCache, 6 configs: only the TLS 1.2 deny statement is added.
  - NextjsStaticAssets: identical.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Until 1.0, breaking changes should only ship in a `0.X.0` release.
`feat`/`fix` release as patches, and users on `^0.7` get those
automatically.

- `docs/breaking-changes.md`: the `0.7.12` section gets a note that it
shipped in a patch by mistake. It and the `0.5.16` sections are folded
into `0.7.0` and `0.5.0`. Each moved section starts with a _Shipped in
0.X.Y._ line, so readers on an earlier patch can tell what applies to
them. The text is otherwise unchanged.
- `CLAUDE.md`: a new Key Conventions rule:
  - patches must not break anyone; warn or deprecate instead;
- breaking PRs take a `!` title (`fix!:`/`feat!:`), which projen bumps
to the next minor, and target the unreleased `next` branch, which is
squash-merged into `main` as one `feat!:` commit when the minor ships;
  - `docs/breaking-changes.md` has no patch headers.

Docs only. No release is triggered.
Node 20 is end-of-life. Declare engines.node >= 22.2.0 (the first 22.x
with zlib.crc32), use zlib.crc32 in place of the hand-written table, and
drop the Dirent.path fallback for Node 20.9-20.11.

Closes #305
- R1-3: Node.js requirement no longer calls 22 an active LTS; newer LTS lines are supported
- R2-1: listFiles comment no longer ties directory-link following to Node 24
- R2-3: sizeOfDirectory docstring says it counts through directory links, as cdk-assets zips them
- R2-4: bundles loaded by the user's next build target minNodeVersion (node22.2.0)
- R2-5: require() of the ESM bundles noted as Node 22.12+
@bestickley
bestickley force-pushed the feat/require-node-22 branch from fb36ff1 to 48c4230 Compare October 20, 2026 13:22
@bestickley bestickley added question Further information is requested and removed question Further information is requested labels Oct 21, 2026

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.

Require Node.js 22+ (LTS) and drop the Node 20 workarounds

1 participant