320 lines
11 KiB
Markdown
320 lines
11 KiB
Markdown
# ESM Migration Plan: Add File Extensions to Imports
|
|
|
|
## Overview
|
|
|
|
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
|
|
|
|
## TL;DR - Remaining Work
|
|
|
|
- [x] expressions - Migrated ✅
|
|
- [x] workflow-parser - Migrated ✅
|
|
- [x] languageservice - Migrated ✅
|
|
- [x] languageserver - Add `.js` extensions to imports ✅
|
|
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
|
|
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
|
|
|
|
**Blocker:** `vscode-languageserver@8.0.2` lacks ESM exports. Stable v10 with `exports` field needed.
|
|
|
|
### ⚠️ Important: `skipLibCheck: true` Required
|
|
|
|
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
|
|
|
|
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
|
|
|
|
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
|
|
|
|
---
|
|
|
|
## Issues Fixed
|
|
|
|
This migration will resolve the following issues:
|
|
|
|
- **#154** - Upgrade `moduleResolution` from `node` to `node16` or `nodenext` in tsconfig
|
|
- **#110** - Published ESM code has imports without file extensions
|
|
- **#64** - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
|
|
- **#146** - Can not import `@actions/workflow-parser`
|
|
|
|
## Problem Statement
|
|
|
|
### Current State
|
|
|
|
All packages use `"moduleResolution": "node"`:
|
|
|
|
| Package | moduleResolution | TypeScript |
|
|
|---------|------------------|------------|
|
|
| expressions | `"node"` | ^4.7.4 |
|
|
| workflow-parser | `"node"` | ^4.8.4 |
|
|
| languageservice | `"node"` | ^4.8.4 |
|
|
| languageserver | `"node"` | ^4.8.4 |
|
|
| browser-playground | `"Node16"` ✅ | ^4.9.4 |
|
|
|
|
This causes TypeScript to emit code like:
|
|
```javascript
|
|
// Published to npm - INVALID ESM
|
|
export { Expr } from "./ast"; // Missing .js extension!
|
|
```
|
|
|
|
### Why This Fails
|
|
|
|
ESM in Node.js 12+ **requires** explicit file extensions. When users try to import these packages:
|
|
|
|
```javascript
|
|
// User's code
|
|
import { Expr } from "@actions/expressions";
|
|
```
|
|
|
|
Node.js fails with:
|
|
```
|
|
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
|
|
```
|
|
|
|
## Migration Strategy
|
|
|
|
### Option A: TypeScript 5.7+ with `rewriteRelativeImportExtensions` (Recommended)
|
|
|
|
TypeScript 5.7 introduced a new compiler option that automatically rewrites `.ts` extensions to `.js` in output:
|
|
|
|
```jsonc
|
|
{
|
|
"compilerOptions": {
|
|
"moduleResolution": "node16", // or "nodenext"
|
|
"rewriteRelativeImportExtensions": true
|
|
}
|
|
}
|
|
```
|
|
|
|
**Source code:**
|
|
```typescript
|
|
import { Expr } from "./ast.ts";
|
|
```
|
|
|
|
**Compiled output:**
|
|
```javascript
|
|
export { Expr } from "./ast.js";
|
|
```
|
|
|
|
**Pros:**
|
|
- Source uses `.ts` extensions (matches actual files)
|
|
- Works with Deno (which requires `.ts` extensions)
|
|
- TypeScript automatically transforms to `.js`
|
|
- Modern, forward-looking approach
|
|
|
|
**Cons:**
|
|
- Requires TypeScript 5.7+
|
|
- Relatively new feature
|
|
- **BUG:** See "Known Issues" section below
|
|
|
|
### Option B: Manual `.js` Extensions
|
|
|
|
Use `.js` extensions in source TypeScript files:
|
|
|
|
```typescript
|
|
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
|
|
```
|
|
|
|
**Pros:**
|
|
- Works with TypeScript 4.7+ (with node16 moduleResolution)
|
|
- Well-established pattern
|
|
- No post-processing needed
|
|
- Works with ts-jest without extra configuration
|
|
|
|
**Cons:**
|
|
- Confusing - `.js` files don't exist at write time
|
|
- Doesn't work with Deno out of the box
|
|
|
|
### Recommendation
|
|
|
|
**Use Option B** (manual `.js` extensions). Option A with `rewriteRelativeImportExtensions` has compatibility issues with ts-jest and requires additional workarounds.
|
|
|
|
---
|
|
|
|
## Known Issues and Workarounds (December 2025)
|
|
|
|
### 1. TypeScript Version Conflicts in Monorepo
|
|
|
|
**Problem:** The root `node_modules/typescript` was version 4.9.5 (pulled in by `ts-node` and `tsutils` dependencies), while workspace packages specified `^5.8.3`.
|
|
|
|
**Symptoms:**
|
|
- `npx tsc --version` showed 4.9.5
|
|
- `require('typescript').version` in ts-jest showed 5.8.3
|
|
- Confusing build failures
|
|
|
|
**Solution:** Add npm overrides in root `package.json`:
|
|
```json
|
|
{
|
|
"overrides": {
|
|
"typescript": "5.8.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. ts-jest Compatibility with TypeScript 5.9+
|
|
|
|
**Problem:** ts-jest 29.4.6 uses `typescript.JSDocParsingMode.ParseAll` which doesn't exist in TypeScript's ES module exports.
|
|
|
|
**Error:**
|
|
```
|
|
TypeError: Cannot read properties of undefined (reading 'ParseAll')
|
|
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
|
|
```
|
|
|
|
**Root Cause:** ts-jest accesses `typescript_1.default.JSDocParsingMode.ParseAll` but TypeScript has no default export in ESM.
|
|
|
|
**Solution:**
|
|
- Use ts-jest 29.0.3 (older version that doesn't use this API)
|
|
- OR wait for ts-jest fix
|
|
- **Stay on TypeScript 5.8.3, not 5.9+**
|
|
|
|
### 3. TypeScript `rewriteRelativeImportExtensions` Bug with .d.ts Files
|
|
|
|
**Problem:** TypeScript's `rewriteRelativeImportExtensions: true` correctly rewrites `.ts` → `.js` in `.js` output files, but **incorrectly keeps `.ts` extensions in `.d.ts` declaration files**.
|
|
|
|
**Example:**
|
|
- Source: `export { Expr } from "./ast.ts";`
|
|
- Output `index.js`: `export { Expr } from "./ast.js";` ✅ Correct
|
|
- Output `index.d.ts`: `export { Expr } from "./ast.ts";` ❌ Wrong (should be `.js`)
|
|
|
|
**Upstream Issue:** https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
|
|
|
|
**Workaround:** Post-process `.d.ts` files with a script. See `script/fix-dts-extensions.cjs`.
|
|
|
|
**Note:** Since we use Option B (manual `.js` extensions), this bug does not affect our migration.
|
|
|
|
### 4. yaml Package Internal Types Not Exported
|
|
|
|
**Problem:** The `yaml` package does not export internal types like `LinePos` and `NodeBase` that are used in `workflow-parser/src/workflows/yaml-object-reader.ts`.
|
|
|
|
**Error:**
|
|
```
|
|
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
|
|
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
|
|
```
|
|
|
|
**Solution:** Define local type aliases in the file that uses them:
|
|
```typescript
|
|
// Local type definitions to replace yaml internal imports
|
|
type LinePos = { line: number; col: number };
|
|
type NodeBase = { range?: [number, number, number] };
|
|
```
|
|
|
|
### 5. languageserver Blocked by vscode-languageserver Dependency
|
|
|
|
**Problem:** The `vscode-languageserver` package (v8.0.2) does not have proper ESM exports. When using `moduleResolution: "node16"`, TypeScript requires packages to have an `exports` field in `package.json` for subpath imports to work.
|
|
|
|
**Error:**
|
|
```
|
|
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
|
|
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
|
|
```
|
|
|
|
**Root Cause:** The `vscode-languageserver` package.json only has `main` and `browser` fields, but no `exports` field:
|
|
```json
|
|
{
|
|
"main": "./lib/node/main.js",
|
|
"browser": {
|
|
"./lib/node/main.js": "./lib/browser/main.js"
|
|
}
|
|
// No "exports" field!
|
|
}
|
|
```
|
|
|
|
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
|
|
|
|
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
|
|
|
|
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
|
|
|
|
**Options to resolve:**
|
|
- Wait for stable vscode-languageserver v10+ with ESM exports
|
|
- Use pre-release `vscode-languageserver@10.0.0-next.16` (has proper exports but is unstable)
|
|
- Fork or patch the dependency
|
|
|
|
---
|
|
|
|
## Migration Status
|
|
|
|
| Package | Tests | ESM Status |
|
|
|---------|-------|------------|
|
|
| expressions | 1068 | ✅ Migrated |
|
|
| workflow-parser | 292 | ✅ Migrated |
|
|
| languageservice | 452 | ✅ Migrated |
|
|
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
|
|
|
|
---
|
|
|
|
## Required Configuration Changes
|
|
|
|
### tsconfig.build.json (each migrated package)
|
|
|
|
**Note:** We use **Option B** (manual `.js` extensions in source files) rather than `rewriteRelativeImportExtensions` because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"module": "node16",
|
|
"moduleResolution": "node16",
|
|
"skipLibCheck": true,
|
|
"lib": ["ES2022"],
|
|
"target": "ES2022"
|
|
}
|
|
}
|
|
```
|
|
|
|
The `skipLibCheck: true` is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
|
|
```
|
|
|
|
### jest.config.js (each migrated package)
|
|
|
|
```javascript
|
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
export default {
|
|
preset: "ts-jest/presets/default-esm",
|
|
moduleNameMapper: {
|
|
"^(\\.{1,2}/.*)\\.js$": "$1",
|
|
"^(\\.{1,2}/.*)\\.ts$": "$1",
|
|
},
|
|
transform: {
|
|
"^.+\\.tsx?$": [
|
|
"ts-jest",
|
|
{
|
|
useESM: true,
|
|
isolatedModules: true,
|
|
},
|
|
],
|
|
},
|
|
moduleFileExtensions: ["ts", "js"],
|
|
};
|
|
```
|
|
|
|
### Root package.json
|
|
|
|
```json
|
|
{
|
|
"overrides": {
|
|
"typescript": "5.8.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Each workspace package.json
|
|
|
|
```json
|
|
{
|
|
"devDependencies": {
|
|
"typescript": "^5.8.3",
|
|
"ts-jest": "^29.0.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- [TypeScript moduleResolution reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html)
|
|
- [TypeScript 5.7 rewriteRelativeImportExtensions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths)
|
|
- [TypeScript .d.ts extension bug #61037](https://github.com/microsoft/TypeScript/issues/61037)
|
|
- [Node.js ESM mandatory extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions)
|
|
- [ts-jest ESM support](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support)
|
|
- [Community fork that works](https://github.com/boxbuild-io/actions-languageservices/commit/077fb2b58dfd2cca3d6e3df1fdf9e26e75db24ae)
|