From 341c98829f32f7d2689e2206e5fac6474e73a6a6 Mon Sep 17 00:00:00 2001 From: johnnyreilly Date: Tue, 10 Nov 2026 18:07:32 +0000 Subject: [PATCH] feat: lower minimum Node.js to 16.20, matching TypeScript 7.1 (#1721) TypeScript 7.1 is ESM-only and needs Node >=16.20. ts-loader loaded it with require(), which only works on Node versions with require(esm) support, so it now loads `/sync` and `/fs` with import() instead (once per compiler package, awaited at the top of the already-async loader). The comparison/execution harnesses can't run on Node 16, so a new framework-free smoke test (test/minimum-node-tests) runs in CI on Node 16.20.0 after installing and building on Node 26. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 + .github/workflows/minimum-node-tests.yml | 27 +++++++ AGENTS.md | 7 ++ CHANGELOG.md | 4 + README.md | 4 +- package.json | 3 +- src/index.ts | 24 +++++- src/typeScriptApi.ts | 80 +++++++++++++------ src/types.ts | 9 +++ test/minimum-node-tests/clean/app.ts | 3 + test/minimum-node-tests/clean/greet.ts | 3 + test/minimum-node-tests/clean/tsconfig.json | 7 ++ test/minimum-node-tests/run-tests.js | 70 ++++++++++++++++ test/minimum-node-tests/typeError/app.ts | 5 ++ .../typeError/tsconfig.json | 7 ++ 15 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/minimum-node-tests.yml create mode 100644 test/minimum-node-tests/clean/app.ts create mode 100644 test/minimum-node-tests/clean/greet.ts create mode 100644 test/minimum-node-tests/clean/tsconfig.json create mode 100644 test/minimum-node-tests/run-tests.js create mode 100644 test/minimum-node-tests/typeError/app.ts create mode 100644 test/minimum-node-tests/typeError/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9365dad3e..f4479eb80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: name: execution tests uses: ./.github/workflows/execution-tests.yml + minimum-node-tests: + name: minimum node tests + uses: ./.github/workflows/minimum-node-tests.yml + lint: name: lint uses: ./.github/workflows/lint.yml diff --git a/.github/workflows/minimum-node-tests.yml b/.github/workflows/minimum-node-tests.yml new file mode 100644 index 000000000..27d0a7f39 --- /dev/null +++ b/.github/workflows/minimum-node-tests.yml @@ -0,0 +1,27 @@ +name: minimum node tests + +on: + workflow_call: + +jobs: + minimum_node_tests: + name: node 16.20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Dev dependencies need newer Node, so install and build on 26... + - uses: actions/setup-node@v7 + with: + node-version: 26 + cache: npm + + - uses: ./.github/actions/build-ts-loader + + # ...then run ts-loader itself on the minimum Node from package.json `engines`. + - uses: actions/setup-node@v7 + with: + node-version: 16.20.0 + + - name: test + run: node --version && node test/minimum-node-tests/run-tests.js diff --git a/AGENTS.md b/AGENTS.md index 2e109d1f2..8ed7d2489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ npm run check:benchmark-tests # type-check the benchmark scripts npm test # full test suite (comparison + execution tests) npm run comparison-tests # fast subset: compare webpack output against snapshots npm run execution-tests # build each test for Node and run its Jasmine specs +npm run minimum-node-tests # smoke test meant to run on the minimum Node in `engines` npm run benchmark # compare this build's compile speed against another ts-loader checkout ``` @@ -111,6 +112,12 @@ npm run execution-tests -- --single-test # one test npm run execution-tests -- --single-test --watch # rebuild and re-run on changes ``` +## Minimum Node tests (`test/minimum-node-tests/`) + +`package.json` `engines` matches TypeScript 7.1's minimum (Node 16.20), but the comparison/execution harnesses need newer Node (Jasmine needs `structuredClone`; the comparison harness `require`s the ESM-only `typescript/sync`). This smoke test builds two tiny projects with webpack + ts-loader, runs the clean bundle and checks a type error is reported - with no test framework, so it runs on the minimum Node. `.github/workflows/minimum-node-tests.yml` installs and builds on Node 26, then runs it on Node 16.20.0. + +This is also why `src/` loads TypeScript via `import()`, not `require()`: Node < 20.19 can't `require()` an ES module. + ## Testing on Windows CI macOS/Linux passing locally does **not** mean Windows passes — this codebase has repeatedly had Windows-only comparison-test failures with no macOS/Linux equivalent. The recurring root cause: `src/typeScriptApi.ts` calls into the TypeScript API (`typescript/sync`), whose `program.getSourceFileNames()`/`getConfigFileNames()`/internal lookups return forward-slash-normalized paths on every OS (the long-standing TS compiler convention), while Node's own `path.resolve`/`path.join`/`path.normalize` return OS-native (backslash on Windows) paths. Any place that builds a path with Node's `path` module and then compares it against, or hands it to, something expecting the API's own spelling — a `Set`/`Map` keyed by API-returned names, or webpack's `addDependency`/`addBuildDependency` (which rejects non-native absolute paths) — silently breaks on Windows only. When investigating a new Windows-only failure, check for this pattern first (search for `path.resolve`/`path.join`/`path.normalize` near any `program.get*`/`toComparablePath` usage in `src/typeScriptApi.ts`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1da7937..2e75e0f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- [Lower the minimum Node.js version to 16.20, matching TypeScript 7.1](https://github.com/TypeStrong/ts-loader/issues/1721) - ts-loader now loads TypeScript with `import()` rather than `require()`, so it no longer needs a Node.js version with `require(esm)` support. + ## 10.0.0-beta.0 - [Support TypeScript 7.1 API](https://github.com/TypeStrong/ts-loader/pull/1704) - thanks @johnnyreilly diff --git a/README.md b/README.md index e6f85b790..559c9d49d 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ For a complete working setup, see [examples/simple](examples/simple/). - TypeScript: 7.1+ - webpack: 4.x and 5.x -- Node.js: 22+ +- Node.js: 16.20+ (the same minimum as TypeScript 7.1) -The test suite runs on every pull request on Linux, macOS and Windows, against TypeScript 7.1 and TypeScript@next, on Node.js 22, 24 and 26. If you find a problem the tests don't catch, please [open an issue](https://github.com/TypeStrong/ts-loader/issues), or better yet, send a PR with a test. +The test suite runs on every pull request on Linux, macOS and Windows, against TypeScript 7.1 and TypeScript@next, on Node.js 22, 24 and 26, plus a smoke test on Node.js 16.20. If you find a problem the tests don't catch, please [open an issue](https://github.com/TypeStrong/ts-loader/issues), or better yet, send a PR with a test. ## Guides diff --git a/package.json b/package.json index 9b8c0f970..2c8845f24 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "tsc --project \"./src\" --noEmit && oxlint src", "comparison-tests": "git clean -xfd test/comparison-tests && node test/comparison-tests/link-test-lib.js && node test/comparison-tests/run-tests.js", "execution-tests": "git clean -xfd test/execution-tests && node test/execution-tests/run-tests.js", + "minimum-node-tests": "node test/minimum-node-tests/run-tests.js", "test": "git clean -xfd test/comparison-tests && git clean -xfd test/execution-tests && node test/run-tests.js", "benchmark": "node test/benchmark-tests/run-benchmark.mts", "clean": "git clean -xfd test/comparison-tests && git clean -xfd test/execution-tests", @@ -37,7 +38,7 @@ "ts" ], "engines": { - "node": ">=22.0.0" + "node": ">=16.20.0" }, "author": "John Reilly (https://johnnyreilly.com)", "contributors": [ diff --git a/src/index.ts b/src/index.ts index 0f7803ddc..c3112a596 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { import { createTypeScriptInstance, getTypeScriptEmit, + loadTypeScriptModules, recheckAllTransitiveDependants, } from './typeScriptApi'; import type { @@ -21,6 +22,7 @@ import type { ResolvedFilePathCache, TSFile, TSInstance, + TypeScriptModules, } from './types'; import * as loaderUtils from './loaderUtils'; import { getTSInstanceFromCache, setTSInstanceInCache } from './instance-cache'; @@ -39,7 +41,7 @@ const canUseSourceMapConsumer = const loaderOptionsCache: LoaderOptionsCache = {}; -function loader( +async function loader( this: webpack.LoaderContext, contents: string, inputSourceMap?: Record, @@ -47,11 +49,21 @@ function loader( // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.cacheable && this.cacheable(); const callback = this.async(); + let typeScriptModules: TypeScriptModules; + try { + typeScriptModules = await loadTypeScriptModules( + loaderUtils.getOptions(this).compiler ?? 'typescript', + ); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + return; + } const configParseErrorMessage = successLoader( this, contents, inputSourceMap, callback, + typeScriptModules, ); if (configParseErrorMessage !== undefined) { // Reconstructed here, in `loader`'s own frame, so the stack trace @@ -65,10 +77,15 @@ function successLoader( contents: string, inputSourceMap: Record | undefined, callback: ReturnType['async']>, + typeScriptModules: TypeScriptModules, ): string | undefined { try { const options = getLoaderOptions(loaderContext); - const instance = getTypeScriptInstance(options, loaderContext); + const instance = getTypeScriptInstance( + options, + loaderContext, + typeScriptModules, + ); const rawFilePath = path.normalize(loaderContext.resourcePath); const filePath = appendSuffixesIfRequired(rawFilePath, options); const fileVersion = updateFileInCache(filePath, contents, instance); @@ -99,6 +116,7 @@ function successLoader( function getTypeScriptInstance( loaderOptions: LoaderOptions, loader: webpack.LoaderContext, + typeScriptModules: TypeScriptModules, ) { const existing = getTSInstanceFromCache( loader._compiler!, @@ -141,7 +159,7 @@ function getTypeScriptInstance( files, resolvedFilePathCache, typeScriptApiInstance: createTypeScriptInstance( - loaderOptions, + typeScriptModules, configFilePath, useCaseSensitiveFileNames, ), diff --git a/src/typeScriptApi.ts b/src/typeScriptApi.ts index ff49dfad9..41c1a7ea1 100644 --- a/src/typeScriptApi.ts +++ b/src/typeScriptApi.ts @@ -1,10 +1,9 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; +import { pathToFileURL } from 'url'; import type * as webpack from 'webpack'; import type { - API, - APIOptions, CompilerOptions, CreateSnapshotParams, Diagnostic, @@ -15,7 +14,6 @@ import type { SyntheticProjectId, } from 'typescript/sync'; import type { StringLiteralLikeNode } from 'typescript/ast'; -import { createFileSystemLayer } from 'typescript/fs'; import * as constants from './constants'; import { @@ -30,28 +28,76 @@ import type { PendingDeclarationFile, SyntheticProgram, TypeScriptInstance, + TypeScriptModules, TSInstance, ResolvedFilePathCache, } from './types'; import { isWebpack5 } from './loaderUtils'; -interface TypeScriptApiModule { - API: new (options?: APIOptions) => API; +const typeScriptModulesCache = new Map>(); + +export function loadTypeScriptModules( + compilerPackage: string, +): Promise { + let typeScriptModules = typeScriptModulesCache.get(compilerPackage); + if (typeScriptModules === undefined) { + typeScriptModules = importTypeScriptModules(compilerPackage); + typeScriptModulesCache.set(compilerPackage, typeScriptModules); + typeScriptModules.catch(() => + typeScriptModulesCache.delete(compilerPackage), + ); + } + return typeScriptModules; +} + +async function importTypeScriptModules( + compilerPackage: string, +): Promise { + const [syncModule, fsModule] = await Promise.all([ + importCompilerEntryPoint>( + `${compilerPackage}/sync`, + ), + importCompilerEntryPoint>( + `${compilerPackage}/fs`, + ), + ]); + return { + API: syncModule.API, + createFileSystemLayer: fsModule.createFileSystemLayer, + }; +} + +/** + * TypeScript 7 is ESM-only; `import()` rather than `require()` lets Node + * versions without `require(esm)` (< 20.19) load it. Resolved via + * `require.resolve` first so `compiler` resolves exactly as it always has, + * Windows absolute paths included. + */ +async function importCompilerEntryPoint(specifier: string): Promise { + try { + return (await import(pathToFileURL(require.resolve(specifier)).href)) as T; + } catch (error) { + throw new Error( + `Could not load the TypeScript API from "${specifier}": ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } } export function createTypeScriptInstance( - loaderOptions: LoaderOptions, + typeScriptModules: TypeScriptModules, configFilePath: string, useCaseSensitiveFileNames: boolean, ): TypeScriptInstance { - const typeScriptApiModule = loadTypeScriptApiModule(loaderOptions.compiler); // No `fs` callbacks: the API reads disk directly, with webpack's content // layered on top per file (see layerFileContents). Case sensitivity is // passed explicitly so the API agrees with `resolvedFilePathCache`. - const api = new typeScriptApiModule.API({ useCaseSensitiveFileNames }); + const api = new typeScriptModules.API({ useCaseSensitiveFileNames }); return { api, + createFileSystemLayer: typeScriptModules.createFileSystemLayer, configFilePath: path.resolve(configFilePath), transpileConfig: undefined, orphanRootFileNames: new Map(), @@ -500,20 +546,6 @@ function computeDeclarationFilePath( return path.join(outDirLikeOption, relativeToRoot); } -function loadTypeScriptApiModule(compilerPackage: string): TypeScriptApiModule { - const specifier = `${compilerPackage}/sync`; - - try { - return require(specifier) as TypeScriptApiModule; - } catch (error) { - throw new Error( - `Could not load the TypeScript API from "${specifier}": ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } -} - type SnapshotChanges = Pick< CreateSnapshotParams, | 'openProjects' @@ -710,7 +742,9 @@ function layerFileContents( contents: string, ): Snapshot { const fileLayer: SnapshotChanges = { - fileSystem: createFileSystemLayer([[fileName, contents]]), + fileSystem: typeScriptInstance.createFileSystemLayer([ + [fileName, contents], + ]), ensurePrograms: true, }; if ( diff --git a/src/types.ts b/src/types.ts index d25a28433..c240b9ca0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,13 @@ import type { Colors } from './colors'; import type { API, + APIOptions, Diagnostic, ParsedCommandLine, Snapshot, SyntheticProjectId, } from 'typescript/sync'; +import type { createFileSystemLayer } from 'typescript/fs'; import type * as webpack from 'webpack'; export interface ErrorInfo { @@ -80,8 +82,15 @@ export interface SyntheticProgram { configuration: string; } +/** The parts of the TypeScript package ts-loader uses at runtime. */ +export interface TypeScriptModules { + API: new (options?: APIOptions) => API; + createFileSystemLayer: typeof createFileSystemLayer; +} + export interface TypeScriptInstance { api: API; + createFileSystemLayer: TypeScriptModules['createFileSystemLayer']; /** Real-cased, as given to the API - not a canonical key (see FilePath). */ configFilePath: string; transpileConfig?: ParsedCommandLine; diff --git a/test/minimum-node-tests/clean/app.ts b/test/minimum-node-tests/clean/app.ts new file mode 100644 index 000000000..e56be089b --- /dev/null +++ b/test/minimum-node-tests/clean/app.ts @@ -0,0 +1,3 @@ +import { greet } from './greet'; + +export default greet('ts-loader'); diff --git a/test/minimum-node-tests/clean/greet.ts b/test/minimum-node-tests/clean/greet.ts new file mode 100644 index 000000000..b370deafb --- /dev/null +++ b/test/minimum-node-tests/clean/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `Hello, ${name}!`; +} diff --git a/test/minimum-node-tests/clean/tsconfig.json b/test/minimum-node-tests/clean/tsconfig.json new file mode 100644 index 000000000..e3d3ff49f --- /dev/null +++ b/test/minimum-node-tests/clean/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2016", + "strict": true + } +} diff --git a/test/minimum-node-tests/run-tests.js b/test/minimum-node-tests/run-tests.js new file mode 100644 index 000000000..51168a166 --- /dev/null +++ b/test/minimum-node-tests/run-tests.js @@ -0,0 +1,70 @@ +// Smoke test for the oldest Node version in package.json `engines`, which the +// comparison/execution test harnesses can't run on. Builds a tiny project with +// webpack + ts-loader, runs the bundle, and checks type errors are reported. +const assert = require('assert'); +const path = require('path'); +const webpack = require('webpack'); + +/** @param {string} project */ +function build(project) { + const outputPath = path.join(__dirname, project, 'dist'); + return new Promise((resolve, reject) => { + const compiler = webpack({ + mode: 'development', + devtool: false, + context: __dirname, + entry: `./${project}/app.ts`, + target: 'node', + output: { + path: outputPath, + filename: 'bundle.js', + libraryTarget: 'commonjs2', + }, + resolve: { extensions: ['.ts', '.js'] }, + module: { rules: [{ test: /\.ts$/, loader: 'ts-loader' }] }, + resolveLoader: { + alias: { 'ts-loader': path.join(__dirname, '../../index.js') }, + }, + }); + compiler.run((error, stats) => { + compiler.close(() => { + if (error) reject(error); + else { + const { errors } = stats.toJson({ all: false, errors: true }); + resolve({ + outputPath, + errors: errors.map(e => (typeof e === 'string' ? e : e.message)), + }); + } + }); + }); + }); +} + +async function main() { + console.log(`Running minimum Node tests on Node ${process.version}`); + + const { outputPath, errors } = await build('clean'); + assert.deepStrictEqual( + errors, + [], + `expected a clean build, got:\n${errors.join('\n')}`, + ); + assert.strictEqual( + require(path.join(outputPath, 'bundle.js')).default, + 'Hello, ts-loader!', + ); + console.log('clean build: ok'); + + const { errors: typeErrors } = await build('typeError'); + assert.ok( + typeErrors.some(message => message.includes('TS2345')), + `expected a TS2345 type error, got:\n${typeErrors.join('\n')}`, + ); + console.log('type error reported: ok'); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/test/minimum-node-tests/typeError/app.ts b/test/minimum-node-tests/typeError/app.ts new file mode 100644 index 000000000..a36b42bec --- /dev/null +++ b/test/minimum-node-tests/typeError/app.ts @@ -0,0 +1,5 @@ +function greet(name: string): string { + return `Hello, ${name}!`; +} + +export default greet(42); diff --git a/test/minimum-node-tests/typeError/tsconfig.json b/test/minimum-node-tests/typeError/tsconfig.json new file mode 100644 index 000000000..e3d3ff49f --- /dev/null +++ b/test/minimum-node-tests/typeError/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2016", + "strict": true + } +}