diff --git a/.gitignore b/.gitignore index 4b8ac00ec4..13997fbbe0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ yarn-error.log packages/**/.npmignore packages/**/tsconfig.json !packages/template/*/tmpl/tsconfig.json +!packages/*/*/spec/fixture/*/tsconfig.json +!packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules +!packages/api/core/spec/fixture/ts7_dep_conf/node_modules +!packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules packages/**/tslint.json packages/**/yarn.lock packages/*/*/index.ts diff --git a/knip.json b/knip.json index dbf3844a3a..3ae3291911 100644 --- a/knip.json +++ b/knip.json @@ -24,6 +24,7 @@ "ignore": ["spec/fixture/**"], "ignoreDependencies": [ "@electron-forge/template-fixture", + "@typescript/typescript6", "electron-forge-template-fixture-two", "@electron-forge/maker-.*" ] diff --git a/packages/api/core/package.json b/packages/api/core/package.json index 07148792d1..1307e8a039 100644 --- a/packages/api/core/package.json +++ b/packages/api/core/package.json @@ -22,7 +22,17 @@ "@electron-forge/test-utils": "workspace:*", "electron-forge-template-fixture-two": "portal:./spec/fixture/electron-forge-template-fixture", "electron-installer-common": "^0.10.2", - "vitest": "catalog:" + "typescript": "^6.0.2", + "vitest": "catalog:", + "webpack": "^5.69.1" + }, + "peerDependencies": { + "typescript": ">=4.7.0 <7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } }, "dependencies": { "@electron-forge/core-utils": "workspace:*", @@ -35,7 +45,6 @@ "@electron/packager": "^20.0.1", "debug": "^4.3.1", "graceful-fs": "^4.2.11", - "jiti": "^2.4.2", "listr2": "^7.0.2" }, "engines": { diff --git a/packages/api/core/spec/fast/util/forge-config.spec.ts b/packages/api/core/spec/fast/util/forge-config.spec.ts index d0d06a8ebd..ecc3353fdc 100644 --- a/packages/api/core/spec/fast/util/forge-config.spec.ts +++ b/packages/api/core/spec/fast/util/forge-config.spec.ts @@ -1,7 +1,10 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { ResolvedForgeConfig } from '@electron-forge/shared-types'; import { describe, expect, it, vi } from 'vitest'; +import { Compilation } from 'webpack'; import findConfig, { forgeConfigIsValidFilePath, @@ -347,6 +350,153 @@ describe('findConfig', () => { const conf = await findConfig(fixturePath); expect(conf.buildIdentifier).toEqual('async-typescript-esm'); }); + + it('should support top-level await and extensionless relative imports', async () => { + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/tla_ts_conf', + ); + const conf = await findConfig(fixturePath); + expect(conf.buildIdentifier).toEqual('tla-relative-import'); + }); + + // Regression test for https://github.com/electron/forge/issues/3949 — + // CJS packages imported by a TypeScript config must be the same instances + // that the rest of the process (e.g. the webpack plugin) sees. + it('should share dependency instances with the loading process', async () => { + type WebpackDepConfig = ResolvedForgeConfig & { + stage: number; + compilationClass: unknown; + }; + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/webpack_dep_ts_conf', + ); + const conf = (await findConfig(fixturePath)) as WebpackDepConfig; + expect(conf.stage).toBeDefined(); + expect(conf.stage).toEqual( + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, + ); + expect(conf.compilationClass).toBe(Compilation); + }); + + it('should load ESM-only dependencies that use top-level await', async () => { + type EsmDepConfig = ResolvedForgeConfig & { answer: number }; + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/esm_dep_ts_conf', + ); + const conf = (await findConfig(fixturePath)) as EsmDepConfig; + expect(conf.buildIdentifier).toEqual('esm-tla-dep'); + expect(conf.answer).toEqual(42); + }); + + it('should respect "paths" aliases from the project tsconfig', async () => { + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/paths_ts_conf', + ); + const conf = await findConfig(fixturePath); + expect(conf.buildIdentifier).toEqual('tsconfig-paths-alias'); + }); + + it('should throw a helpful error when typescript is not installed', async () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/missing_ts_dep_conf', + ); + // Copy the fixture outside the repo so the monorepo's own `typescript` + // install is not resolvable from the project directory. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'forge-missing-ts-'), + ); + try { + await fs.cp(fixturePath, tmpDir, { recursive: true }); + await expect(findConfig(tmpDir)).rejects.toThrow( + /requires the "typescript" package \(version 4\.7\.0 or later\) to be installed/, + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + spy.mockRestore(); + } + }); + + it('should throw a helpful error when the project TypeScript is v7+', async () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/ts7_dep_conf', + ); + try { + await expect(findConfig(fixturePath)).rejects.toThrow( + /TypeScript 7\+ \(the native compiler\) no longer provides the JavaScript compiler API/, + ); + } finally { + spy.mockRestore(); + } + }); + + it('should fall back to a side-by-side @typescript/typescript6 when the project TypeScript is v7+', async () => { + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/ts7_with_ts6_dep_conf', + ); + const conf = await findConfig(fixturePath); + expect(conf.buildIdentifier).toEqual('ts7-with-ts6-fallback'); + }); + + it('should surface type errors when the config fails to load', async () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/typed_error_ts_conf', + ); + try { + // The config crashes at runtime; the loader should type-check it and + // report the actual type error instead of the runtime stack trace. + await expect(findConfig(fixturePath)).rejects.toThrow(/TS2339/); + } finally { + spy.mockRestore(); + } + }); + + describe('FORGE_TYPECHECK_CONFIG', () => { + it('should not type-check configs that load successfully by default', async () => { + type TypeErrorOnlyConfig = ResolvedForgeConfig & { + buildIdentifier: string; + }; + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/type_error_only_ts_conf', + ); + const conf = (await findConfig(fixturePath)) as TypeErrorOnlyConfig; + expect(conf.buildIdentifier).toEqual('type-error-only'); + }); + + it('should fail on type errors when FORGE_TYPECHECK_CONFIG is set', async () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const fixturePath = path.resolve( + import.meta.dirname, + '../../fixture/type_error_only_ts_conf', + ); + process.env.FORGE_TYPECHECK_CONFIG = '1'; + try { + await expect(findConfig(fixturePath)).rejects.toThrow(/TS2322/); + } finally { + delete process.env.FORGE_TYPECHECK_CONFIG; + spy.mockRestore(); + } + }); + }); }); describe('ESM and CJS module formats', () => { diff --git a/packages/api/core/spec/fixture/esm_dep_ts_conf/forge.config.ts b/packages/api/core/spec/fixture/esm_dep_ts_conf/forge.config.ts new file mode 100644 index 0000000000..b65ceef77d --- /dev/null +++ b/packages/api/core/spec/fixture/esm_dep_ts_conf/forge.config.ts @@ -0,0 +1,13 @@ +// The TypeScript config loader must load ESM-only dependencies (here one +// with top-level await) through Node's native ESM loader. +import type { ForgeConfig } from '@electron-forge/shared-types'; + +// @ts-expect-error the fixture package ships no type definitions +import { answer } from 'esm-tla-dep'; + +const config: ForgeConfig & { answer: number } = { + buildIdentifier: 'esm-tla-dep', + answer, +}; + +export default config; diff --git a/packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/index.js b/packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/index.js new file mode 100644 index 0000000000..2bfef2cb07 --- /dev/null +++ b/packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/index.js @@ -0,0 +1,3 @@ +// An ESM-only dependency that uses top-level await — it can only be loaded +// through Node's real ESM loader, never through require(). +export const answer = await Promise.resolve(42); diff --git a/packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/package.json b/packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/package.json new file mode 100644 index 0000000000..ff6754005a --- /dev/null +++ b/packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/package.json @@ -0,0 +1,6 @@ +{ + "name": "esm-tla-dep", + "version": "1.0.0", + "type": "module", + "main": "index.js" +} diff --git a/packages/api/core/spec/fixture/esm_dep_ts_conf/package.json b/packages/api/core/spec/fixture/esm_dep_ts_conf/package.json new file mode 100644 index 0000000000..8f1cd9f99d --- /dev/null +++ b/packages/api/core/spec/fixture/esm_dep_ts_conf/package.json @@ -0,0 +1,18 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@electron-forge/shared-types": "*", + "electron": "99.99.99", + "esm-tla-dep": "1.0.0" + } +} diff --git a/packages/api/core/spec/fixture/missing_ts_dep_conf/forge.config.ts b/packages/api/core/spec/fixture/missing_ts_dep_conf/forge.config.ts new file mode 100644 index 0000000000..f033dc4baa --- /dev/null +++ b/packages/api/core/spec/fixture/missing_ts_dep_conf/forge.config.ts @@ -0,0 +1,8 @@ +// This fixture is copied to a temporary directory (where no `typescript` +// package is resolvable) to assert the loader's helpful error message. It +// deliberately has no imports so it stands alone outside the repo. +const config = { + buildIdentifier: 'missing-typescript', +}; + +export default config; diff --git a/packages/api/core/spec/fixture/missing_ts_dep_conf/package.json b/packages/api/core/spec/fixture/missing_ts_dep_conf/package.json new file mode 100644 index 0000000000..f59c18baee --- /dev/null +++ b/packages/api/core/spec/fixture/missing_ts_dep_conf/package.json @@ -0,0 +1,16 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/paths_ts_conf/forge.config.ts b/packages/api/core/spec/fixture/paths_ts_conf/forge.config.ts new file mode 100644 index 0000000000..9e841c1570 --- /dev/null +++ b/packages/api/core/spec/fixture/paths_ts_conf/forge.config.ts @@ -0,0 +1,11 @@ +// The TypeScript config loader must respect `paths` aliases from the +// project's own tsconfig.json. +import type { ForgeConfig } from '@electron-forge/shared-types'; + +import { getBuildIdentifier } from '@fixture/identifier'; + +const config: ForgeConfig = { + buildIdentifier: getBuildIdentifier(), +}; + +export default config; diff --git a/packages/api/core/spec/fixture/paths_ts_conf/package.json b/packages/api/core/spec/fixture/paths_ts_conf/package.json new file mode 100644 index 0000000000..da951252fb --- /dev/null +++ b/packages/api/core/spec/fixture/paths_ts_conf/package.json @@ -0,0 +1,17 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@electron-forge/shared-types": "*", + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/paths_ts_conf/src/identifier.ts b/packages/api/core/spec/fixture/paths_ts_conf/src/identifier.ts new file mode 100644 index 0000000000..60df12aca5 --- /dev/null +++ b/packages/api/core/spec/fixture/paths_ts_conf/src/identifier.ts @@ -0,0 +1,3 @@ +export function getBuildIdentifier(): string { + return 'tsconfig-paths-alias'; +} diff --git a/packages/api/core/spec/fixture/paths_ts_conf/tsconfig.json b/packages/api/core/spec/fixture/paths_ts_conf/tsconfig.json new file mode 100644 index 0000000000..adb65b7248 --- /dev/null +++ b/packages/api/core/spec/fixture/paths_ts_conf/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "preserve", + "moduleResolution": "bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@fixture/*": ["src/*"] + } + } +} diff --git a/packages/api/core/spec/fixture/tla_ts_conf/forge.config.ts b/packages/api/core/spec/fixture/tla_ts_conf/forge.config.ts new file mode 100644 index 0000000000..5675bb39c2 --- /dev/null +++ b/packages/api/core/spec/fixture/tla_ts_conf/forge.config.ts @@ -0,0 +1,14 @@ +// Top-level await plus an extensionless relative TypeScript import, in a +// project without "type": "module" — the template-shaped config that the +// TypeScript config loader must keep supporting (see #3872 / #3676). +import type { ForgeConfig } from '@electron-forge/shared-types'; + +import { getBuildIdentifier } from './src/identifier'; + +const buildIdentifier = await getBuildIdentifier(); + +const config: ForgeConfig = { + buildIdentifier, +}; + +export default config; diff --git a/packages/api/core/spec/fixture/tla_ts_conf/package.json b/packages/api/core/spec/fixture/tla_ts_conf/package.json new file mode 100644 index 0000000000..da951252fb --- /dev/null +++ b/packages/api/core/spec/fixture/tla_ts_conf/package.json @@ -0,0 +1,17 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@electron-forge/shared-types": "*", + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/tla_ts_conf/src/identifier.ts b/packages/api/core/spec/fixture/tla_ts_conf/src/identifier.ts new file mode 100644 index 0000000000..05eb03e141 --- /dev/null +++ b/packages/api/core/spec/fixture/tla_ts_conf/src/identifier.ts @@ -0,0 +1,3 @@ +export async function getBuildIdentifier(): Promise { + return 'tla-relative-import'; +} diff --git a/packages/api/core/spec/fixture/ts7_dep_conf/forge.config.ts b/packages/api/core/spec/fixture/ts7_dep_conf/forge.config.ts new file mode 100644 index 0000000000..4432113035 --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_dep_conf/forge.config.ts @@ -0,0 +1,9 @@ +// This fixture's `node_modules/typescript` is a TypeScript 7-shaped stub +// (`"type": "module"`, no root `"."` export) used to assert the loader's +// actionable error. It deliberately has no imports — the loader must fail +// before ever transpiling this file. +const config = { + buildIdentifier: 'typescript-seven', +}; + +export default config; diff --git a/packages/api/core/spec/fixture/ts7_dep_conf/node_modules/typescript/package.json b/packages/api/core/spec/fixture/ts7_dep_conf/node_modules/typescript/package.json new file mode 100644 index 0000000000..59b0aaa151 --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_dep_conf/node_modules/typescript/package.json @@ -0,0 +1,8 @@ +{ + "name": "typescript", + "version": "7.0.1", + "type": "module", + "exports": { + "./package.json": "./package.json" + } +} diff --git a/packages/api/core/spec/fixture/ts7_dep_conf/package.json b/packages/api/core/spec/fixture/ts7_dep_conf/package.json new file mode 100644 index 0000000000..f59c18baee --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_dep_conf/package.json @@ -0,0 +1,16 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/forge.config.ts b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/forge.config.ts new file mode 100644 index 0000000000..1d3583d7b6 --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/forge.config.ts @@ -0,0 +1,11 @@ +// This fixture's `node_modules/typescript` is a TypeScript 7-shaped stub, +// but `@typescript/typescript6` (re-exporting a classic-API TypeScript) is +// installed side-by-side — the loader must pick it up transparently and +// still transpile this file. +const identifierParts: string[] = ['ts7', 'with', 'ts6', 'fallback']; + +const config = { + buildIdentifier: identifierParts.join('-'), +}; + +export default config; diff --git a/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/index.cjs b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/index.cjs new file mode 100644 index 0000000000..0e943df03e --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/index.cjs @@ -0,0 +1,12 @@ +// Stand-in for Microsoft's real `@typescript/typescript6` package, which +// re-exports the classic TypeScript compiler API for tools that need it +// alongside a TypeScript 7 install. Requiring `typescript` from here would +// hit this fixture's v7-shaped stub, so resolve the monorepo's real +// TypeScript from @electron-forge/core's own dependencies instead. +const { createRequire } = require('node:module'); +const path = require('node:path'); + +// __dirname -> /node_modules/@typescript/typescript6; six levels up +// is packages/api/core. +const coreDir = path.resolve(__dirname, '..', '..', '..', '..', '..', '..'); +module.exports = createRequire(path.join(coreDir, 'noop.js'))('typescript'); diff --git a/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/package.json b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/package.json new file mode 100644 index 0000000000..ac3e8d4607 --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/package.json @@ -0,0 +1,5 @@ +{ + "name": "@typescript/typescript6", + "version": "0.0.0-fixture", + "main": "index.cjs" +} diff --git a/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/typescript/package.json b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/typescript/package.json new file mode 100644 index 0000000000..59b0aaa151 --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/typescript/package.json @@ -0,0 +1,8 @@ +{ + "name": "typescript", + "version": "7.0.1", + "type": "module", + "exports": { + "./package.json": "./package.json" + } +} diff --git a/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/package.json b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/package.json new file mode 100644 index 0000000000..f59c18baee --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/package.json @@ -0,0 +1,16 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/type_error_only_ts_conf/forge.config.ts b/packages/api/core/spec/fixture/type_error_only_ts_conf/forge.config.ts new file mode 100644 index 0000000000..85237ab7a5 --- /dev/null +++ b/packages/api/core/spec/fixture/type_error_only_ts_conf/forge.config.ts @@ -0,0 +1,6 @@ +// Deliberate type error (TS2322) in a config that loads fine at runtime: +// by default the loader must NOT type-check it (zero happy-path cost), but +// with FORGE_TYPECHECK_CONFIG set the load must fail with the diagnostic. +const buildIdentifier: number = 'type-error-only'; + +export default { buildIdentifier }; diff --git a/packages/api/core/spec/fixture/type_error_only_ts_conf/package.json b/packages/api/core/spec/fixture/type_error_only_ts_conf/package.json new file mode 100644 index 0000000000..f59c18baee --- /dev/null +++ b/packages/api/core/spec/fixture/type_error_only_ts_conf/package.json @@ -0,0 +1,16 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/type_error_only_ts_conf/tsconfig.json b/packages/api/core/spec/fixture/type_error_only_ts_conf/tsconfig.json new file mode 100644 index 0000000000..ea5b317f6e --- /dev/null +++ b/packages/api/core/spec/fixture/type_error_only_ts_conf/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/packages/api/core/spec/fixture/typed_error_ts_conf/forge.config.ts b/packages/api/core/spec/fixture/typed_error_ts_conf/forge.config.ts new file mode 100644 index 0000000000..692345f763 --- /dev/null +++ b/packages/api/core/spec/fixture/typed_error_ts_conf/forge.config.ts @@ -0,0 +1,7 @@ +import { sep } from 'node:path'; + +// Deliberate type error (TS2339) that also crashes at runtime: the loader +// must surface the TypeScript diagnostic instead of the raw runtime error. +const buildIdentifier: string = sep.thisMethodDoesNotExist(); + +export default { buildIdentifier }; diff --git a/packages/api/core/spec/fixture/typed_error_ts_conf/package.json b/packages/api/core/spec/fixture/typed_error_ts_conf/package.json new file mode 100644 index 0000000000..f59c18baee --- /dev/null +++ b/packages/api/core/spec/fixture/typed_error_ts_conf/package.json @@ -0,0 +1,16 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "99.99.99" + } +} diff --git a/packages/api/core/spec/fixture/typed_error_ts_conf/tsconfig.json b/packages/api/core/spec/fixture/typed_error_ts_conf/tsconfig.json new file mode 100644 index 0000000000..b4cbbb843b --- /dev/null +++ b/packages/api/core/spec/fixture/typed_error_ts_conf/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/packages/api/core/spec/fixture/webpack_dep_ts_conf/forge.config.ts b/packages/api/core/spec/fixture/webpack_dep_ts_conf/forge.config.ts new file mode 100644 index 0000000000..171806d7e2 --- /dev/null +++ b/packages/api/core/spec/fixture/webpack_dep_ts_conf/forge.config.ts @@ -0,0 +1,19 @@ +// Regression fixture for https://github.com/electron/forge/issues/3949: a +// named import from a CJS package (webpack) whose class statics are assigned +// after the class definition. The loaded values must be the exact same +// instances the loading process sees — a config loader that evaluates +// dependencies in a parallel module system hands out a duplicate webpack. +import { Compilation } from 'webpack'; + +import type { ForgeConfig } from '@electron-forge/shared-types'; + +const config: ForgeConfig & { + stage: number; + compilationClass: typeof Compilation; +} = { + buildIdentifier: 'webpack-dep', + stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, + compilationClass: Compilation, +}; + +export default config; diff --git a/packages/api/core/spec/fixture/webpack_dep_ts_conf/package.json b/packages/api/core/spec/fixture/webpack_dep_ts_conf/package.json new file mode 100644 index 0000000000..d3a43b26f8 --- /dev/null +++ b/packages/api/core/spec/fixture/webpack_dep_ts_conf/package.json @@ -0,0 +1,18 @@ +{ + "name": "", + "productName": "", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@electron-forge/shared-types": "*", + "electron": "99.99.99", + "webpack": "^5.69.1" + } +} diff --git a/packages/api/core/src/util/forge-config.ts b/packages/api/core/src/util/forge-config.ts index c4e5a8f016..a1f3de5014 100644 --- a/packages/api/core/src/util/forge-config.ts +++ b/packages/api/core/src/util/forge-config.ts @@ -2,9 +2,9 @@ import path from 'node:path'; import { ForgeConfig, ResolvedForgeConfig } from '@electron-forge/shared-types'; import fs from 'graceful-fs'; -import { createJiti } from 'jiti'; import { runMutatingHook } from './hook.js'; +import { loadTypeScriptConfig } from './load-ts-config.js'; import PluginInterface from './plugin-interface.js'; import { readRawPackageJson } from './read-package-json.js'; import { pathToFileURL } from 'node:url'; @@ -164,15 +164,12 @@ export default async (dir: string): Promise => { ) { const forgeConfigPath = path.resolve(dir, forgeConfig); try { - let loadFn; - if (['.cts', '.mts', '.ts'].includes(path.extname(forgeConfigPath))) { - const jiti = createJiti(import.meta.filename); - loadFn = jiti.import; - } // The loaded "config" could potentially be a static forge config, ESM module or async function let loaded: MaybeESM; - if (loadFn) { - loaded = await loadFn(forgeConfigPath); + if (['.cts', '.mts', '.ts'].includes(path.extname(forgeConfigPath))) { + loaded = await loadTypeScriptConfig< + MaybeESM + >(dir, forgeConfigPath); } else { loaded = await import(pathToFileURL(forgeConfigPath).toString()); } diff --git a/packages/api/core/src/util/load-ts-config.ts b/packages/api/core/src/util/load-ts-config.ts new file mode 100644 index 0000000000..e1bb9228ca --- /dev/null +++ b/packages/api/core/src/util/load-ts-config.ts @@ -0,0 +1,709 @@ +import crypto from 'node:crypto'; +import { createRequire, isBuiltin } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import fs from 'graceful-fs'; + +import type * as ts from 'typescript'; + +/** + * Zero-dependency TypeScript config loader. + * + * Loads a `.ts`/`.mts`/`.cts` Forge config using the USER PROJECT's own + * `typescript` package (resolved from the project directory, so Forge itself + * ships no compiler). The config file and every project-local TypeScript file + * reachable through static analysis are transpiled to ES2022 ESM (CommonJS + * for `.cts` sources) and written as uniquely-named temp sibling files next + * to their sources. The emitted JavaScript is then post-processed via the + * TypeScript AST: + * + * - relative / tsconfig-`paths` specifiers -> rewritten to the temp sibling + * - bare specifiers resolving to CJS deps -> replaced with `createRequire` + * bindings, so the dependency instance is identical to a plain `require()` + * anywhere else in the process (single shared `Module._cache`) + * - bare specifiers resolving to ESM deps -> left as native `import` + * (keeps dependencies that use top-level await working) + * - dynamic `import("./relative-ts")` -> hoisted static import, so it + * still works after the temp files are deleted (e.g. post-load hooks) + * + * The entry temp file is evaluated with a native `await import()` and every + * temp file is deleted in `finally`. Nothing process-global is ever + * registered and unique temp names mean no stale ESM-cache hits. + * + * This "one real module system" property is what fixes + * https://github.com/electron/forge/issues/3949: loaders that evaluate the + * config's node_modules dependencies through a parallel module system (like + * jiti) hand the config a *second copy* of packages such as webpack, so + * `Compilation.PROCESS_ASSETS_STAGE_*` comparisons inside the webpack plugin + * silently fail and `index.html` is dropped from packaged apps. + * + * Because the user's own compiler is available, load failures are upgraded to + * proper type errors: if evaluating the config throws, the config is + * type-checked and any diagnostics are surfaced instead of the raw crash. + * Setting FORGE_TYPECHECK_CONFIG=1 type-checks the config before every load. + */ + +type TypeScriptModule = typeof ts; + +const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); + +/** Options that only make sense for a real build of the user's `src` tree + * and cause false positives (e.g. TS6059) when type-checking the config. */ +const CHECK_INCOMPATIBLE_OPTIONS = [ + 'composite', + 'declaration', + 'declarationDir', + 'declarationMap', + 'emitDeclarationOnly', + 'incremental', + 'inlineSourceMap', + 'inlineSources', + 'out', + 'outDir', + 'outFile', + 'rootDir', + 'rootDirs', + 'sourceMap', + 'sourceRoot', + 'tsBuildInfoFile', +]; + +function loadUserTypeScript(projectDir: string): TypeScriptModule { + const require = createRequire(path.join(projectDir, 'noop.js')); + // Resolve the version from package.json first: TypeScript 7+ (the native + // compiler) is `"type": "module"` with no root `"."` export, so a plain + // `require('typescript')` would die with ERR_PACKAGE_PATH_NOT_EXPORTED + // before any feature detection could run. `typescript/package.json` stays + // exported across every version this loader can meet. + let pkgPath: string; + try { + pkgPath = require.resolve('typescript/package.json'); + } catch { + throw new Error( + `Loading a TypeScript Forge config requires the "typescript" package (version 4.7.0 or later) to be installed in your project (searched from ${projectDir}). ` + + `Forge's TypeScript templates include it by default — run \`npm install --save-dev typescript\` (or the equivalent for your package manager) and try again.`, + ); + } + const version: string = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; + if (Number(version.split('.')[0]) >= 7) { + // TypeScript 7 drops the JavaScript compiler API this loader is built on + // (`transpileModule`, `resolveModuleName`, ...) in favor of a JSON-RPC + // interface to the native compiler. Microsoft's prescribed path for + // API-dependent tools is a side-by-side TypeScript 6 — use it + // transparently when the project has it installed. + try { + const ts6 = require('@typescript/typescript6') as TypeScriptModule; + if (typeof ts6.transpileModule === 'function') return ts6; + } catch { + // Not installed — fall through to the actionable error below. + } + throw new Error( + `Your project's "typescript" dependency is version ${version}, but TypeScript 7+ (the native compiler) no longer provides the JavaScript compiler API that Forge uses to load TypeScript config files. ` + + `Until Forge supports the TypeScript 7 API, install TypeScript 6 alongside it — run \`npm install --save-dev @typescript/typescript6\` (or pin \`typescript@6\`) and try again.`, + ); + } + return require('typescript') as TypeScriptModule; +} + +function readTsconfigOptions( + typescript: TypeScriptModule, + configPath: string, +): ts.CompilerOptions { + const found = typescript.findConfigFile( + path.dirname(configPath), + typescript.sys.fileExists, + 'tsconfig.json', + ); + if (!found) return {}; + const read = typescript.readConfigFile(found, typescript.sys.readFile); + if (read.error) return {}; + const parsed = typescript.parseJsonConfigFileContent( + read.config, + typescript.sys, + path.dirname(found), + undefined, + // Passing the config file name sets `configFilePath`, so `types` / + // `typeRoots` resolve relative to the project rather than the cwd. + found, + ); + return parsed.options ?? {}; +} + +function bundlerModuleResolution( + typescript: TypeScriptModule, +): ts.ModuleResolutionKind { + // `Bundler` needs TypeScript >= 5.0; fall back to node resolution before that. + const kinds = typescript.ModuleResolutionKind as unknown as Record< + string, + ts.ModuleResolutionKind | undefined + >; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- one of the two always exists + return (kinds.Bundler ?? kinds.NodeJs)!; +} + +function nearestPackageType(fromDir: string): 'module' | 'commonjs' { + let dir = fromDir; + for (;;) { + const pkg = path.join(dir, 'package.json'); + if (fs.existsSync(pkg)) { + try { + return JSON.parse(fs.readFileSync(pkg, 'utf8')).type === 'module' + ? 'module' + : 'commonjs'; + } catch { + return 'commonjs'; + } + } + const parent = path.dirname(dir); + if (parent === dir) return 'commonjs'; + dir = parent; + } +} + +/** + * Classify a specifier that does NOT resolve to a project-local TypeScript + * file, using Node's real resolution from the importing file. + */ +function classifyDependency( + specifier: string, + fromFile: string, +): 'builtin' | 'esm' | 'cjs' { + if (isBuiltin(specifier)) return 'builtin'; + const require = createRequire(fromFile); + let resolved: string; + try { + resolved = require.resolve(specifier); + } catch { + // ESM-only package (no "require" condition) or genuinely missing — leave + // it to the native import, which either works or throws the right error. + return 'esm'; + } + if (resolved.endsWith('.mjs')) return 'esm'; + if (!resolved.endsWith('.js')) return 'cjs'; // .cjs / .json / .node + return nearestPackageType(path.dirname(resolved)) === 'module' + ? 'esm' + : 'cjs'; +} + +/** Shift an inline source map down by `lineOffset` lines (for the banner). */ +function shiftInlineSourceMap(code: string, lineOffset: number): string { + return code.replace( + /(\/\/# sourceMappingURL=data:application\/json;base64,)([A-Za-z0-9+/=]+)/, + (_match, prefix: string, base64: string) => { + try { + const map = JSON.parse(Buffer.from(base64, 'base64').toString('utf8')); + map.mappings = ';'.repeat(lineOffset) + (map.mappings ?? ''); + return prefix + Buffer.from(JSON.stringify(map)).toString('base64'); + } catch { + return prefix + base64; + } + }, + ); +} + +/** + * Type-check the config with the user's own TypeScript, returning formatted + * diagnostics (or undefined if the config is clean). Only ever runs when the + * config failed to load or FORGE_TYPECHECK_CONFIG is set — the happy path + * never pays for a full program creation. + */ +function typeCheckConfig( + typescript: TypeScriptModule, + entry: string, + projectOptions: ts.CompilerOptions, + projectDir: string, +): string | undefined { + const options: ts.CompilerOptions = { ...projectOptions }; + for (const key of CHECK_INCOMPATIBLE_OPTIONS) { + delete options[key]; + } + // Check under the same semantics the loader evaluates with. + options.noEmit = true; + options.skipLibCheck = true; + options.allowImportingTsExtensions = true; + options.moduleResolution = bundlerModuleResolution(typescript); + options.module = typescript.ModuleKind.ESNext; + options.target = + typescript.ScriptTarget.ES2022 ?? typescript.ScriptTarget.ESNext; + options.esModuleInterop = true; + + const host = typescript.createCompilerHost(options); + // Forge may run from anywhere — resolve everything from the project. + host.getCurrentDirectory = () => projectDir; + const program = typescript.createProgram([entry], options, host); + const diagnostics = typescript + .getPreEmitDiagnostics(program) + .filter((d) => d.category === typescript.DiagnosticCategory.Error); + if (diagnostics.length === 0) return undefined; + return typescript.formatDiagnostics(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => projectDir, + getNewLine: () => '\n', + }); +} + +interface Edit { + start: number; + end: number; + text: string; +} + +function applyEdits(code: string, edits: Edit[]): string { + // Apply in reverse so earlier edit positions stay valid. + let output = code; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + output = output.slice(0, edit.start) + edit.text + output.slice(edit.end); + } + return output; +} + +async function evaluateConfig( + typescript: TypeScriptModule, + entry: string, + projectOptions: ts.CompilerOptions, + projectDir: string, +): Promise { + const runId = crypto.randomBytes(6).toString('hex'); + + const resolutionOptions: ts.CompilerOptions = { + ...projectOptions, + moduleResolution: bundlerModuleResolution(typescript), + allowImportingTsExtensions: true, + noEmit: true, + }; + + /** Resolve a specifier to a project-local TypeScript file (or undefined). */ + const resolveProjectTs = ( + specifier: string, + containingFile: string, + ): string | undefined => { + const result = typescript.resolveModuleName( + specifier, + containingFile, + resolutionOptions, + typescript.sys, + ); + const resolved = result.resolvedModule; + if (!resolved || resolved.isExternalLibraryImport) return undefined; + if (!TS_EXTENSIONS.has(resolved.extension)) return undefined; + return path.resolve(resolved.resolvedFileName); + }; + + // ---- 1. Collect the project-local TypeScript graph ----------------------- + const graph = new Set(); + const queue = [entry]; + while (queue.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above + const file = queue.pop()!; + if (graph.has(file)) continue; + graph.add(file); + const preprocessed = typescript.preProcessFile( + fs.readFileSync(file, 'utf8'), + true, + true, + ); + for (const imported of preprocessed.importedFiles) { + const resolved = resolveProjectTs(imported.fileName, file); + if (resolved && !graph.has(resolved)) queue.push(resolved); + } + } + + // Unique temp names per load: no stale ESM-cache hits across reloads. + const tempOf = new Map(); + for (const file of graph) { + const emitExt = file.endsWith('.cts') ? '.cjs' : '.mjs'; + tempOf.set( + file, + path.join( + path.dirname(file), + `${path.basename(file)}.forge-${runId}${emitExt}`, + ), + ); + } + + /** Relative specifier from `fromOriginal`'s directory to `target`'s temp. + * Temps are siblings of their sources, so other relative specifiers in the + * emitted code keep resolving unchanged. */ + const relativeToTemp = (fromOriginal: string, target: string): string => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- target is always in the graph + const temp = tempOf.get(target)!; + let rel = path + .relative(path.dirname(fromOriginal), temp) + .split(path.sep) + .join('/'); + if (!rel.startsWith('.')) rel = `./${rel}`; + return rel; + }; + + // ---- 2. Transpile + rewrite each file ------------------------------------- + const transpileFile = (file: string): string => { + const isCts = file.endsWith('.cts'); + const emitOptions: ts.CompilerOptions = { + module: isCts + ? typescript.ModuleKind.CommonJS + : typescript.ModuleKind.ESNext, + target: typescript.ScriptTarget.ES2022 ?? typescript.ScriptTarget.ESNext, + esModuleInterop: true, + isolatedModules: true, + // Inline source maps so stack traces can point back at the TypeScript + // source even after the temp files are deleted. + inlineSourceMap: true, + inlineSources: true, + }; + // Carry over the emit-affecting options from the user's tsconfig. + for (const option of [ + 'experimentalDecorators', + 'emitDecoratorMetadata', + 'jsx', + 'jsxFactory', + 'jsxFragmentFactory', + 'jsxImportSource', + 'useDefineForClassFields', + ]) { + if (projectOptions[option] !== undefined) { + emitOptions[option] = projectOptions[option]; + } + } + const output = typescript.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: emitOptions, + fileName: file, + reportDiagnostics: true, + }); + const fatal = (output.diagnostics ?? []).filter( + (d) => d.category === typescript.DiagnosticCategory.Error, + ); + if (fatal.length > 0) { + throw new Error( + `Failed to transpile ${file}:\n` + + typescript.formatDiagnostics(fatal, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => projectDir, + getNewLine: () => '\n', + }), + ); + } + return isCts + ? rewriteCjs(output.outputText, file) + : rewriteEsm(output.outputText, file); + }; + + /** Rewrite the ESM emit of `file` (parse emitted JS, string-edit by position). */ + const rewriteEsm = (code: string, file: string): string => { + const sourceFile = typescript.createSourceFile( + `${file}.mjs`, + code, + typescript.ScriptTarget.Latest, + true, + typescript.ScriptKind.JS, + ); + const edits: Edit[] = []; + const hoisted: string[] = []; + let counter = 0; + + const cjsNamedBindings = ( + elements: readonly ts.ImportSpecifier[], + moduleVar: string, + ): string => + elements + .map( + (el) => + `const ${el.name.text} = ${moduleVar}[${JSON.stringify( + (el.propertyName ?? el.name).text, + )}];`, + ) + .join(' '); + + const handleTopLevel = (statement: ts.Statement): void => { + if ( + typescript.isImportDeclaration(statement) && + typescript.isStringLiteralLike(statement.moduleSpecifier) + ) { + const specifier = statement.moduleSpecifier.text; + const tsFile = resolveProjectTs(specifier, file); + if (tsFile) { + edits.push({ + start: statement.moduleSpecifier.getStart(sourceFile), + end: statement.moduleSpecifier.getEnd(), + text: JSON.stringify(relativeToTemp(file, tsFile)), + }); + return; + } + // Builtins and ESM deps stay native imports (single instance via the + // real ESM loader; top-level-await deps keep working). CJS deps are + // rewritten to the real `require`, sharing Node's CJS module cache + // with the rest of the process (#3949). + if (classifyDependency(specifier, file) !== 'cjs') return; + counter += 1; + const moduleVar = `__forgeMod_${counter}`; + const parts = [ + `const ${moduleVar} = __forgeRequire(${JSON.stringify(specifier)});`, + ]; + const clause = statement.importClause; + if (clause) { + if (clause.name) { + parts.push( + `const ${clause.name.text} = __forgeInterop(${moduleVar});`, + ); + } + if (clause.namedBindings) { + if (typescript.isNamespaceImport(clause.namedBindings)) { + parts.push( + `const ${clause.namedBindings.name.text} = __forgeStar(${moduleVar});`, + ); + } else { + parts.push( + cjsNamedBindings(clause.namedBindings.elements, moduleVar), + ); + } + } + } + edits.push({ + start: statement.getStart(sourceFile), + end: statement.getEnd(), + text: parts.join(' '), + }); + } else if ( + typescript.isExportDeclaration(statement) && + statement.moduleSpecifier && + typescript.isStringLiteralLike(statement.moduleSpecifier) + ) { + const specifier = statement.moduleSpecifier.text; + const tsFile = resolveProjectTs(specifier, file); + if (tsFile) { + edits.push({ + start: statement.moduleSpecifier.getStart(sourceFile), + end: statement.moduleSpecifier.getEnd(), + text: JSON.stringify(relativeToTemp(file, tsFile)), + }); + return; + } + if (classifyDependency(specifier, file) !== 'cjs') return; + if ( + !statement.exportClause || + !typescript.isNamedExports(statement.exportClause) + ) { + throw new Error( + `\`export * from "${specifier}"\` re-exports from a CommonJS dependency are not supported in a TypeScript Forge config (${file}). Import the module and re-export explicitly.`, + ); + } + counter += 1; + const moduleVar = `__forgeMod_${counter}`; + const parts = [ + `const ${moduleVar} = __forgeRequire(${JSON.stringify(specifier)});`, + ]; + const exportedNames: string[] = []; + for (const el of statement.exportClause.elements) { + const from = (el.propertyName ?? el.name).text; + const local = `__forgeReExport_${counter}_${exportedNames.length}`; + parts.push( + from === 'default' + ? `const ${local} = __forgeInterop(${moduleVar});` + : `const ${local} = ${moduleVar}[${JSON.stringify(from)}];`, + ); + exportedNames.push(`${local} as ${el.name.text}`); + } + parts.push(`export { ${exportedNames.join(', ')} };`); + edits.push({ + start: statement.getStart(sourceFile), + end: statement.getEnd(), + text: parts.join(' '), + }); + } + }; + + const visitExpressions = (node: ts.Node): void => { + if ( + typescript.isCallExpression(node) && + node.arguments.length === 1 && + typescript.isStringLiteralLike(node.arguments[0]) + ) { + const specifier = node.arguments[0].text; + if (node.expression.kind === typescript.SyntaxKind.ImportKeyword) { + // Dynamic import of a project TS file -> hoisted static import, so + // it still resolves after the temps are deleted (post-load hooks). + const tsFile = resolveProjectTs(specifier, file); + if (tsFile) { + counter += 1; + hoisted.push( + `import * as __forgeDynamic_${counter} from ${JSON.stringify( + relativeToTemp(file, tsFile), + )};`, + ); + edits.push({ + start: node.getStart(sourceFile), + end: node.getEnd(), + text: `Promise.resolve(__forgeDynamic_${counter})`, + }); + } + // Bare / non-TS dynamic imports stay native. + } else if ( + typescript.isIdentifier(node.expression) && + node.expression.text === 'require' + ) { + // Bare require("./relative-ts") -> require the ESM temp + // (require(esm) works on Node >= 22.12 for TLA-free subgraphs). + const tsFile = resolveProjectTs(specifier, file); + if (tsFile) { + edits.push({ + start: node.arguments[0].getStart(sourceFile), + end: node.arguments[0].getEnd(), + text: JSON.stringify(relativeToTemp(file, tsFile)), + }); + } + } + } + typescript.forEachChild(node, visitExpressions); + }; + + for (const statement of sourceFile.statements) handleTopLevel(statement); + visitExpressions(sourceFile); + + // The banner is a single line so the inline source map only shifts by one. + // `require`, `__filename` and `__dirname` all point at the ORIGINAL file. + const banner = [ + `import { createRequire as __forgeCreateRequire } from "node:module";`, + `const __forgeRequire = __forgeCreateRequire(${JSON.stringify(file)});`, + `const require = __forgeRequire;`, + `const __filename = ${JSON.stringify(file)};`, + `const __dirname = ${JSON.stringify(path.dirname(file))};`, + `const __forgeInterop = (m) => (m && m.__esModule ? m["default"] : m);`, + `const __forgeStar = (m) => { if (m && m.__esModule) return m; const ns = { default: m }; for (const k in m) { if (k !== "default") ns[k] = m[k]; } return ns; };`, + ...hoisted, + ].join(' '); + return `${banner}\n${shiftInlineSourceMap(applyEdits(code, edits), 1)}`; + }; + + /** Rewrite CJS emit (`.cts`): only relative-TS require()/import() specifiers + * need fixing — everything else already goes through the real `require`. */ + const rewriteCjs = (code: string, file: string): string => { + const sourceFile = typescript.createSourceFile( + `${file}.cjs`, + code, + typescript.ScriptTarget.Latest, + true, + typescript.ScriptKind.JS, + ); + const edits: Edit[] = []; + const visit = (node: ts.Node): void => { + const isRequireCall = + typescript.isCallExpression(node) && + typescript.isIdentifier(node.expression) && + node.expression.text === 'require'; + const isDynamicImport = + typescript.isCallExpression(node) && + node.expression.kind === typescript.SyntaxKind.ImportKeyword; + if ( + (isRequireCall || isDynamicImport) && + node.arguments.length === 1 && + typescript.isStringLiteralLike(node.arguments[0]) + ) { + const tsFile = resolveProjectTs(node.arguments[0].text, file); + if (tsFile) { + edits.push({ + start: node.arguments[0].getStart(sourceFile), + end: node.arguments[0].getEnd(), + text: JSON.stringify(relativeToTemp(file, tsFile)), + }); + } + } + typescript.forEachChild(node, visit); + }; + visit(sourceFile); + return applyEdits(code, edits); + }; + + // ---- 3. Write temps, import the entry, always clean up -------------------- + const written: string[] = []; + try { + for (const file of graph) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- populated for the whole graph + const temp = tempOf.get(file)!; + fs.writeFileSync(temp, transpileFile(file)); + written.push(temp); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- entry is always in the graph + const namespace = await import(pathToFileURL(tempOf.get(entry)!).href); + let exported: unknown = + namespace && 'default' in namespace ? namespace.default : namespace; + // A `.cts` config emits `exports.default` behind an `__esModule` marker, + // which the namespace above wraps once more — unwrap it. + if ( + exported && + typeof exported === 'object' && + '__esModule' in exported && + 'default' in exported + ) { + exported = exported.default; + } + return exported as T; + } finally { + for (const temp of written) { + try { + fs.rmSync(temp, { force: true }); + } catch { + // Best effort — never mask the actual load result. + } + } + } +} + +/** + * Load a TypeScript Forge config file. See the module docs above for how. + * + * @param projectDir - the directory of the project whose config is loaded + * (used to resolve the project's own `typescript` package) + * @param configPath - absolute path to the `.ts`/`.mts`/`.cts` config file + */ +export async function loadTypeScriptConfig( + projectDir: string, + configPath: string, +): Promise { + const typescript = loadUserTypeScript(projectDir); + const entry = path.resolve(configPath); + const projectOptions = readTsconfigOptions(typescript, entry); + + if (process.env.FORGE_TYPECHECK_CONFIG) { + const diagnostics = typeCheckConfig( + typescript, + entry, + projectOptions, + projectDir, + ); + if (diagnostics) { + throw new Error( + `Type checking of your Forge config failed:\n\n${diagnostics}`, + ); + } + } + + try { + return await evaluateConfig( + typescript, + entry, + projectOptions, + projectDir, + ); + } catch (err) { + // The config crashed at runtime. Before rethrowing, type-check it — a + // proper diagnostic (e.g. a typo'd import) beats a runtime stack trace. + let diagnostics: string | undefined; + try { + diagnostics = typeCheckConfig( + typescript, + entry, + projectOptions, + projectDir, + ); + } catch { + // If the type check itself blows up, surface the original error. + } + if (diagnostics) { + throw new Error( + `Failed to load your Forge config — it has TypeScript type errors:\n\n${diagnostics}`, + { cause: err }, + ); + } + throw err; + } +} diff --git a/packages/template/vite-typescript/tmpl/package.json b/packages/template/vite-typescript/tmpl/package.json index 9ea4159d04..df4e64ce7d 100644 --- a/packages/template/vite-typescript/tmpl/package.json +++ b/packages/template/vite-typescript/tmpl/package.json @@ -8,6 +8,7 @@ "devDependencies": { "@electron-forge/plugin-vite": "ELECTRON_FORGE/VERSION", "@types/electron-squirrel-startup": "^1.0.2", + "@types/node": "^22.10.7", "oxfmt": "^0.41.0", "oxlint": "^1.0.0", "typescript": "^6.0.0", diff --git a/packages/template/vite-typescript/tmpl/tsconfig.json b/packages/template/vite-typescript/tmpl/tsconfig.json index 500ecdaf24..83031e013f 100644 --- a/packages/template/vite-typescript/tmpl/tsconfig.json +++ b/packages/template/vite-typescript/tmpl/tsconfig.json @@ -10,7 +10,8 @@ "sourceMap": true, "rootDir": "src", "outDir": "dist", - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"] } diff --git a/packages/template/webpack-typescript/tmpl/package.json b/packages/template/webpack-typescript/tmpl/package.json index 58ec83aca5..7c8bec507b 100644 --- a/packages/template/webpack-typescript/tmpl/package.json +++ b/packages/template/webpack-typescript/tmpl/package.json @@ -7,6 +7,7 @@ }, "devDependencies": { "@electron-forge/plugin-webpack": "ELECTRON_FORGE/VERSION", + "@types/node": "^22.10.7", "@vercel/webpack-asset-relocator-loader": "1.7.3", "css-loader": "^6.0.0", "fork-ts-checker-webpack-plugin": "^7.2.13", diff --git a/packages/template/webpack-typescript/tmpl/tsconfig.json b/packages/template/webpack-typescript/tmpl/tsconfig.json index 500ecdaf24..83031e013f 100644 --- a/packages/template/webpack-typescript/tmpl/tsconfig.json +++ b/packages/template/webpack-typescript/tmpl/tsconfig.json @@ -10,7 +10,8 @@ "sourceMap": true, "rootDir": "src", "outDir": "dist", - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"] } diff --git a/yarn.lock b/yarn.lock index bf9f476d2b..f877afe43a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -822,9 +822,15 @@ __metadata: electron-forge-template-fixture-two: "portal:./spec/fixture/electron-forge-template-fixture" electron-installer-common: "npm:^0.10.2" graceful-fs: "npm:^4.2.11" - jiti: "npm:^2.4.2" listr2: "npm:^7.0.2" + typescript: "npm:^6.0.2" vitest: "catalog:" + webpack: "npm:^5.69.1" + peerDependencies: + typescript: ">=4.7.0 <7" + peerDependenciesMeta: + typescript: + optional: true languageName: unknown linkType: soft @@ -11731,7 +11737,7 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^2.4.2, jiti@npm:^2.6.0": +"jiti@npm:^2.6.0": version: 2.6.1 resolution: "jiti@npm:2.6.1" bin: