Skip to content

Chore: bump @types/node off the end-of-life Node 20 line - #444

Open
AmaadMartin wants to merge 1 commit into
mainfrom
fix/types-node-22-lts
Open

Chore: bump @types/node off the end-of-life Node 20 line#444
AmaadMartin wants to merge 1 commit into
mainfrom
fix/types-node-22-lts

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:

Problem: The root package.json and dev/package.json both pinned "@types/node": "^20.12.7", which resolved to 20.19.43. Per the public nodejs/Release schedule, the Node 20 "Iron" line ended on 2026-04-30 — it is already past end-of-life, not approaching it.

So the compiler was checking all 531 TypeScript files in the repo against an API surface older than the runtime that executes them. Two silent defects follow, and the second is the dangerous one:

  1. False negatives — APIs the runtime has but the Node 20 typings omit fail to compile, so they get worked around or never used.
  2. False positives — anything Node removed or changed after 20 is still described as present, so the compiler blesses a call the runtime no longer honours.

There is no runtime symptom today. The defect is that the type checker is checking against the wrong contract, and the gap widens every release.

Solution: Move both manifests to ^22.20.1 (highest published 22.x) and regenerate package-lock.json. Three files, no source changes, no behaviour change.

Why 22 and not 24 or 26. @types/node must describe the oldest supported runtime, never the newest — typings ahead of the runtime re-create this exact defect pointing the other way. From the same public schedule: v20 ended 2026-04-30, so v22 "Jod" is now the oldest line still supported (maintenance until 2027-04-30). v24 is the newer active LTS; v26 is not an LTS line yet.

22 is also the oldest Node that CI actually executes — I measured this rather than assuming it, and the runner images have quietly diverged. Neither workflow pins a Node version (actions/setup-node@v6 with no node-version and no node-version-file), so every leg takes whatever its runner image ships. Read out of the CI logs for this very PR:

leg Node version actually used
run-tests (ubuntu-latest) v22.23.1
run-tests (windows-latest) v22.23.1
run-tests (macos-latest) v24.18.0
Cross-Language Tests (macos-latest) v24.18.0

So CI is simultaneously running two different Node majors depending on the OS image, and the oldest of them is 22.23.1. Typing against 22 covers every leg; typing against 24 would not. This drift is a direct consequence of not pinning node-version — out of scope here, and already the subject of separate open PRs. Local verification ran on Node v22.22.2.

Choosing 22 over 24 is not just policy — it is empirically forced. I measured it (see the falsification section below): @types/node@24.13.3 breaks npm run build with a real, pre-existing latent bug in this repo:

core/src/telemetry/tracing.ts(359,3): error TS2741: Property '[Symbol.asyncDispose]'
is missing in type '{ next: ...; return: ...; throw: ...; [Symbol.asyncIterator](): ... }'
but required in type 'AsyncGenerator<T, void, void>'.

That is a hand-rolled iterator object literal that does not implement the full async-generator protocol; @types/node@24 ships the AsyncDisposable globals that make TypeScript demand it. Fixing that is out of scope for a dependency bump and would need a real code change plus a test, so it has been queued as its own task. Staying inside 22.x also keeps undici-types at ~6.21.0 (unchanged), so the fetch/Headers/FormData/Response globals under the eight fetch() call sites are untouched.

Scope notes.

  • @types/node is a devDependency in both manifests, is absent from core's and dev's dependencies/peerDependencies, and is not in either package's files. Nothing changes for consumers of the published packages, and no emitted JavaScript changes because no source file changes.

  • core/package.json and integrations/package.json do not declare @types/node; they consume the hoisted root copy, so the ambient type surface moves for all four workspaces even though only two manifests change.

  • The chore: subject is deliberate and does not match the fix/... branch name. release-please keys off the commit subject, and a fix:/feat: subject would open a release PR bumping @google/adk, @google/adk-devtools and @google/adk-integrations in lockstep for a devDependency change that ships nothing.

  • node_modules/mariadb declares its own @types/node: ^24.x and keeps a nested copy. That is expected and was left alone — no overrides block was added.

  • Lockfile regeneration — note on how it was produced. The lockfile delta is exactly 3 hunks / 10 lines: the two devDependencies range lines and the node_modules/@types/node entry. Everything else is untouched (1174 entries before and after, none added or removed, lockfileVersion 3 unchanged, undici-types still ~6.21.0), and every added resolved URL points at registry.npmjs.org.

    It was regenerated by npm install from the base lockfile with a modern npm (11.18.0) — deliberately, and worth recording because an earlier attempt got this wrong. Regenerating with an older npm (9.2.x) drops the "license": "MIT" field from the rewritten entry: on a clean install that npm writes no license metadata at all, and on an incremental install it preserves existing entries while writing the newly-resolved one without it. The result looks like a legitimate npm rewrite but silently deletes metadata that 1173 of the lockfile's entries carry. Regenerating under npm 11 — the npm that ships with the Node 24 on the macOS runner — reproduces the entry in full, license included.

  • No engines field, no .nvmrc, and no workflow node-version pin were added — those are separate queued tasks, deliberately out of scope here.

Collision check (required before implementation): gh pr list --repo AmaadMartin/adk-js --state open --limit 100 returned 100 open PRs; I diffed every plausibly adjacent one (#443, #428, #416, #406, #383, #382, #425, #360, #345). No open PR bumps @types/node. Findings: #382/#360/#425 touch the same manifests/lockfile but on unrelated lines (@types/express, root test deps, removing gts) — textual overlap only, so I branched from main rather than stacking arbitrarily on one of three. #383 adds "@types/node": "^20.12.7" to two CommonJS integration fixture manifests (tests/integration/build_setup/ts_commonjs*/package.json) — different files, outside this task's three-file scope, but if both land those fixtures will still pin the EOL line and should be followed up. Re-checked before the final push: #445 ("declare a supported Node.js range on the published packages") has since opened and touches dev/package.json and package-lock.json too, but on the engines field rather than @types/node — again textual overlap only, no competing implementation. It is the complementary half of this work: it establishes the declared floor, while this PR moves the typings onto a supported line.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[ ] I have added or updated unit tests for my change. — deliberately not done; see below. Zero new/modified production lines, so there is nothing to unit test.
[x] All unit tests pass locally. — with one caveat: 2573 passed, 1 failed, and that single failure is pre-existing on main, environmental, and identical before and after this change (detailed below).

No new unit tests were added, deliberately. There are zero new or modified lines of production code, so new-line coverage is vacuous. A test asserting a version string in package.json would test npm, not ADK, and is review noise. The proof for a typings-only bump is that the entire existing diagnostic surface and test suite are unchanged — so the evidence below is a before/after equivalence proof rather than a new assertion.

Baseline captured first, on unmodified main, then re-measured after the bump:

check @types/node 20.19.43 (before) @types/node 22.20.1 (after)
npm install exit 0 exit 0
npm run build exit 0 exit 0
npx tsc --noEmit --pretty false (post-build) exit 2, 281 errors exit 2, 281 errors — byte-identical
npm run lint exit 0 exit 0
npm run format:check exit 0 exit 0
npm run docs:check exit 0 exit 0
npx secretlint "**/*" exit 0
vitest run --project unit:core --project unit:dev 2573 passed, 1 failed (182 files) 2573 passed, 1 failed — same test

The acceptance criterion is diff /tmp/tsc-before.log /tmp/tsc-after.log being empty, and it is:

$ diff /tmp/tsc-before.log /tmp/tsc-after.log && echo "TSC_IDENTICAL"
TSC_IDENTICAL

About the 281 errors and the 1 failing test — both are pre-existing on main and unrelated to this change. I verified both against unmodified main before touching anything:

  • npm run ts:check is already red on main (it is not a CI step, which is how it stayed red). The 281 errors are the src-vs-dist duality: test files import both @google/adk (→ core/dist/types/*.d.ts) and ../../src/*.js, so TypeScript sees two nominally distinct copies of every class with a private member. The criterion here is "the diagnostic set is unchanged", not "zero errors" — I did not try to fix these.
  • The one failing unit test is dev/test/cli/cli_create_test.ts > createAgent > Interactive Mode > should handle Vertex AI selection with gcloud defaults. It fails identically before and after: it expects the stubbed project id gcloud-project but reads the developer machine's real gcloud default project. It is a local-environment leak, not a regression, and it does not reproduce on CI runners which have no gcloud config. I did not touch it.

Proving the check can actually fail (falsification). A green equivalence check is worthless if the harness cannot go red, so I mutated the one input that matters — the @types/node major — and confirmed both gates fire:

$ npm pkg set devDependencies.@types/node="^24.10.1" && npm install   # resolves 24.13.3
$ npm run build                       # exit 1  <- FAILS
core/src/telemetry/tracing.ts(359,3): error TS2741: Property '[Symbol.asyncDispose]' is missing ...
$ npx tsc --noEmit --pretty false     # 282 errors (baseline 281)
$ diff /tmp/tsc-before.log /tmp/tsc-node24.log
0a1
> core/src/telemetry/tracing.ts(359,3): error TS2741: ...

So the harness has real signal: a wrong bump is caught by both npm run build and the tsc equivalence diff, while 22.20.1 leaves both untouched. The tree was then restored and re-verified (build exit 0, tsc 281 errors, byte-identical to baseline, git status clean).

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

npm install && npm run build

# 1. the bump is real and hoisted to the single root copy
node -p "require('@types/node/package.json').version"   # -> 22.20.1
node -p "require('undici-types/package.json').version"  # -> 6.21.0 (unchanged)
grep '"@types/node"' package.json dev/package.json      # -> "^22.20.1" twice

# 2. scope is exactly three files
git diff --name-only   # package.json, dev/package.json, package-lock.json

# 3. no internal registry host leaked into the regenerated lockfile
git diff -- package-lock.json | grep '^+' | grep resolved | grep -v registry.npmjs.org   # empty

# 4. no suppression sneaked in
git diff | grep -nE '@ts-(expect-error|ignore)|eslint-disable|: any|as any|as never'     # empty

# 5. the dev CLI and dev server still run on the new typings
node dev/dist/esm/cli_entrypoint.js --help
node dev/dist/esm/cli_entrypoint.js api_server dev/samples --port 8137

All five were run. Results: 22.20.1 / 6.21.0 / ^22.20.1 twice; exactly three files; both greps empty; CLI --help exits 0 and prints the full command list. The dev server started and served the sample agents normally — this exercises express, node:http, node:fs and the agent loader, i.e. the parts of the Node surface the typings describe:

$ curl http://localhost:8137/list-apps
["agent_as_tool","agent_transfer_return","agent_with_tool","google_search_agent","loop_agent","parallel_agent","sequential_agent"]
HTTP=200

$ curl -X POST -H 'Content-Type: application/json' -d '{}' \
    http://localhost:8137/apps/loop_agent/users/u1/sessions
{"id":"52fae498-...","appName":"loop_agent","userId":"u1","state":{},"events":[],"lastUpdateTime":...}
HTTP=200

Cross-platform coverage. Everything measured locally was on Linux, and Windows is the leg most likely to expose a genuine difference (path- and fs-typing edges), so a green Linux run alone would not be sufficient. CI covers all three OS legs, and each run-tests leg runs the full gate: npm install, npx secretlint "**/*", npm run build, npm run test:coverage (statements 86 / branches 87 / functions 88 / lines 86), npm run lint, npm run format:check, npm run docs:check. Coverage did not move, as expected for a change with zero production-code lines.

The Windows leg is flaky on this branch, disclosed rather than buried. Across four CI runs of what is runtime-identical code, windows-latest went pass → fail → pass → fail, while ubuntu-latest, macos-latest and the cross-language leg passed every time. Two distinct failures appeared, and neither is a type error:

FAIL unit:core core/test/code_executors/unsafe_local_code_executor_test.ts
  > UnsafeLocalCodeExecutor > should execute shell code and return stdout
Error: Test timed out in 5000ms.
FAIL integration tests/integration/a2a/basic/a2a_agent_test.ts > A2A: Remote Agent Basic
[ADK CLI] Error starting API server: listen EACCES: permission denied ::1:49740
Error: CLI exited prematurely with code 1   (tests/integration/test_case_utils.ts:341)

The first is a 5s budget on a child-process spawn — the classic assertion a loaded Windows runner misses. The second is the OS refusing to bind an ephemeral port: Windows reserves blocks of ephemeral ports, and a pre-selected port landing in a reserved range fails EACCES (not EADDRINUSE).

Neither can be caused by this change, on two independent grounds:

  1. Same bytes, different outcome. The pass and the first fail ran the identical tree (git rev-parse <sha>^{tree} = 54dd3c84… for both commits), so nothing about the code differed.
  2. A typings bump has no runtime behaviour. @types/node is erased at compile time and vitest transpiles with esbuild without type checking, so the executed JavaScript is bit-identical. There is no mechanism by which it could affect a child-process timeout or a TCP bind.

I did not touch, skip, re-time, or .skip either test to get green — both are outside this PR's scope, and adjacent open PRs (#355, #373) already work in the first file. Both have been queued as their own tasks (the port one should bind port 0 and read the assigned port back, rather than pre-selecting).
I did not touch, skip, re-time, or .skip that test to get green — it is outside this PR's scope, and adjacent open PRs (#355, #373) are already working in that file. It has been queued as its own task instead.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[ ] I have commented my code, particularly in hard-to-understand areas. — n/a, no code changed; the rationale lives in this description and the commit message.
[ ] I have added tests that prove my fix is effective or that my feature works. — no tests added (see above). The equivalent evidence is the byte-identical tsc before/after proof, plus the falsification run showing that proof can go red.
[x] New and existing unit tests pass locally with my changes, with the one pre-existing environmental failure noted above.

@AmaadMartin
AmaadMartin force-pushed the fix/types-node-22-lts branch from 61848ff to c841d0d Compare August 1, 2026 09:26
The root and dev manifests both pinned "@types/node": "^20.12.7", which
resolved to 20.19.43. The Node 20 "Iron" line ended on 2026-04-30 per the
nodejs/Release schedule, so the compiler was checking every TypeScript file
in the repo against the API surface of a runtime that is no longer
supported. That hides two things: APIs the runtime has but the typings
omit, and - the dangerous direction - APIs removed after 20 that the
typings still describe as present.

Move both manifests to ^22.20.1, the highest published 22.x. Node 22 "Jod"
is the oldest line still supported (maintenance until 2027-04-30), and
@types/node should describe the oldest runtime we support rather than the
newest: typings ahead of the runtime re-create this same defect pointing
the other way, blessing calls that are absent at execution time.

22 is also the oldest runtime CI actually executes. Neither workflow pins a
Node version - both call actions/setup-node@v6 with no node-version - so
every leg takes whatever its runner image ships, and those images have
diverged: ubuntu-latest and windows-latest run v22.23.1 while macos-latest
runs v24.18.0. Typing against the oldest of those is the safe direction.

Note that 24 is not merely undesirable here, it does not currently build:
with @types/node@24 installed, core/src/telemetry/tracing.ts:359 fails with
TS2741 because a hand-rolled async iterator object literal does not
implement [Symbol.asyncDispose]. That is tracked separately and is out of
scope for a dependency bump.

Staying inside 22.x also keeps undici-types at ~6.21.0, so the
fetch/Headers/FormData/Response globals are untouched.

@types/node is a devDependency in both manifests and is not part of either
package's published dependency or type closure, so nothing changes for
consumers. No source file changes and no emitted JavaScript changes.

Verified: build, lint, format:check, docs:check and secretlint all exit 0,
and `tsc --noEmit` produces a byte-identical diagnostic set before and
after the bump (281 pre-existing errors, unchanged).
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