5.2 KiB
RFC: Lower the Node engines floor to 22.18
Status: implemented
Problem
The root engines.node was >=24, which excluded the entire Node 22 LTS line for no runtime reason. The harness has exactly two Node features whose availability gates the floor, and both are satisfied well below Node 24 — so the floor was higher than the code actually requires. Pinning it honestly widens the supported install base (Node 22 LTS is in service until 2027) without weakening any guarantee, provided CI proves the claim on the floor version rather than merely asserting it in a manifest.
Decision
Set engines.node to ^22.18.0 || >=24.0.0 (Node 22.18+ on the LTS line, or 24+) and test it on the keyless CI matrix ['22.18', 24, 26]. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor. Two Node features gate the range, each with its own LTS-line and Current-line unflag point:
node:sqlite—packages/session-persistence/session-persistence-sqlitedoes a top-levelimport { DatabaseSync } from 'node:sqlite'. The module dropped its--experimental-sqliteflag requirement at 22.13 (LTS) and 23.4 (Current); before those, importing it throws at load.- Native TypeScript type-stripping — the
packages/ui/stdio-agent/tests/built-bin.e2e.tssmoke boots the publishedlib/bin.jsunder plainnode(no tsx) and loads the example's.tsplugins (mock-llm.ts,echo-tool.ts). Type-stripping is the default from 22.18 (LTS) and 23.6 (Current); before those it needs--experimental-strip-types.
On the 22.x line both features clear at 22.18 (the later of 22.13/22.18), so ^22.18.0 is the LTS floor. The range is disjoint rather than an open >=22.18 because the Node 23.0–23.5 window still has at least one feature flagged (sqlite until 23.4, stripping until 23.6): >=22.18 would advertise support there, where the sqlite backend throws ERR_UNKNOWN_BUILTIN_MODULE at load. Node 23 is non-LTS and already end-of-life, so rather than carve out >=23.6 the range skips the whole line and resumes at >=24.0.0 — the same shape several of the repo's own dependencies already declare (^22.18.0 || >=24.11.0).
@types/node is pinned to the 22.x line (^22.20.0) to match the floor: reaching for a Node 23+/24+/25+ API then fails tsc on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only the 22.18 matrix leg could catch. The whole tree typechecks clean against the Node 22 type surface today, so the pin costs nothing.
Consequences
- The supported base widens to the Node 22 LTS line, and the
['22.18', 24, 26]matrix proves it on every push and PR rather than trusting the manifest. - The built-bin smoke needs no version-conditional flag: at 22.18 type-stripping is already the default, so the test stays the plain
node lib/bin.jspath it documents. - A future change reaching for a Node 23+ API fails
tscimmediately (the@types/nodepin); one reaching for an API added in 22.19/22.20 — inside the 22.x type surface but above the floor — is caught instead by the 22.18 matrix leg. Either way the floor must move in the same change. - The
vendor/hmrandvendor/loadercomments about Node 24 module-cache internals are unaffected — they describe dev-time HMR loader behavior, not the shipped runtime contract, and are pinned vendored source.
Alternatives considered
- Floor
>=22.13(thenode:sqliteboundary) plus--experimental-strip-typesin the built-bin smoke on 22.13–22.17. Rejected: it adds a version-conditional test flag for one narrow range and dresses up an experimental-flag dependency as first-class support. 22.18 clears both boundaries with zero test special-casing, and the five-patch gap below it buys nothing real. - Keep
>=24. Rejected: it excludes Node 22 LTS with no runtime justification once the two boundaries above are known. - Open-ended
>=22.18. Rejected: it advertises support for Node 23.0–23.5, wherenode:sqlite(until 23.4) or type-stripping (until 23.6) is still flagged, so the sqlite backend throws at load. The disjoint^22.18.0 || >=24.0.0matches the real runtime boundary. - Include Node 23.6+ (
^22.18.0 || >=23.6.0). Rejected: 23.6+ does run both features unflagged, but Node 23 is end-of-life — advertising a dead release line adds a range term (and, to back it, a CI leg) for a runtime no deployment should use. 24 is the meaningful resumption point, and the 22.18 and 24 legs already bracket the same unflagged code paths. - Matrix
[22, 24, 26](latest 22.x) instead of pinning22.18. Rejected: "latest 22.x" drifts upward over time and would silently stop exercising the declared floor. Pinning the floor version is what makes the matrix a proof of the claim rather than a proof of some newer 22.x. - Keep
@types/nodeahead of the floor (^25). Rejected: types ahead of the runtime floor let a Node 24/25-only API compile clean and fail only at runtime on 22.18 — exactly the "green types, broken product" gap. Pinning@types/nodeto the 22.x line turns that into a compile error everywhere, and the tree already typechecks clean against the Node 22 surface, so the pin is free.