From 7e168dd32ee22163b9a26b3788e13182be870a22 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 23 Jul 2026 15:36:55 -0400 Subject: [PATCH] feat!: drop support for Node.js 20, require 22.12.0+ Node.js 20 reached End-of-Life (EOL) in April 2026, three months ago. We require 22.12.0+ specifically because that version added `require(esm)` support. Many of our own packages that Netlify CLI depends on as well as much of the community has been requiring 22.12.0+ in order to modernize and slim down dependency trees. --- .github/test-matrix.yml | 2 +- CLAUDE.md | 2 +- README.md | 2 +- e2e/install.e2e.ts | 10 +++------- package-lock.json | 13 ++++++------- package.json | 4 ++-- src/commands/blobs/blobs-set.ts | 2 +- src/utils/nodejs-compile-cache.ts | 6 ++++-- src/utils/proxy.ts | 2 +- tests/integration/commands/dev/dev.config.test.ts | 4 +--- tests/integration/commands/dev/v2-api.test.ts | 5 +---- tests/integration/telemetry.test.ts | 5 +++-- tsconfig.base.json | 2 +- 13 files changed, 26 insertions(+), 33 deletions(-) diff --git a/.github/test-matrix.yml b/.github/test-matrix.yml index 903e4a71937..8fbe138a0f3 100644 --- a/.github/test-matrix.yml +++ b/.github/test-matrix.yml @@ -2,7 +2,7 @@ # Node.js versions tested on Linux in every PR's test matrix. # The minimum version should stay aligned with package.json `engines.node`. # Used by: unit-tests.yml, integration-tests.yml, e2e-tests.yml -node_versions: ['20.12.2', '22', '24'] +node_versions: ['22.13.0', '24'] # Pin specific install versions for entries in `node_versions` / `primary_node_version`. node_install_overrides: diff --git a/CLAUDE.md b/CLAUDE.md index 973e4eefab9..9fda070ae3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ The CLI integrates with Netlify's build plugin system, allowing plugins to: - Provide additional CLI commands ### Development Setup Requirements -- Node.js 20.12.2+ required +- Node.js 22.13.0+ required - Git LFS must be installed for full test suite - Some integration tests require Netlify Auth Token (`NETLIFY_AUTH_TOKEN`) or login via `./bin/run.js login` diff --git a/README.md b/README.md index 2ade1040131..da11d76d8a9 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ See the [CLI command line reference](https://cli.netlify.com/#commands) to get s ## Installation -Netlify CLI requires [Node.js](https://nodejs.org) version 20.12.2 or above. To install, run the following command from +Netlify CLI requires [Node.js](https://nodejs.org) version 22.13.0 or above. To install, run the following command from any directory in your terminal: ```bash diff --git a/e2e/install.e2e.ts b/e2e/install.e2e.ts index 4570b2ef9f2..a017c4a766e 100644 --- a/e2e/install.e2e.ts +++ b/e2e/install.e2e.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs' import fs from 'node:fs/promises' import { platform } from 'node:os' import path from 'node:path' +import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import process from 'node:process' @@ -15,10 +16,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import createDebug from 'debug' import picomatch from 'picomatch' -import pkg from '../package.json' - const __dirname = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(__dirname, '..') +const pkg = createRequire(import.meta.url)('../package.json') as { version: string } const distDir = path.join(projectRoot, 'dist') const tempdirPrefix = 'netlify-cli-e2e-test--' @@ -215,11 +215,7 @@ const installTests: [packageManager: string, config: InstallTest][] = [ ] describe.each(installTests)('%s → installs the cli and runs commands without errors', (packageManager, config) => { - // Yarn v1 enforces engine constraints strictly. A transitive dep (chokidar@5) requires node >=20.19.0, - // breaking yarn installs on older Node 20.x. Node 20 EOL is April 2026, so we skip rather than override. - const yarnOnOldNode20 = packageManager === 'yarn' && process.versions.node === '20.12.2' - - it.skipIf(yarnOnOldNode20)('runs the commands without errors', async () => { + it('runs the commands without errors', async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), tempdirPrefix)) try { diff --git a/package-lock.json b/package-lock.json index f26ee5eecf1..af3213be3e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,7 +119,7 @@ "@netlify/functions": "^5.3.0", "@netlify/types": "^2.8.0", "@sindresorhus/slugify": "^3.0.0", - "@tsconfig/node20": "20.1.0", + "@tsconfig/node22": "22.0.5", "@tsconfig/recommended": "^1.0.13", "@types/backoff": "^2.5.5", "@types/content-type": "^1.1.9", @@ -177,7 +177,7 @@ "vitest": "^3.2.4" }, "engines": { - "node": ">=20.12.2" + "node": ">=22.12.0" } }, "node_modules/@ampproject/remapping": { @@ -6131,7 +6131,6 @@ }, "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "version": "1.1.0", - "extraneous": true, "inBundle": true, "license": "MIT" }, @@ -6735,10 +6734,10 @@ "version": "1.0.4", "license": "MIT" }, - "node_modules/@tsconfig/node20": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.0.tgz", - "integrity": "sha512-7Yz1xDv3sot8+NwiYA8wkx60qr3Tr+soJwlqVQSkiEqqgYoNmMmqSPiYs1vMmTxneB3igHAD11dGOR2+BShzTA==", + "node_modules/@tsconfig/node22": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz", + "integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index dad8d1bda90..254d6c334d4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Netlify Inc.", "type": "module", "engines": { - "node": ">=20.12.2" + "node": ">=22.13.0" }, "files": [ "/bin", @@ -160,7 +160,7 @@ "@netlify/functions": "^5.3.0", "@netlify/types": "^2.8.0", "@sindresorhus/slugify": "^3.0.0", - "@tsconfig/node20": "20.1.0", + "@tsconfig/node22": "22.0.5", "@tsconfig/recommended": "^1.0.13", "@types/backoff": "^2.5.5", "@types/content-type": "^1.1.9", diff --git a/src/commands/blobs/blobs-set.ts b/src/commands/blobs/blobs-set.ts index 9378007c9b0..66b34d21d6c 100644 --- a/src/commands/blobs/blobs-set.ts +++ b/src/commands/blobs/blobs-set.ts @@ -33,7 +33,7 @@ export const blobsSet = async ( if (input) { const inputPath = resolve(input) try { - value = await fs.readFile(inputPath) + value = new Uint8Array(await fs.readFile(inputPath)).buffer } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { return logAndThrowError( diff --git a/src/utils/nodejs-compile-cache.ts b/src/utils/nodejs-compile-cache.ts index 87b5cf3bb8f..e2c9864c5bf 100644 --- a/src/utils/nodejs-compile-cache.ts +++ b/src/utils/nodejs-compile-cache.ts @@ -16,8 +16,9 @@ export let didEnableCompileCache = false * Keep in mind that the cache is specific to a version of netlify-cli and a version of node.js and it is stored on the * user's disk in a temp dir. If any of these changes or the temp dir is cleared, the next run is a cold run. * - * The programmatic API to enable this (`enableCompileCache()`) was added in node 22.8.0, but we currently support - * >=20.12.2, hence the conditional below. (For completeness, note that support via the env var was added in 22.1.0.) + * The programmatic API to enable this (`enableCompileCache()`) was added in node 22.8.0, below our minimum supported + * version, so the conditional below is defensive rather than strictly necessary. (For completeness, note that support + * via the env var was added in 22.1.0.) * * The Netlify CLI is often used in CI workflows. In this context, we wouldn't want the overhead of the first run * because we almost certainly would not get any benefits on "subsequent runs". Even if the user has configured caching @@ -30,6 +31,7 @@ export const maybeEnableCompileCache = (): void => { // The docs recommend turning this off when running tests to generate precise coverage if (process.env.NODE_ENV === 'test') return + // TODO(serhalp): Consider enabling unconditionally now that we require Node.js 22.13.0+. // eslint-disable-next-line n/no-unsupported-features/node-builtins if ('enableCompileCache' in module && typeof module.enableCompileCache === 'function') { // eslint-disable-next-line n/no-unsupported-features/node-builtins diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 22245c4494a..ac65ffabae6 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -711,7 +711,7 @@ const initializeProxy = async function ({ proxyRes.on('end', async function onEnd() { // @ts-expect-error TS(7005) FIXME: Variable 'responseData' implicitly has an 'any[]' ... Remove this comment to see the full error message - let responseBody = Buffer.concat(responseData) + let responseBody: Buffer = Buffer.concat(responseData) let responseStatus = options.status || proxyRes.statusCode diff --git a/tests/integration/commands/dev/dev.config.test.ts b/tests/integration/commands/dev/dev.config.test.ts index e6aa6984b68..3fe04869e3f 100644 --- a/tests/integration/commands/dev/dev.config.test.ts +++ b/tests/integration/commands/dev/dev.config.test.ts @@ -1,12 +1,10 @@ import { Buffer } from 'node:buffer' -import { version } from 'node:process' import events from 'node:events' import type { HandlerEvent } from '@netlify/functions' import FormData from 'form-data' import getPort from 'get-port' import fetch from 'node-fetch' -import { gte } from 'semver' import { describe, test } from 'vitest' import { withDevServer } from '../../utils/dev-server.js' @@ -459,7 +457,7 @@ describe.concurrent('commands/dev/config', () => { }) }) - test.runIf(gte(version, '20.12.2'))('should support functions with streaming responses', async (t) => { + test('should support functions with streaming responses', async (t) => { await withSiteBuilder(t, async (builder) => { builder .withPackageJson({ packageJson: { dependencies: { '@netlify/functions': 'latest' } } }) diff --git a/tests/integration/commands/dev/v2-api.test.ts b/tests/integration/commands/dev/v2-api.test.ts index a137f21e718..6585f5561f8 100644 --- a/tests/integration/commands/dev/v2-api.test.ts +++ b/tests/integration/commands/dev/v2-api.test.ts @@ -1,7 +1,4 @@ -import { version } from 'process' - import execa from 'execa' -import { gte } from 'semver' import { describe, test } from 'vitest' import { FixtureTestContext, setupFixtureTests } from '../../utils/fixture.js' @@ -26,7 +23,7 @@ const setup = async ({ fixture }: { fixture: { directory: string } }) => { await execa('npm', ['install'], { cwd: fixture.directory }) } -describe.runIf(gte(version, '20.12.2')).concurrent('v2 api', async () => { +describe.concurrent('v2 api', async () => { await setupFixtureTests('dev-server-with-v2-functions', { devServer: true, mockApi: { routes }, setup }, () => { test('should successfully be able to run v2 functions', async ({ devServer, expect }) => { const response = await fetch(`http://localhost:${devServer!.port}/.netlify/functions/ping`) diff --git a/tests/integration/telemetry.test.ts b/tests/integration/telemetry.test.ts index 2ec6462b225..6e6ab698781 100644 --- a/tests/integration/telemetry.test.ts +++ b/tests/integration/telemetry.test.ts @@ -1,16 +1,17 @@ +import { createRequire } from 'node:module' import { env as _env, version as nodejsVersion } from 'process' import type { Options } from 'execa' import execa from 'execa' import { expect, test } from 'vitest' -import pkg from '../../package.json' - import { callCli } from './utils/call-cli.js' import { cliPath } from './utils/cli-path.js' import { MockApiTestContext, withMockApi } from './utils/mock-api-vitest.js' import { withSiteBuilder } from './utils/site-builder.js' +const pkg = createRequire(import.meta.url)('../../package.json') as { name: string; version: string } + const getCLIOptions = (apiUrl: string): Options => ({ env: { NETLIFY_TEST_TRACK_URL: `${apiUrl}/track`, diff --git a/tsconfig.base.json b/tsconfig.base.json index d213ccc0ced..8b6185e1d7e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,5 +1,5 @@ { - "extends": ["@tsconfig/recommended", "@tsconfig/node20"], + "extends": ["@tsconfig/recommended", "@tsconfig/node22"], "compilerOptions": { "allowJs": true, "downlevelIteration": true,