Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/minimum-node-tests.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -111,6 +112,12 @@ npm run execution-tests -- --single-test <name> # one test
npm run execution-tests -- --single-test <name> --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`).
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -37,7 +38,7 @@
"ts"
],
"engines": {
"node": ">=22.0.0"
"node": ">=16.20.0"
},
"author": "John Reilly <johnny_reilly@hotmail.com> (https://johnnyreilly.com)",
"contributors": [
Expand Down
24 changes: 21 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
createTypeScriptInstance,
getTypeScriptEmit,
loadTypeScriptModules,
recheckAllTransitiveDependants,
} from './typeScriptApi';
import type {
Expand All @@ -21,6 +22,7 @@ import type {
ResolvedFilePathCache,
TSFile,
TSInstance,
TypeScriptModules,
} from './types';
import * as loaderUtils from './loaderUtils';
import { getTSInstanceFromCache, setTSInstanceInCache } from './instance-cache';
Expand All @@ -39,19 +41,29 @@ const canUseSourceMapConsumer =

const loaderOptionsCache: LoaderOptionsCache = {};

function loader(
async function loader(
this: webpack.LoaderContext<LoaderOptions>,
contents: string,
inputSourceMap?: Record<string, unknown>,
) {
// 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
Expand All @@ -65,10 +77,15 @@ function successLoader(
contents: string,
inputSourceMap: Record<string, unknown> | undefined,
callback: ReturnType<webpack.LoaderContext<LoaderOptions>['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);
Expand Down Expand Up @@ -99,6 +116,7 @@ function successLoader(
function getTypeScriptInstance(
loaderOptions: LoaderOptions,
loader: webpack.LoaderContext<LoaderOptions>,
typeScriptModules: TypeScriptModules,
) {
const existing = getTSInstanceFromCache(
loader._compiler!,
Expand Down Expand Up @@ -141,7 +159,7 @@ function getTypeScriptInstance(
files,
resolvedFilePathCache,
typeScriptApiInstance: createTypeScriptInstance(
loaderOptions,
typeScriptModules,
configFilePath,
useCaseSensitiveFileNames,
),
Expand Down
80 changes: 57 additions & 23 deletions src/typeScriptApi.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand All @@ -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<string, Promise<TypeScriptModules>>();

export function loadTypeScriptModules(
compilerPackage: string,
): Promise<TypeScriptModules> {
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<TypeScriptModules> {
const [syncModule, fsModule] = await Promise.all([
importCompilerEntryPoint<Pick<TypeScriptModules, 'API'>>(
`${compilerPackage}/sync`,
),
importCompilerEntryPoint<Pick<TypeScriptModules, 'createFileSystemLayer'>>(
`${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<T>(specifier: string): Promise<T> {
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(),
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -710,7 +742,9 @@ function layerFileContents(
contents: string,
): Snapshot {
const fileLayer: SnapshotChanges = {
fileSystem: createFileSystemLayer([[fileName, contents]]),
fileSystem: typeScriptInstance.createFileSystemLayer([
[fileName, contents],
]),
ensurePrograms: true,
};
if (
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions test/minimum-node-tests/clean/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { greet } from './greet';

export default greet('ts-loader');
3 changes: 3 additions & 0 deletions test/minimum-node-tests/clean/greet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function greet(name: string): string {
return `Hello, ${name}!`;
}
7 changes: 7 additions & 0 deletions test/minimum-node-tests/clean/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2016",
"strict": true
}
}
Loading
Loading