Skip to content

Commit 7cd45c4

Browse files
authored
[heft-typescript-plugin] Add emitModulePackageJson option for ESM output folders (#5665)
* [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. * fixup! [heft-typescript-plugin] Add emitModulePackageJson option for ESM output folders * Convert something to a destructuring. * fixup! [heft-typescript-plugin] Add emitModulePackageJson option for ESM output folders
1 parent 6457718 commit 7cd45c4

6 files changed

Lines changed: 100 additions & 17 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-typescript-plugin",
5+
"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.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-typescript-plugin",
10+
"email": "iclanton@users.noreply.github.com"
11+
}

common/reviews/api/heft-typescript-plugin.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface _ICompilerCapabilities {
4141

4242
// @beta (undocumented)
4343
export interface IEmitModuleKind {
44+
// (undocumented)
45+
emitModulePackageJson?: boolean;
4446
// (undocumented)
4547
jsExtensionOverride?: string;
4648
// (undocumented)

heft-plugins/heft-typescript-plugin/src/TypeScriptBuilder.ts

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ export class TypeScriptBuilder {
490490
this._cleanupWorker();
491491
//#endregion
492492

493+
this._emitModulePackageJsonFiles(ts);
493494
this._logEmitPerformance(ts);
494495

495496
//#region FINAL_ANALYSIS
@@ -557,6 +558,8 @@ export class TypeScriptBuilder {
557558
this._cleanupWorker();
558559
//#endregion
559560

561+
this._emitModulePackageJsonFiles(ts);
562+
560563
if (pendingTranspilePromises.size) {
561564
const emitResults: TTypescript.EmitResult[] = await Promise.all(pendingTranspilePromises.values());
562565
for (const { diagnostics } of emitResults) {
@@ -735,7 +738,8 @@ export class TypeScriptBuilder {
735738
ts.ModuleKind.CommonJS,
736739
tsconfig.options.outDir!,
737740
/* isPrimary */ tsconfig.options.module === ts.ModuleKind.CommonJS,
738-
'.cjs'
741+
'.cjs',
742+
/* emitModulePackageJson */ false
739743
);
740744

741745
const cjsReason: IModuleKindReason = {
@@ -754,7 +758,8 @@ export class TypeScriptBuilder {
754758
ts.ModuleKind.ESNext,
755759
tsconfig.options.outDir!,
756760
/* isPrimary */ tsconfig.options.module === ts.ModuleKind.ESNext,
757-
'.mjs'
761+
'.mjs',
762+
/* emitModulePackageJson */ false
758763
);
759764

760765
const mjsReason: IModuleKindReason = {
@@ -773,7 +778,8 @@ export class TypeScriptBuilder {
773778
tsconfig.options.module,
774779
tsconfig.options.outDir!,
775780
/* isPrimary */ true,
776-
/* jsExtensionOverride */ undefined
781+
/* jsExtensionOverride */ undefined,
782+
/* emitModulePackageJson */ false
777783
);
778784

779785
const tsConfigReason: IModuleKindReason = {
@@ -788,16 +794,14 @@ export class TypeScriptBuilder {
788794
}
789795

790796
if (this._configuration.additionalModuleKindsToEmit) {
791-
for (const additionalModuleKindToEmit of this._configuration.additionalModuleKindsToEmit) {
792-
const moduleKind: TTypescript.ModuleKind = this._parseModuleKind(
793-
ts,
794-
additionalModuleKindToEmit.moduleKind
795-
);
797+
for (const { moduleKind: moduleKindString, outFolderName, emitModulePackageJson = false } of this
798+
._configuration.additionalModuleKindsToEmit) {
799+
const moduleKind: TTypescript.ModuleKind = this._parseModuleKind(ts, moduleKindString);
796800

797-
const outDirKey: string = `${additionalModuleKindToEmit.outFolderName}:.js`;
801+
const outDirKey: string = `${outFolderName}:.js`;
798802
const moduleKindReason: IModuleKindReason = {
799803
kind: ts.ModuleKind[moduleKind] as keyof typeof TTypescript.ModuleKind,
800-
outDir: additionalModuleKindToEmit.outFolderName,
804+
outDir: outFolderName,
801805
extension: '.js',
802806
reason: `additionalModuleKindsToEmit`
803807
};
@@ -807,18 +811,19 @@ export class TypeScriptBuilder {
807811

808812
if (existingKind) {
809813
throw new Error(
810-
`Module kind "${additionalModuleKindToEmit.moduleKind}" is already emitted at ${existingKind.outDir} with extension '${existingKind.extension}' by option ${existingKind.reason}.`
814+
`Module kind "${moduleKind}" is already emitted at ${existingKind.outDir} with extension '${existingKind.extension}' by option ${existingKind.reason}.`
811815
);
812816
} else if (existingDir) {
813817
throw new Error(
814-
`Output folder "${additionalModuleKindToEmit.outFolderName}" already contains module kind ${existingDir.kind} with extension '${existingDir.extension}', specified by option ${existingDir.reason}.`
818+
`Output folder "${outFolderName}" already contains module kind ${existingDir.kind} with extension '${existingDir.extension}', specified by option ${existingDir.reason}.`
815819
);
816820
} else {
817821
const outFolderKey: string | undefined = this._addModuleKindToEmit(
818822
moduleKind,
819-
additionalModuleKindToEmit.outFolderName,
823+
outFolderName,
820824
/* isPrimary */ false,
821-
undefined
825+
undefined,
826+
emitModulePackageJson
822827
);
823828

824829
if (outFolderKey) {
@@ -834,7 +839,8 @@ export class TypeScriptBuilder {
834839
moduleKind: TTypescript.ModuleKind,
835840
outFolderPath: string,
836841
isPrimary: boolean,
837-
jsExtensionOverride: string | undefined
842+
jsExtensionOverride: string | undefined,
843+
emitModulePackageJson: boolean
838844
): string | undefined {
839845
let outFolderName: string;
840846
if (path.isAbsolute(outFolderPath)) {
@@ -885,8 +891,8 @@ export class TypeScriptBuilder {
885891
outFolderPath,
886892
moduleKind,
887893
jsExtensionOverride,
888-
889-
isPrimary
894+
isPrimary,
895+
emitModulePackageJson
890896
});
891897

892898
return `${outFolderName}:${jsExtensionOverride || '.js'}`;
@@ -972,6 +978,7 @@ export class TypeScriptBuilder {
972978
`Emitting program "${innerCompilerOptions!.configFilePath}"`
973979
);
974980

981+
this._emitModulePackageJsonFiles(ts);
975982
this._logEmitPerformance(ts);
976983

977984
// Reset performance counters
@@ -1128,6 +1135,57 @@ export class TypeScriptBuilder {
11281135
return host;
11291136
}
11301137

1138+
/**
1139+
* For each module kind configured with `emitModulePackageJson: true`, writes a
1140+
* `package.json` with the appropriate `"type"` field to ensure Node.js correctly
1141+
* interprets `.js` files in the output folder.
1142+
*/
1143+
private _emitModulePackageJsonFiles(ts: ExtendedTypeScript): void {
1144+
for (const { emitModulePackageJson, moduleKind, outFolderPath } of this._moduleKindsToEmit) {
1145+
if (!emitModulePackageJson) {
1146+
continue;
1147+
}
1148+
1149+
// "module" and "commonjs" are the only recognized values. See
1150+
// https://nodejs.org/api/packages.html#type
1151+
let moduleType: string | undefined;
1152+
switch (moduleKind) {
1153+
// UMD contains a CommonJS wrapper, so it should be treated as CommonJS for package.json generation purposes
1154+
case ts.ModuleKind.UMD:
1155+
case ts.ModuleKind.CommonJS: {
1156+
moduleType = 'commonjs';
1157+
break;
1158+
}
1159+
1160+
case ts.ModuleKind.AMD:
1161+
case ts.ModuleKind.None:
1162+
case ts.ModuleKind.Preserve:
1163+
case ts.ModuleKind.System: {
1164+
moduleType = undefined;
1165+
break;
1166+
}
1167+
1168+
default: {
1169+
moduleType = 'module';
1170+
break;
1171+
}
1172+
}
1173+
1174+
if (moduleType) {
1175+
const packageJsonPath: string = `${outFolderPath}package.json`;
1176+
const packageJsonContent: string = `{\n "type": "${moduleType}"\n}\n`;
1177+
1178+
ts.sys.writeFile(packageJsonPath, packageJsonContent);
1179+
this._typescriptTerminal.writeVerboseLine(`Wrote ${packageJsonPath} with "type": "${moduleType}"`);
1180+
} else {
1181+
throw new Error(
1182+
`Unsupported module kind ${ts.ModuleKind[moduleKind]} for package.json generation. ` +
1183+
`Remove the \`emitModulePackageJson\` option for this module kind.`
1184+
);
1185+
}
1186+
}
1187+
}
1188+
11311189
private _parseModuleKind(ts: ExtendedTypeScript, moduleKindName: string): TTypescript.ModuleKind {
11321190
switch (moduleKindName.toLowerCase()) {
11331191
case 'commonjs':

heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface IEmitModuleKind {
4545
moduleKind: 'commonjs' | 'amd' | 'umd' | 'system' | 'es2015' | 'esnext';
4646
outFolderName: string;
4747
jsExtensionOverride?: string;
48+
emitModulePackageJson?: boolean;
4849
}
4950

5051
/**

heft-plugins/heft-typescript-plugin/src/schemas/typescript.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
"outFolderName": {
3232
"type": "string",
3333
"pattern": "[^\\\\\\/]"
34+
},
35+
36+
"emitModulePackageJson": {
37+
"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.",
38+
"type": "boolean"
3439
}
3540
},
3641
"required": ["moduleKind", "outFolderName"]

heft-plugins/heft-typescript-plugin/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,10 @@ export interface ICachedEmitModuleKind {
6262
* Declarations are only emitted for the primary module kind.
6363
*/
6464
isPrimary: boolean;
65+
66+
/**
67+
* If true, a package.json with the appropriate "type" field will be written
68+
* to the output folder after emit.
69+
*/
70+
emitModulePackageJson: boolean;
6571
}

0 commit comments

Comments
 (0)