Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/api/core/spec/fast/util/forge-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ResolvedForgeConfig } from '@electron-forge/shared-types';
import { describe, expect, it, vi } from 'vitest';

import findConfig, {
createCaseInsensitiveModuleCache,
forgeConfigIsValidFilePath,
registerForgeConfigForDirectory,
renderConfigTemplate,
Expand Down Expand Up @@ -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<string, NodeModule | undefined> = {};
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);
});
});
94 changes: 92 additions & 2 deletions packages/api/core/src/util/forge-config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, NodeModule | undefined>;

export function createCaseInsensitiveModuleCache(
realCache: ModuleCache,
): ModuleCache {
// Lazily-built lower-cased-key -> real-key index, invalidated on mutation.
let lowerIndex: Map<string, string> | 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')
Expand Down Expand Up @@ -198,7 +288,7 @@ export default async (dir: string): Promise<ResolvedForgeConfig> => {
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;
Expand Down
Loading