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..1e57fbed0d3 --- /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 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" + } + ], + "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..b819d050c3b 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) { @@ -735,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 = { @@ -754,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 = { @@ -773,7 +778,8 @@ export class TypeScriptBuilder { tsconfig.options.module, tsconfig.options.outDir!, /* isPrimary */ true, - /* jsExtensionOverride */ undefined + /* jsExtensionOverride */ undefined, + /* emitModulePackageJson */ false ); const tsConfigReason: IModuleKindReason = { @@ -788,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` }; @@ -807,18 +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 + undefined, + emitModulePackageJson ); if (outFolderKey) { @@ -834,7 +839,8 @@ export class TypeScriptBuilder { moduleKind: TTypescript.ModuleKind, outFolderPath: string, isPrimary: boolean, - jsExtensionOverride: string | undefined + jsExtensionOverride: string | undefined, + emitModulePackageJson: boolean ): string | undefined { let outFolderName: string; if (path.isAbsolute(outFolderPath)) { @@ -885,8 +891,8 @@ export class TypeScriptBuilder { outFolderPath, moduleKind, jsExtensionOverride, - - isPrimary + isPrimary, + emitModulePackageJson }); return `${outFolderName}:${jsExtensionOverride || '.js'}`; @@ -972,6 +978,7 @@ export class TypeScriptBuilder { `Emitting program "${innerCompilerOptions!.configFilePath}"` ); + this._emitModulePackageJsonFiles(ts); this._logEmitPerformance(ts); // Reset performance counters @@ -1128,6 +1135,57 @@ 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 { emitModulePackageJson, moduleKind, outFolderPath } of this._moduleKindsToEmit) { + if (!emitModulePackageJson) { + continue; + } + + // "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; + } + + default: { + moduleType = 'module'; + break; + } + } + + 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.` + ); + } + } + } + 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..d068b3149c8 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 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" } }, "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; }