Skip to content

feat(core): replace jiti with a zero-dependency TypeScript config loader#4311

Draft
claude[bot] wants to merge 3 commits into
nextfrom
feat/user-ts-config-loader
Draft

feat(core): replace jiti with a zero-dependency TypeScript config loader#4311
claude[bot] wants to merge 3 commits into
nextfrom
feat/user-ts-config-loader

Conversation

@claude

@claude claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Requested by Samuel Attard, Niklas Wenzel · Slack thread

  • I have read the contribution documentation for this project.
  • I agree to follow the code of conduct that this project follows, as appropriate.
  • The changes are appropriately documented (if applicable).
  • The changes have sufficient test coverage (if applicable).
  • The testsuite passes successfully on my local machine (if applicable).

Summarize your changes:

Fixes #3949. Also resolves the TODO from #4059: "Check if we still need jiti for the configuration loading" — we don't; this PR removes it.

Before

On Windows, TypeScript-config projects using the Webpack plugin silently drop index.html from packaged apps: jiti evaluates the config's node_modules dependencies through its own parallel module system, so the config gets a second copy of webpack and Compilation.PROCESS_ASSETS_STAGE_* comparisons inside the plugin quietly fail (#3949). On top of that, type errors in a Forge config never surface — you get a runtime crash or silent misbehavior instead of a diagnostic.

After

TypeScript configs load through the project's own TypeScript compiler and Node's real module system — one webpack, index.html emitted — and type errors in the config produce real TypeScript diagnostics.

How

The loader (packages/api/core/src/util/load-ts-config.ts) resolves typescript from the user's project (every TS template ships it; a helpful error is thrown if it's missing), collects the config's project-local TS graph via ts.preProcessFile + ts.resolveModuleName (tsconfig paths respected), transpiles each file with ts.transpileModule (ES2022 ESM; CommonJS for .cts; inline source maps pointing back at the TS source), and rewrites the emitted imports via the TS AST: relative/paths specifiers point at temp sibling files, bare specifiers resolving to CJS deps become real createRequire bindings (shared Module._cache — the #3949 fix), ESM deps stay native imports (top-level-await deps keep working), and dynamic imports of project TS files are hoisted so post-load hooks still work. The entry temp file is evaluated with a native await import(); all temps are uniquely named per load and deleted in finally; nothing process-global is ever registered.

Type checking: if the config fails to load, it is type-checked with the user's compiler and the raw crash is replaced with formatted TS diagnostics (zero happy-path cost). Setting FORGE_TYPECHECK_CONFIG=1 opts in to a fatal type check before every load. (Env-var switch chosen to match the existing FORGE_VITE_* precedent; deliberately not ELECTRON_FORGE_*, since that prefix is magically mapped onto config properties by the config proxy.) Because TypeScript 6 no longer includes @types packages automatically, the TypeScript templates' tsconfigs now set "types": ["node"] (+ @types/node devDep) so stock template configs type-check clean — verified against a template-shaped project under TS 6 (previously TS2591: Cannot find name 'require' in webpack.plugins.ts).

What #3907 required, verified

Behavior Test
.ts / .cts / .mts configs (incl. async function configs) load existing forge-config specs, unchanged
Single-instance node_modules deps (webpack Compilation identity + PROCESS_ASSETS_STAGE value) webpack_dep_ts_conf — "should share dependency instances with the loading process"
Top-level await + extensionless relative imports tla_ts_conf — "should support top-level await and extensionless relative imports"
ESM-only dependency using top-level await esm_dep_ts_conf — "should load ESM-only dependencies that use top-level await"
tsconfig paths aliases paths_ts_conf — "should respect "paths" aliases from the project tsconfig"
Helpful error when typescript isn't installed missing_ts_dep_conf — "should throw a helpful error when typescript is not installed"
Load failures surfaced as TS diagnostics typed_error_ts_conf — "should surface type errors when the config fails to load" (asserts TS2339)
Opt-in always-on type check (and zero-cost default) type_error_only_ts_confFORGE_TYPECHECK_CONFIG describe block (asserts TS2322)
No temp leftovers, nothing process-global temps deleted in finally with unique per-load names; validated in a standalone harness that diffs Module._*/globalThis and scans for leftovers

All 29 pre-existing forge-config specs pass unchanged; the full fast suite is green (416 passed / 1 skipped / 7 todo), as are yarn build (tsgo), yarn lint:js, yarn knip (default + --production) and yarn constraints.

Dependencies

Removes jiti; adds nothing at runtime — the loader uses the project’s own typescript (the templates already ship it), now declared as an optional peerDependency of @electron-forge/core: "typescript": ">=4.7.0 <7" with peerDependenciesMeta.typescript.optional: true (only needed when the project actually uses a TypeScript config).

  • Floor (>=4.7.0), verified empirically against a template-shaped project (extensionless relative TS imports, webpack Compilation identity + PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER === 3000, top-level await, a .cts helper imported as ./helper.cjs, async-function configs): the full matrix passes on 4.7.2, 4.7.4 and 4.8.4 (4.9.5/5.0.4/6.0.3 were already validated). 4.5/4.6 happen to pass too — the .cts/.mts resolver machinery predates its official 4.7 release — but 4.7 is the first version where that support is documented; 4.4.4 fails (TS2307: no .cjs.cts mapping).
  • Cap (<7): TypeScript 7 (the native compiler) is already published under the plain typescript name (rc dist-tag: 7.0.1-rc) as "type": "module" with no root "." export, so require('typescript') throws ERR_PACKAGE_PATH_NOT_EXPORTED before any feature detection could run — and its new unstable/* JSON-RPC API drops the classic compiler API this loader is built on (transpileModule, resolveModuleName, preProcessFile, …). TS 7.1’s planned “stable API” is a new class-based API, i.e. a future loader port, not a version bump. The loader therefore 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 when present (Microsoft’s escape hatch for API-dependent tools — verified end-to-end against the real typescript@7.0.1-rc + @typescript/typescript6@6.0.1, full matrix green) and otherwise fails with an actionable error suggesting npm install --save-dev @typescript/typescript6 (or pinning typescript@6).

New devDependencies in @electron-forge/core only: typescript (type-only import) and webpack (for the #3949 identity test).

Tradeoffs

  • Temp .forge-<hex>.mjs/.cjs files are written next to the config while it loads (deleted in finally, unique names per load, so no stale ESM-cache hits).
  • The loader is ~700 lines (including docs/comments) owned by Forge instead of a dependency.
  • export * from 'cjs-dep' re-exports in a config are unsupported and fail with a loud, actionable error.
  • Real-Windows validation by the Regression in v7.8.1: index.html no longer listed in Webpack assets #3949 reporter is still pending.

Generated by Claude Code

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
let loaded: MaybeESM<ForgeConfig | AsyncForgeConfigGenerator>;
if (loadFn) {
loaded = await loadFn(forgeConfigPath);
if (['.cts', '.mts', '.ts'].includes(path.extname(forgeConfigPath))) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we share this array with the TS_EXTENSIONS extension list? Maybe expose an isTypeScriptFileExtension function from the loader or something like that?

…nst 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.
@socket-security

socket-security Bot commented Jul 2, 2026

Copy link
Copy Markdown

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants