From 5ade16ebf5fadfc3dbfeb3572be0f897229d7286 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 23 Feb 2026 13:14:53 -0800 Subject: [PATCH 1/4] [heft-typescript-plugin] Add emitModulePackageJson option for ESM output folders Add a new 'emitModulePackageJson' option for 'additionalModuleKindsToEmit' entries in typescript.json. When enabled, the TypeScript plugin writes a package.json with the appropriate "type" field ("module" for ESNext/ES2015, "commonjs" for CommonJS) to the output folder after compilation. This ensures Node.js correctly interprets .js files in ESM output folders like lib-esm/, fixing named import failures on Node 18 where .js files without a nearest "type": "module" package.json are treated as CommonJS. Enabled by default in local-node-rig and decoupled-local-node-rig for their lib-esm output. --- ...mitModulePackageJson_2026-02-23-00-00.json | 11 ++++++ .../reviews/api/heft-typescript-plugin.api.md | 2 + .../src/TypeScriptBuilder.ts | 38 +++++++++++++++++-- .../src/TypeScriptPlugin.ts | 1 + .../src/schemas/typescript.schema.json | 5 +++ .../heft-typescript-plugin/src/types.ts | 6 +++ .../profiles/default/config/typescript.json | 3 +- .../profiles/default/config/typescript.json | 3 +- 8 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json diff --git a/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json b/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json new file mode 100644 index 00000000000..5d671a10c8d --- /dev/null +++ b/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-typescript-plugin", + "comment": "Add `emitModulePackageJson` option for `additionalModuleKindsToEmit` entries. When enabled, a `package.json` with the appropriate `\"type\"` field (`\"module\"` for ESNext/ES2015 or `\"commonjs\"` for CommonJS) is written to the output folder after compilation. This ensures Node.js correctly interprets `.js` files in the output folder, fixing ESM import failures on Node 18.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-typescript-plugin", + "email": "iclanton@users.noreply.github.com" +} diff --git a/common/reviews/api/heft-typescript-plugin.api.md b/common/reviews/api/heft-typescript-plugin.api.md index 2e42af718cc..51276bd9191 100644 --- a/common/reviews/api/heft-typescript-plugin.api.md +++ b/common/reviews/api/heft-typescript-plugin.api.md @@ -41,6 +41,8 @@ export interface _ICompilerCapabilities { // @beta (undocumented) export interface IEmitModuleKind { + // (undocumented) + emitModulePackageJson?: boolean; // (undocumented) jsExtensionOverride?: string; // (undocumented) diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts index cf1c90058a6..ed3c2a362ef 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts @@ -490,6 +490,7 @@ export class TypeScriptBuilder { this._cleanupWorker(); //#endregion + this._emitModulePackageJsonFiles(ts); this._logEmitPerformance(ts); //#region FINAL_ANALYSIS @@ -557,6 +558,8 @@ export class TypeScriptBuilder { this._cleanupWorker(); //#endregion + this._emitModulePackageJsonFiles(ts); + if (pendingTranspilePromises.size) { const emitResults: TTypescript.EmitResult[] = await Promise.all(pendingTranspilePromises.values()); for (const { diagnostics } of emitResults) { @@ -818,7 +821,8 @@ export class TypeScriptBuilder { moduleKind, additionalModuleKindToEmit.outFolderName, /* isPrimary */ false, - undefined + undefined, + additionalModuleKindToEmit.emitModulePackageJson ); if (outFolderKey) { @@ -834,7 +838,8 @@ export class TypeScriptBuilder { moduleKind: TTypescript.ModuleKind, outFolderPath: string, isPrimary: boolean, - jsExtensionOverride: string | undefined + jsExtensionOverride: string | undefined, + emitModulePackageJson: boolean = false ): string | undefined { let outFolderName: string; if (path.isAbsolute(outFolderPath)) { @@ -885,8 +890,8 @@ export class TypeScriptBuilder { outFolderPath, moduleKind, jsExtensionOverride, - - isPrimary + isPrimary, + emitModulePackageJson }); return `${outFolderName}:${jsExtensionOverride || '.js'}`; @@ -972,6 +977,7 @@ export class TypeScriptBuilder { `Emitting program "${innerCompilerOptions!.configFilePath}"` ); + this._emitModulePackageJsonFiles(ts); this._logEmitPerformance(ts); // Reset performance counters @@ -1128,6 +1134,30 @@ export class TypeScriptBuilder { return host; } + /** + * For each module kind configured with `emitModulePackageJson: true`, writes a + * `package.json` with the appropriate `"type"` field to ensure Node.js correctly + * interprets `.js` files in the output folder. + */ + private _emitModulePackageJsonFiles(ts: ExtendedTypeScript): void { + for (const moduleKindToEmit of this._moduleKindsToEmit) { + if (!moduleKindToEmit.emitModulePackageJson) { + continue; + } + + const isEsm: boolean = + moduleKindToEmit.moduleKind === ts.ModuleKind.ES2015 || + moduleKindToEmit.moduleKind === ts.ModuleKind.ESNext; + const moduleType: string = isEsm ? 'module' : 'commonjs'; + + const packageJsonPath: string = `${moduleKindToEmit.outFolderPath}package.json`; + const packageJsonContent: string = `{\n "type": "${moduleType}"\n}\n`; + + ts.sys.writeFile(packageJsonPath, packageJsonContent); + this._typescriptTerminal.writeVerboseLine(`Wrote ${packageJsonPath} with "type": "${moduleType}"`); + } + } + private _parseModuleKind(ts: ExtendedTypeScript, moduleKindName: string): TTypescript.ModuleKind { switch (moduleKindName.toLowerCase()) { case 'commonjs': diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts index cd47dfc1b89..6fb6eb72647 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts @@ -45,6 +45,7 @@ export interface IEmitModuleKind { moduleKind: 'commonjs' | 'amd' | 'umd' | 'system' | 'es2015' | 'esnext'; outFolderName: string; jsExtensionOverride?: string; + emitModulePackageJson?: boolean; } /** diff --git a/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json b/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json index d42ff1f0476..f19fe229c94 100644 --- a/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json +++ b/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json @@ -31,6 +31,11 @@ "outFolderName": { "type": "string", "pattern": "[^\\\\\\/]" + }, + + "emitModulePackageJson": { + "description": "If true, a package.json file will be written to the output folder with the appropriate \"type\" field (e.g. \"module\" for ESNext/ES2015, or \"commonjs\" for CommonJS). This ensures that Node.js correctly interprets .js files in the output folder regardless of the nearest ancestor package.json \"type\" setting.", + "type": "boolean" } }, "required": ["moduleKind", "outFolderName"] diff --git a/heft-plugins/heft-typescript-plugin/src/types.ts b/heft-plugins/heft-typescript-plugin/src/types.ts index c7855b9ab55..f4704105bb0 100644 --- a/heft-plugins/heft-typescript-plugin/src/types.ts +++ b/heft-plugins/heft-typescript-plugin/src/types.ts @@ -62,4 +62,10 @@ export interface ICachedEmitModuleKind { * Declarations are only emitted for the primary module kind. */ isPrimary: boolean; + + /** + * If true, a package.json with the appropriate "type" field will be written + * to the output folder after emit. + */ + emitModulePackageJson: boolean; } diff --git a/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json b/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json index 19141b16622..ba733af4280 100644 --- a/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json +++ b/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json @@ -8,7 +8,8 @@ "additionalModuleKindsToEmit": [ { "moduleKind": "esnext", - "outFolderName": "lib-esm" + "outFolderName": "lib-esm", + "emitModulePackageJson": true } ] } diff --git a/rigs/local-node-rig/profiles/default/config/typescript.json b/rigs/local-node-rig/profiles/default/config/typescript.json index 19141b16622..ba733af4280 100644 --- a/rigs/local-node-rig/profiles/default/config/typescript.json +++ b/rigs/local-node-rig/profiles/default/config/typescript.json @@ -8,7 +8,8 @@ "additionalModuleKindsToEmit": [ { "moduleKind": "esnext", - "outFolderName": "lib-esm" + "outFolderName": "lib-esm", + "emitModulePackageJson": true } ] } From 67ef5b1e1a042384ebf4f7b204430243f651c274 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 23 Feb 2026 17:58:00 -0800 Subject: [PATCH 2/4] fixup! [heft-typescript-plugin] Add emitModulePackageJson option for ESM output folders --- .../heft-typescript-plugin/src/TypeScriptBuilder.ts | 13 ++++++++----- .../profiles/default/config/typescript.json | 3 +-- .../profiles/default/config/typescript.json | 3 +-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts index ed3c2a362ef..b812b5cd027 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts @@ -738,7 +738,8 @@ export class TypeScriptBuilder { ts.ModuleKind.CommonJS, tsconfig.options.outDir!, /* isPrimary */ tsconfig.options.module === ts.ModuleKind.CommonJS, - '.cjs' + '.cjs', + /* emitModulePackageJson */ false ); const cjsReason: IModuleKindReason = { @@ -757,7 +758,8 @@ export class TypeScriptBuilder { ts.ModuleKind.ESNext, tsconfig.options.outDir!, /* isPrimary */ tsconfig.options.module === ts.ModuleKind.ESNext, - '.mjs' + '.mjs', + /* emitModulePackageJson */ false ); const mjsReason: IModuleKindReason = { @@ -776,7 +778,8 @@ export class TypeScriptBuilder { tsconfig.options.module, tsconfig.options.outDir!, /* isPrimary */ true, - /* jsExtensionOverride */ undefined + /* jsExtensionOverride */ undefined, + /* emitModulePackageJson */ false ); const tsConfigReason: IModuleKindReason = { @@ -822,7 +825,7 @@ export class TypeScriptBuilder { additionalModuleKindToEmit.outFolderName, /* isPrimary */ false, undefined, - additionalModuleKindToEmit.emitModulePackageJson + additionalModuleKindToEmit.emitModulePackageJson ?? false ); if (outFolderKey) { @@ -839,7 +842,7 @@ export class TypeScriptBuilder { outFolderPath: string, isPrimary: boolean, jsExtensionOverride: string | undefined, - emitModulePackageJson: boolean = false + emitModulePackageJson: boolean ): string | undefined { let outFolderName: string; if (path.isAbsolute(outFolderPath)) { diff --git a/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json b/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json index ba733af4280..19141b16622 100644 --- a/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json +++ b/rigs/decoupled-local-node-rig/profiles/default/config/typescript.json @@ -8,8 +8,7 @@ "additionalModuleKindsToEmit": [ { "moduleKind": "esnext", - "outFolderName": "lib-esm", - "emitModulePackageJson": true + "outFolderName": "lib-esm" } ] } diff --git a/rigs/local-node-rig/profiles/default/config/typescript.json b/rigs/local-node-rig/profiles/default/config/typescript.json index ba733af4280..19141b16622 100644 --- a/rigs/local-node-rig/profiles/default/config/typescript.json +++ b/rigs/local-node-rig/profiles/default/config/typescript.json @@ -8,8 +8,7 @@ "additionalModuleKindsToEmit": [ { "moduleKind": "esnext", - "outFolderName": "lib-esm", - "emitModulePackageJson": true + "outFolderName": "lib-esm" } ] } From c9ad6b578ed72ea8a4ab1c9263e60de16368ac77 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 23 Feb 2026 18:03:58 -0800 Subject: [PATCH 3/4] Convert something to a destructuring. --- .../src/TypeScriptBuilder.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts index b812b5cd027..c5aa751e560 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts @@ -794,16 +794,14 @@ export class TypeScriptBuilder { } if (this._configuration.additionalModuleKindsToEmit) { - for (const additionalModuleKindToEmit of this._configuration.additionalModuleKindsToEmit) { - const moduleKind: TTypescript.ModuleKind = this._parseModuleKind( - ts, - additionalModuleKindToEmit.moduleKind - ); + for (const { moduleKind: moduleKindString, outFolderName, emitModulePackageJson = false } of this + ._configuration.additionalModuleKindsToEmit) { + const moduleKind: TTypescript.ModuleKind = this._parseModuleKind(ts, moduleKindString); - const outDirKey: string = `${additionalModuleKindToEmit.outFolderName}:.js`; + const outDirKey: string = `${outFolderName}:.js`; const moduleKindReason: IModuleKindReason = { kind: ts.ModuleKind[moduleKind] as keyof typeof TTypescript.ModuleKind, - outDir: additionalModuleKindToEmit.outFolderName, + outDir: outFolderName, extension: '.js', reason: `additionalModuleKindsToEmit` }; @@ -813,19 +811,19 @@ export class TypeScriptBuilder { if (existingKind) { throw new Error( - `Module kind "${additionalModuleKindToEmit.moduleKind}" is already emitted at ${existingKind.outDir} with extension '${existingKind.extension}' by option ${existingKind.reason}.` + `Module kind "${moduleKind}" is already emitted at ${existingKind.outDir} with extension '${existingKind.extension}' by option ${existingKind.reason}.` ); } else if (existingDir) { throw new Error( - `Output folder "${additionalModuleKindToEmit.outFolderName}" already contains module kind ${existingDir.kind} with extension '${existingDir.extension}', specified by option ${existingDir.reason}.` + `Output folder "${outFolderName}" already contains module kind ${existingDir.kind} with extension '${existingDir.extension}', specified by option ${existingDir.reason}.` ); } else { const outFolderKey: string | undefined = this._addModuleKindToEmit( moduleKind, - additionalModuleKindToEmit.outFolderName, + outFolderName, /* isPrimary */ false, undefined, - additionalModuleKindToEmit.emitModulePackageJson ?? false + emitModulePackageJson ); if (outFolderKey) { From cc04d69a6c242ab1c527040f741964b23fa21a0e Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Tue, 24 Feb 2026 11:16:03 -0800 Subject: [PATCH 4/4] fixup! [heft-typescript-plugin] Add emitModulePackageJson option for ESM output folders --- ...mitModulePackageJson_2026-02-23-00-00.json | 2 +- .../src/TypeScriptBuilder.ts | 47 +++++++++++++++---- .../src/schemas/typescript.schema.json | 2 +- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json b/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json index 5d671a10c8d..1e57fbed0d3 100644 --- a/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json +++ b/common/changes/@rushstack/heft-typescript-plugin/emitModulePackageJson_2026-02-23-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/heft-typescript-plugin", - "comment": "Add `emitModulePackageJson` option for `additionalModuleKindsToEmit` entries. When enabled, a `package.json` with the appropriate `\"type\"` field (`\"module\"` for ESNext/ES2015 or `\"commonjs\"` for CommonJS) is written to the output folder after compilation. This ensures Node.js correctly interprets `.js` files in the output folder, fixing ESM import failures on Node 18.", + "comment": "Add `emitModulePackageJson` option for `additionalModuleKindsToEmit` entries. When enabled, a `package.json` with the appropriate `\"type\"` field is written to the output folder after compilation, ensuring Node.js correctly interprets `.js` files regardless of the nearest ancestor package.json `\"type\"` setting.", "type": "minor" } ], diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts index c5aa751e560..b819d050c3b 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts @@ -1141,21 +1141,48 @@ export class TypeScriptBuilder { * interprets `.js` files in the output folder. */ private _emitModulePackageJsonFiles(ts: ExtendedTypeScript): void { - for (const moduleKindToEmit of this._moduleKindsToEmit) { - if (!moduleKindToEmit.emitModulePackageJson) { + for (const { emitModulePackageJson, moduleKind, outFolderPath } of this._moduleKindsToEmit) { + if (!emitModulePackageJson) { continue; } - const isEsm: boolean = - moduleKindToEmit.moduleKind === ts.ModuleKind.ES2015 || - moduleKindToEmit.moduleKind === ts.ModuleKind.ESNext; - const moduleType: string = isEsm ? 'module' : 'commonjs'; + // "module" and "commonjs" are the only recognized values. See + // https://nodejs.org/api/packages.html#type + let moduleType: string | undefined; + switch (moduleKind) { + // UMD contains a CommonJS wrapper, so it should be treated as CommonJS for package.json generation purposes + case ts.ModuleKind.UMD: + case ts.ModuleKind.CommonJS: { + moduleType = 'commonjs'; + break; + } + + case ts.ModuleKind.AMD: + case ts.ModuleKind.None: + case ts.ModuleKind.Preserve: + case ts.ModuleKind.System: { + moduleType = undefined; + break; + } - const packageJsonPath: string = `${moduleKindToEmit.outFolderPath}package.json`; - const packageJsonContent: string = `{\n "type": "${moduleType}"\n}\n`; + default: { + moduleType = 'module'; + break; + } + } - ts.sys.writeFile(packageJsonPath, packageJsonContent); - this._typescriptTerminal.writeVerboseLine(`Wrote ${packageJsonPath} with "type": "${moduleType}"`); + if (moduleType) { + const packageJsonPath: string = `${outFolderPath}package.json`; + const packageJsonContent: string = `{\n "type": "${moduleType}"\n}\n`; + + ts.sys.writeFile(packageJsonPath, packageJsonContent); + this._typescriptTerminal.writeVerboseLine(`Wrote ${packageJsonPath} with "type": "${moduleType}"`); + } else { + throw new Error( + `Unsupported module kind ${ts.ModuleKind[moduleKind]} for package.json generation. ` + + `Remove the \`emitModulePackageJson\` option for this module kind.` + ); + } } } diff --git a/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json b/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json index f19fe229c94..d068b3149c8 100644 --- a/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json +++ b/heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json @@ -34,7 +34,7 @@ }, "emitModulePackageJson": { - "description": "If true, a package.json file will be written to the output folder with the appropriate \"type\" field (e.g. \"module\" for ESNext/ES2015, or \"commonjs\" for CommonJS). This ensures that Node.js correctly interprets .js files in the output folder regardless of the nearest ancestor package.json \"type\" setting.", + "description": "If true, a package.json file will be written to the output folder with the appropriate \"type\" field for the specified module kind. This ensures that Node.js correctly interprets .js files in the output folder regardless of the nearest ancestor package.json \"type\" setting. Only valid for CommonJS, UMD, and ES module kinds.", "type": "boolean" } },