From 291e0968ad0a54a9b47edf0381c34881df09f378 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:03:32 +0000 Subject: [PATCH 1/3] feat(core): replace jiti with a zero-dependency TypeScript config loader jiti evaluates a config's node_modules dependencies through its own parallel module system, so packages loaded by the config (e.g. webpack) are duplicate instances of the ones the rest of the process uses. For TypeScript configs using the webpack plugin this made Compilation.PROCESS_ASSETS_STAGE_* comparisons silently fail and dropped index.html from packaged apps (#3949). The new loader uses the user project's own typescript package instead: the config's project-local TS graph is transpiled to temp sibling files, import specifiers are rewritten so CJS dependencies go through the real require() (shared Module._cache) and ESM dependencies stay native imports, and the entry is evaluated with a native await import(). All temp files are deleted in a finally block and nothing process-global is registered. Because the user's own compiler is at hand, configs that fail to load are type-checked and the load error is replaced with proper TypeScript diagnostics; FORGE_TYPECHECK_CONFIG=1 opts in to type-checking on every load. The TypeScript templates' tsconfigs now include the node types explicitly so the stock configs type-check cleanly under TypeScript 6 (which no longer includes @types packages automatically). Removes the jiti dependency and adds no runtime dependency in its place. Fixes #3949 --- .gitignore | 2 + packages/api/core/package.json | 5 +- .../core/spec/fast/util/forge-config.spec.ts | 124 ++++ .../fixture/esm_dep_ts_conf/forge.config.ts | 13 + .../node_modules/esm-tla-dep/index.js | 3 + .../node_modules/esm-tla-dep/package.json | 6 + .../spec/fixture/esm_dep_ts_conf/package.json | 18 + .../missing_ts_dep_conf/forge.config.ts | 8 + .../fixture/missing_ts_dep_conf/package.json | 16 + .../fixture/paths_ts_conf/forge.config.ts | 11 + .../spec/fixture/paths_ts_conf/package.json | 17 + .../fixture/paths_ts_conf/src/identifier.ts | 3 + .../spec/fixture/paths_ts_conf/tsconfig.json | 13 + .../spec/fixture/tla_ts_conf/forge.config.ts | 14 + .../spec/fixture/tla_ts_conf/package.json | 17 + .../fixture/tla_ts_conf/src/identifier.ts | 3 + .../type_error_only_ts_conf/forge.config.ts | 6 + .../type_error_only_ts_conf/package.json | 16 + .../type_error_only_ts_conf/tsconfig.json | 10 + .../typed_error_ts_conf/forge.config.ts | 7 + .../fixture/typed_error_ts_conf/package.json | 16 + .../fixture/typed_error_ts_conf/tsconfig.json | 11 + .../webpack_dep_ts_conf/forge.config.ts | 19 + .../fixture/webpack_dep_ts_conf/package.json | 18 + packages/api/core/src/util/forge-config.ts | 13 +- packages/api/core/src/util/load-ts-config.ts | 686 ++++++++++++++++++ .../vite-typescript/tmpl/package.json | 1 + .../vite-typescript/tmpl/tsconfig.json | 3 +- .../webpack-typescript/tmpl/package.json | 1 + .../webpack-typescript/tmpl/tsconfig.json | 3 +- yarn.lock | 5 +- 31 files changed, 1074 insertions(+), 14 deletions(-) create mode 100644 packages/api/core/spec/fixture/esm_dep_ts_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/index.js create mode 100644 packages/api/core/spec/fixture/esm_dep_ts_conf/node_modules/esm-tla-dep/package.json create mode 100644 packages/api/core/spec/fixture/esm_dep_ts_conf/package.json create mode 100644 packages/api/core/spec/fixture/missing_ts_dep_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/missing_ts_dep_conf/package.json create mode 100644 packages/api/core/spec/fixture/paths_ts_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/paths_ts_conf/package.json create mode 100644 packages/api/core/spec/fixture/paths_ts_conf/src/identifier.ts create mode 100644 packages/api/core/spec/fixture/paths_ts_conf/tsconfig.json create mode 100644 packages/api/core/spec/fixture/tla_ts_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/tla_ts_conf/package.json create mode 100644 packages/api/core/spec/fixture/tla_ts_conf/src/identifier.ts create mode 100644 packages/api/core/spec/fixture/type_error_only_ts_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/type_error_only_ts_conf/package.json create mode 100644 packages/api/core/spec/fixture/type_error_only_ts_conf/tsconfig.json create mode 100644 packages/api/core/spec/fixture/typed_error_ts_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/typed_error_ts_conf/package.json create mode 100644 packages/api/core/spec/fixture/typed_error_ts_conf/tsconfig.json create mode 100644 packages/api/core/spec/fixture/webpack_dep_ts_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/webpack_dep_ts_conf/package.json create mode 100644 packages/api/core/src/util/load-ts-config.ts diff --git a/.gitignore b/.gitignore index 4b8ac00ec4..07e627fc02 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ 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/**/tslint.json packages/**/yarn.lock packages/*/*/index.ts diff --git a/packages/api/core/package.json b/packages/api/core/package.json index 07148792d1..8160c67045 100644 --- a/packages/api/core/package.json +++ b/packages/api/core/package.json @@ -22,7 +22,9 @@ "@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" }, "dependencies": { "@electron-forge/core-utils": "workspace:*", @@ -35,7 +37,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..b11f584e91 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,127 @@ 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 to be installed/, + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + spy.mockRestore(); + } + }); + + 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/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..49cc24cd7d --- /dev/null +++ b/packages/api/core/src/util/load-ts-config.ts @@ -0,0 +1,686 @@ +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')); + let tsPath: string; + try { + tsPath = require.resolve('typescript'); + } catch { + throw new Error( + `Loading a TypeScript Forge config requires the "typescript" package 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.`, + ); + } + return require(tsPath) 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..ebee25b335 100644 --- a/yarn.lock +++ b/yarn.lock @@ -822,9 +822,10 @@ __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" languageName: unknown linkType: soft @@ -11731,7 +11732,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: From a48fc5087ab4d22066906ec2a1287bc52ea67ada Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 17:18:30 +0000 Subject: [PATCH 2/3] feat(core): declare typescript as an optional peer dep and guard against TypeScript 7 The config loader needs the project's typescript package, so declare it as an optional peerDependency of @electron-forge/core with the empirically verified range ">=4.7.0 <7": - Floor: the full loader behavior matrix (extensionless relative TS imports, single-instance webpack Compilation identity, top-level await, a .cts helper imported as ./helper.cjs, async-function configs) passes on typescript 4.7.2/4.7.4/4.8.4 against a template-shaped project; 4.4 fails (no .cjs -> .cts resolution mapping). 4.5/4.6 pass incidentally, but 4.7 is the first release where .cts/.mts support is documented. - Cap: TypeScript 7 (the native compiler, already published under the plain "typescript" name on the rc dist-tag) is "type": "module" with no root "." export and drops the JavaScript compiler API this loader is built on, so require('typescript') would die with ERR_PACKAGE_PATH_NOT_EXPORTED before any feature detection. The loader now reads the version from typescript/package.json (still exported in 7.x) before requiring the module. On v7+ it transparently falls back to a side-by-side @typescript/typescript6 install (Microsoft's escape hatch for API-dependent tools; verified against the real typescript@7.0.1-rc and @typescript/typescript6@6.0.1) and otherwise throws an actionable error suggesting exactly that. The missing-typescript error now also states the minimum supported version. --- .gitignore | 2 ++ knip.json | 1 + packages/api/core/package.json | 8 +++++ .../core/spec/fast/util/forge-config.spec.ts | 28 ++++++++++++++++- .../spec/fixture/ts7_dep_conf/forge.config.ts | 9 ++++++ .../node_modules/typescript/package.json | 8 +++++ .../spec/fixture/ts7_dep_conf/package.json | 17 ++++++++++ .../ts7_with_ts6_dep_conf/forge.config.ts | 11 +++++++ .../@typescript/typescript6/index.cjs | 12 +++++++ .../@typescript/typescript6/package.json | 5 +++ .../node_modules/typescript/package.json | 8 +++++ .../ts7_with_ts6_dep_conf/package.json | 18 +++++++++++ packages/api/core/src/util/load-ts-config.ts | 31 ++++++++++++++++--- yarn.lock | 5 +++ 14 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 packages/api/core/spec/fixture/ts7_dep_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/ts7_dep_conf/node_modules/typescript/package.json create mode 100644 packages/api/core/spec/fixture/ts7_dep_conf/package.json create mode 100644 packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/forge.config.ts create mode 100644 packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/index.cjs create mode 100644 packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/@typescript/typescript6/package.json create mode 100644 packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/node_modules/typescript/package.json create mode 100644 packages/api/core/spec/fixture/ts7_with_ts6_dep_conf/package.json diff --git a/.gitignore b/.gitignore index 07e627fc02..13997fbbe0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ 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 8160c67045..1307e8a039 100644 --- a/packages/api/core/package.json +++ b/packages/api/core/package.json @@ -26,6 +26,14 @@ "vitest": "catalog:", "webpack": "^5.69.1" }, + "peerDependencies": { + "typescript": ">=4.7.0 <7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "dependencies": { "@electron-forge/core-utils": "workspace:*", "@electron-forge/maker-base": "workspace:*", 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 b11f584e91..ecc3353fdc 100644 --- a/packages/api/core/spec/fast/util/forge-config.spec.ts +++ b/packages/api/core/spec/fast/util/forge-config.spec.ts @@ -416,7 +416,7 @@ describe('findConfig', () => { try { await fs.cp(fixturePath, tmpDir, { recursive: true }); await expect(findConfig(tmpDir)).rejects.toThrow( - /requires the "typescript" package to be installed/, + /requires the "typescript" package \(version 4\.7\.0 or later\) to be installed/, ); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); @@ -424,6 +424,32 @@ describe('findConfig', () => { } }); + 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') 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..15749243cb --- /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-rc", + "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..fc0af6babc --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_dep_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": "99.99.99", + "typescript": "7.0.1-rc" + } +} 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..6d23d89b92 --- /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": "6.0.1", + "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..15749243cb --- /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-rc", + "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..f367381820 --- /dev/null +++ b/packages/api/core/spec/fixture/ts7_with_ts6_dep_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": { + "@typescript/typescript6": "6.0.1", + "electron": "99.99.99", + "typescript": "7.0.1-rc" + } +} diff --git a/packages/api/core/src/util/load-ts-config.ts b/packages/api/core/src/util/load-ts-config.ts index 49cc24cd7d..e1bb9228ca 100644 --- a/packages/api/core/src/util/load-ts-config.ts +++ b/packages/api/core/src/util/load-ts-config.ts @@ -71,16 +71,39 @@ const CHECK_INCOMPATIBLE_OPTIONS = [ function loadUserTypeScript(projectDir: string): TypeScriptModule { const require = createRequire(path.join(projectDir, 'noop.js')); - let tsPath: string; + // 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 { - tsPath = require.resolve('typescript'); + pkgPath = require.resolve('typescript/package.json'); } catch { throw new Error( - `Loading a TypeScript Forge config requires the "typescript" package to be installed in your project (searched from ${projectDir}). ` + + `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.`, ); } - return require(tsPath) as TypeScriptModule; + 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( diff --git a/yarn.lock b/yarn.lock index ebee25b335..f877afe43a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -826,6 +826,11 @@ __metadata: 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 From 270b8f06dd9b5961ef321a2b42939d00f8a59ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 17:21:53 +0000 Subject: [PATCH 3/3] test(core): make the TypeScript 7 guard fixtures fully synthetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture manifests declared the real registry versions typescript@7.0.1-rc and @typescript/typescript6@6.0.1 as devDependencies, which dependency scanners pick up as a (never actually installed) dependency change. Drop those declarations and give the committed package stubs synthetic versions — the specs resolve them straight from the fixture's checked-in node_modules, so nothing is ever fetched from the registry. --- .../fixture/ts7_dep_conf/node_modules/typescript/package.json | 2 +- packages/api/core/spec/fixture/ts7_dep_conf/package.json | 3 +-- .../node_modules/@typescript/typescript6/package.json | 2 +- .../node_modules/typescript/package.json | 2 +- .../api/core/spec/fixture/ts7_with_ts6_dep_conf/package.json | 4 +--- 5 files changed, 5 insertions(+), 8 deletions(-) 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 index 15749243cb..59b0aaa151 100644 --- 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 @@ -1,6 +1,6 @@ { "name": "typescript", - "version": "7.0.1-rc", + "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 index fc0af6babc..f59c18baee 100644 --- a/packages/api/core/spec/fixture/ts7_dep_conf/package.json +++ b/packages/api/core/spec/fixture/ts7_dep_conf/package.json @@ -11,7 +11,6 @@ "author": "", "license": "MIT", "devDependencies": { - "electron": "99.99.99", - "typescript": "7.0.1-rc" + "electron": "99.99.99" } } 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 index 6d23d89b92..ac3e8d4607 100644 --- 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 @@ -1,5 +1,5 @@ { "name": "@typescript/typescript6", - "version": "6.0.1", + "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 index 15749243cb..59b0aaa151 100644 --- 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 @@ -1,6 +1,6 @@ { "name": "typescript", - "version": "7.0.1-rc", + "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 index f367381820..f59c18baee 100644 --- 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 @@ -11,8 +11,6 @@ "author": "", "license": "MIT", "devDependencies": { - "@typescript/typescript6": "6.0.1", - "electron": "99.99.99", - "typescript": "7.0.1-rc" + "electron": "99.99.99" } }