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 f420e1e11e..d08335db1d 100644 --- a/packages/api/core/spec/fast/util/forge-config.spec.ts +++ b/packages/api/core/spec/fast/util/forge-config.spec.ts @@ -4,6 +4,7 @@ import { ResolvedForgeConfig } from '@electron-forge/shared-types'; import { describe, expect, it, vi } from 'vitest'; import findConfig, { + createCaseInsensitiveModuleCache, forgeConfigIsValidFilePath, registerForgeConfigForDirectory, renderConfigTemplate, @@ -411,3 +412,79 @@ describe('renderConfigTemplate', () => { }); }); }); + +// Regression coverage for electron/forge#3949. On Windows jiti's resolved +// module filenames aren't case-canonicalized against Node's realpath-keyed +// require.cache, so jiti's dedup misses an already-loaded module and evaluates +// a duplicate (e.g. webpack, whose Compilation stage constants then come back +// undefined). createCaseInsensitiveModuleCache wraps the cache so lookups that +// only differ by case still resolve to the single loaded copy. +describe('createCaseInsensitiveModuleCache', () => { + // NodeModule is heavy; the cache logic only cares about object identity. + const fakeModule = (id: string) => + ({ id, exports: {} }) as unknown as NodeModule; + + it('resolves a key that differs only by case to the same module object', () => { + const realKey = + 'C:\\Users\\Niklas\\project\\node_modules\\webpack\\lib\\index.js'; + const mod = fakeModule(realKey); + const cache = createCaseInsensitiveModuleCache({ [realKey]: mod }); + + // jiti asks with a differently-cased drive/segment casing than Node stored. + const requestedKey = realKey.toLowerCase(); + expect(cache[requestedKey]).toBe(mod); + expect(requestedKey in cache).toBe(true); + }); + + it('uses the exact key fast-path without a case fallback when present', () => { + const realKey = '/abs/path/webpack/lib/index.js'; + const mod = fakeModule(realKey); + const cache = createCaseInsensitiveModuleCache({ [realKey]: mod }); + + expect(cache[realKey]).toBe(mod); + expect(realKey in cache).toBe(true); + }); + + it('returns undefined / false for keys with no case-insensitive match', () => { + const cache = createCaseInsensitiveModuleCache({ + '/abs/webpack/lib/index.js': fakeModule('/abs/webpack/lib/index.js'), + }); + + expect(cache['/abs/other/lib/index.js']).toBeUndefined(); + expect('/abs/other/lib/index.js' in cache).toBe(false); + }); + + it('reflects entries added after construction (index invalidation)', () => { + const cache = createCaseInsensitiveModuleCache({}); + + const lateKey = '/Abs/Late/module.js'; + cache[lateKey] = fakeModule(lateKey); + + // A later case-insensitive lookup must see the newly-set entry. + expect(cache[lateKey.toLowerCase()]).toBe(cache[lateKey]); + expect(lateKey.toLowerCase() in cache).toBe(true); + }); + + it('reflects deletions (index invalidation)', () => { + const key = '/Abs/module.js'; + const cache = createCaseInsensitiveModuleCache({ [key]: fakeModule(key) }); + expect(key.toLowerCase() in cache).toBe(true); + + delete cache[key]; + + expect(cache[key.toLowerCase()]).toBeUndefined(); + expect(key.toLowerCase() in cache).toBe(false); + }); + + it('writes through to the underlying real cache object', () => { + const realCache: Record = {}; + const cache = createCaseInsensitiveModuleCache(realCache); + + const key = '/Abs/written.js'; + const mod = fakeModule(key); + cache[key] = mod; + + // Mutations must land on the same object Node hands around, not a copy. + expect(realCache[key]).toBe(mod); + }); +}); diff --git a/packages/api/core/src/util/forge-config.ts b/packages/api/core/src/util/forge-config.ts index 806d6aef17..66c0042da0 100644 --- a/packages/api/core/src/util/forge-config.ts +++ b/packages/api/core/src/util/forge-config.ts @@ -1,10 +1,10 @@ +import Module from 'node:module'; import path from 'node:path'; import { ForgeConfig, ResolvedForgeConfig } from '@electron-forge/shared-types'; import { Eta } from 'eta'; import fs from 'fs-extra'; import * as interpret from 'interpret'; -import { createJiti } from 'jiti'; import * as rechoir from 'rechoir'; // eslint-disable-next-line n/no-missing-import @@ -14,6 +14,96 @@ import { runMutatingHook } from './hook'; import PluginInterface from './plugin-interface'; import { readRawPackageJson } from './read-package-json'; +import type { createJiti } from 'jiti'; + +// Node's require.cache is keyed by realpath'd, case-preserving filenames. On +// Windows (a case-insensitive filesystem) jiti's internally-resolved module +// filenames are not case-canonicalized against those keys, so jiti's dedup +// (a lookup into its require's `.cache`) misses a module Node has already +// loaded and jiti evaluates a *second* copy of it. When that module is webpack, +// the duplicate copy's `Compilation.PROCESS_ASSETS_STAGE_*` constants come back +// undefined and plugins tap `processAssets` at the wrong stage, silently +// dropping assets like index.html from the packaged app. See electron/forge#3949. +// +// This wraps the raw require.cache in a Proxy that also resolves lookups by a +// case-insensitive fallback, so jiti's dedup finds the already-loaded copy. +type ModuleCache = Record; + +export function createCaseInsensitiveModuleCache( + realCache: ModuleCache, +): ModuleCache { + // Lazily-built lower-cased-key -> real-key index, invalidated on mutation. + let lowerIndex: Map | null = null; + const buildIndex = () => { + lowerIndex = new Map(); + for (const key of Object.keys(realCache)) { + lowerIndex.set(key.toLowerCase(), key); + } + }; + // Returns the real (case-preserving) cache key for a requested key when it + // only differs by case, or undefined when the exact key already exists or no + // case-insensitive match is found. + const canonicalKey = (prop: string | symbol): string | undefined => { + if (typeof prop !== 'string' || prop in realCache) return undefined; + if (lowerIndex === null) buildIndex(); + return lowerIndex!.get(prop.toLowerCase()); + }; + return new Proxy(realCache, { + get(target, prop, receiver) { + const key = canonicalKey(prop); + if (key !== undefined && key in target) return target[key]; + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + if (canonicalKey(prop) !== undefined) return true; + return Reflect.has(target, prop); + }, + set(target, prop, value, receiver) { + lowerIndex = null; + return Reflect.set(target, prop, value, receiver); + }, + deleteProperty(target, prop) { + lowerIndex = null; + return Reflect.deleteProperty(target, prop); + }, + }); +} + +// jiti destructures `createRequire` from `node:module` at its own load time and +// forge-config.ts is jiti's sole importer, so we can scope the fix to exactly +// the moment jiti is loaded: temporarily wrap `Module.createRequire` so the +// require jiti builds for itself gets a case-insensitive view over the same +// module cache, then restore it in a `finally`. Only jiti's own require view is +// affected; the global require.cache is never written or replaced. Off-win32, +// jiti is loaded untouched. Must use CJS `require('jiti')` (not dynamic import) +// so the wrapped `createRequire` is the one jiti captures. +let cachedCreateJiti: typeof createJiti | undefined; +function loadCreateJiti(): typeof createJiti { + if (cachedCreateJiti) return cachedCreateJiti; + if (process.platform !== 'win32') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + cachedCreateJiti = require('jiti').createJiti; + return cachedCreateJiti!; + } + const original = Module.createRequire; + Module.createRequire = function patchedCreateRequire(filename) { + const req = original.call(this, filename); + try { + req.cache = createCaseInsensitiveModuleCache(req.cache); + } catch { + // req.cache non-writable in some runtime: fall back to the untouched cache + } + return req; + }; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + cachedCreateJiti = require('jiti').createJiti; + } finally { + Module.createRequire = original; + } + return cachedCreateJiti!; +} + const underscoreCase = (str: string) => str .replace(/(.)([A-Z][a-z]+)/g, '$1_$2') @@ -198,7 +288,7 @@ export default async (dir: string): Promise => { try { let loadFn; if (['.cts', '.mts', '.ts'].includes(path.extname(forgeConfigPath))) { - const jiti = createJiti(__filename); + const jiti = loadCreateJiti()(__filename); loadFn = jiti.import; } else { loadFn = dynamicImportMaybe;