From c1e49c2e3552fd85aa1a07ba5197c7e54456f590 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 20 Apr 2026 22:05:33 -0700 Subject: [PATCH 1/6] fix(plugin-vite): auto-externalize native node modules --- packages/plugin/vite/spec/VitePlugin.spec.ts | 203 +++++++++++++++++- .../vite/spec/detect-native-modules.spec.ts | 192 +++++++++++++++++ packages/plugin/vite/src/VitePlugin.ts | 132 +++++++++++- .../vite/src/config/vite.main.config.ts | 4 +- .../vite/src/config/vite.preload.config.ts | 4 +- .../plugin/vite/src/detect-native-modules.ts | 84 ++++++++ 6 files changed, 607 insertions(+), 12 deletions(-) create mode 100644 packages/plugin/vite/spec/detect-native-modules.spec.ts create mode 100644 packages/plugin/vite/src/detect-native-modules.ts diff --git a/packages/plugin/vite/spec/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index a67d91ea61..710d10a9a4 100644 --- a/packages/plugin/vite/spec/VitePlugin.spec.ts +++ b/packages/plugin/vite/spec/VitePlugin.spec.ts @@ -118,7 +118,7 @@ describe('VitePlugin', async () => { expect(config.packagerConfig.ignore).toEqual(/test/); }); - it('ignores everything but files in .vite', async () => { + it('ignores everything but .vite and package.json', async () => { const config = await plugin.resolveForgeConfig( {} as ResolvedForgeConfig, ); @@ -126,8 +126,67 @@ describe('VitePlugin', async () => { expect(ignore('')).toEqual(false); expect(ignore('/abc')).toEqual(true); + expect(ignore('/src/main.ts')).toEqual(true); expect(ignore('/.vite')).toEqual(false); expect(ignore('/.vite/foo')).toEqual(false); + expect(ignore('/package.json')).toEqual(false); + }); + + it('allows the node_modules directory itself but blocks unknown modules', async () => { + const config = await plugin.resolveForgeConfig( + {} as ResolvedForgeConfig, + ); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + expect(ignore('/node_modules')).toEqual(false); + expect(ignore('/node_modules/typescript')).toEqual(true); + expect(ignore('/node_modules/typescript/lib/typescript.js')).toEqual( + true, + ); + }); + + it('allows externalized modules through the ignore function', async () => { + plugin = new VitePlugin(baseConfig); + const config = await plugin.resolveForgeConfig( + {} as ResolvedForgeConfig, + ); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + // Simulate what scanExternalModules does + // Access the private set via the public scanExternalModules method indirectly + // by writing a fixture and scanning it (tested separately below). + // Here we verify the ignore function blocks unknown modules: + expect(ignore('/node_modules/unknown-pkg')).toEqual(true); + expect(ignore('/node_modules/unknown-pkg/index.js')).toEqual(true); + }); + + it('allows scoped packages in node_modules when externalized', async () => { + plugin = new VitePlugin(baseConfig); + const scanDir = await fs.promises.mkdtemp(path.join(tmp, 'vite-scan-')); + plugin.setDirectories(scanDir); + + const buildDir = path.join(scanDir, '.vite', 'build'); + await fs.promises.mkdir(buildDir, { recursive: true }); + await fs.promises.writeFile( + path.join(buildDir, 'main.js'), + 'const x = require("@serialport/bindings-cpp");', + 'utf-8', + ); + + await plugin.scanExternalModules(); + const config = await plugin.resolveForgeConfig( + {} as ResolvedForgeConfig, + ); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + expect( + ignore( + '/node_modules/@serialport/bindings-cpp/build/Release/bindings.node', + ), + ).toEqual(false); + expect(ignore('/node_modules/@serialport/other-pkg')).toEqual(true); + + await fs.promises.rm(scanDir, { recursive: true }); }); it('ignores source map files by default', async () => { @@ -207,4 +266,146 @@ describe('VitePlugin', async () => { }); }); }); + + describe('getPackageNameFromRequire', () => { + it('extracts simple package names', () => { + expect(VitePlugin.getPackageNameFromRequire('better-sqlite3')).toEqual( + 'better-sqlite3', + ); + expect(VitePlugin.getPackageNameFromRequire('mssql')).toEqual('mssql'); + }); + + it('extracts scoped package names', () => { + expect( + VitePlugin.getPackageNameFromRequire('@serialport/bindings-cpp'), + ).toEqual('@serialport/bindings-cpp'); + expect( + VitePlugin.getPackageNameFromRequire('@electron/rebuild/lib/something'), + ).toEqual('@electron/rebuild'); + }); + + it('extracts package name from deep imports', () => { + expect( + VitePlugin.getPackageNameFromRequire('better-sqlite3/lib/binding'), + ).toEqual('better-sqlite3'); + }); + + it('returns null for relative paths', () => { + expect(VitePlugin.getPackageNameFromRequire('./foo')).toBeNull(); + expect(VitePlugin.getPackageNameFromRequire('../bar')).toBeNull(); + expect(VitePlugin.getPackageNameFromRequire('/absolute/path')).toBeNull(); + }); + + it('returns null for Node.js builtins', () => { + expect(VitePlugin.getPackageNameFromRequire('fs')).toBeNull(); + expect(VitePlugin.getPackageNameFromRequire('path')).toBeNull(); + expect(VitePlugin.getPackageNameFromRequire('node:crypto')).toBeNull(); + }); + + it('returns null for electron', () => { + expect(VitePlugin.getPackageNameFromRequire('electron')).toBeNull(); + expect(VitePlugin.getPackageNameFromRequire('electron/main')).toBeNull(); + expect( + VitePlugin.getPackageNameFromRequire('electron/renderer'), + ).toBeNull(); + expect( + VitePlugin.getPackageNameFromRequire('electron/common'), + ).toBeNull(); + }); + + it('returns null for incomplete scoped packages', () => { + expect(VitePlugin.getPackageNameFromRequire('@scope')).toBeNull(); + }); + }); + + describe('scanExternalModules', () => { + let scanDir: string; + let plugin: VitePlugin; + + beforeAll(async () => { + scanDir = await fs.promises.mkdtemp(path.join(tmp, 'vite-scan-ext-')); + plugin = new VitePlugin(baseConfig); + plugin.setDirectories(scanDir); + }); + + it('finds externalized require calls in built output', async () => { + const buildDir = path.join(scanDir, '.vite', 'build'); + await fs.promises.mkdir(buildDir, { recursive: true }); + await fs.promises.writeFile( + path.join(buildDir, 'main.js'), + [ + 'const sqlite = require("better-sqlite3");', + 'const fs = require("node:fs");', + 'const path = require("path");', + 'const electron = require("electron");', + 'const serial = require("@serialport/bindings-cpp");', + 'const local = require("./local");', + 'const mssql = require("mssql");', + 'const backtick = require(`backtick-pkg`);', + ].join('\n'), + 'utf-8', + ); + + await plugin.scanExternalModules(); + const config = await plugin.resolveForgeConfig({} as ResolvedForgeConfig); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + // These should be included + expect(ignore('/node_modules/better-sqlite3')).toEqual(false); + expect( + ignore( + '/node_modules/better-sqlite3/build/Release/better_sqlite3.node', + ), + ).toEqual(false); + expect(ignore('/node_modules/@serialport/bindings-cpp')).toEqual(false); + expect(ignore('/node_modules/mssql')).toEqual(false); + expect(ignore('/node_modules/backtick-pkg')).toEqual(false); + + // These should still be excluded + expect(ignore('/node_modules/typescript')).toEqual(true); + expect(ignore('/node_modules/vite')).toEqual(true); + }); + + it('handles missing build directory gracefully', async () => { + const emptyDir = await fs.promises.mkdtemp(path.join(tmp, 'vite-empty-')); + const emptyPlugin = new VitePlugin(baseConfig); + emptyPlugin.setDirectories(emptyDir); + + await emptyPlugin.scanExternalModules(); + const config = await emptyPlugin.resolveForgeConfig( + {} as ResolvedForgeConfig, + ); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + expect(ignore('/node_modules/anything')).toEqual(true); + + await fs.promises.rm(emptyDir, { recursive: true }); + }); + + it('only scans .js files', async () => { + const buildDir = path.join(scanDir, '.vite', 'build'); + await fs.promises.writeFile( + path.join(buildDir, 'main.js.map'), + '{"sources": ["require(\\"should-not-match\\")"]}', + 'utf-8', + ); + + const freshPlugin = new VitePlugin(baseConfig); + freshPlugin.setDirectories(scanDir); + await freshPlugin.scanExternalModules(); + const config = await freshPlugin.resolveForgeConfig( + {} as ResolvedForgeConfig, + ); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + // should-not-match must not be included (came from a .map file) + expect(ignore('/node_modules/should-not-match')).toEqual(true); + // but better-sqlite3 from main.js should still be found + expect(ignore('/node_modules/better-sqlite3')).toEqual(false); + }); + + afterAll(async () => { + await fs.promises.rm(scanDir, { recursive: true }); + }); + }); }); diff --git a/packages/plugin/vite/spec/detect-native-modules.spec.ts b/packages/plugin/vite/spec/detect-native-modules.spec.ts new file mode 100644 index 0000000000..f99e61a042 --- /dev/null +++ b/packages/plugin/vite/spec/detect-native-modules.spec.ts @@ -0,0 +1,192 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + detectNativePackages, + isNativePackage, +} from '../src/detect-native-modules'; + +describe('detect-native-modules', () => { + const tmp = os.tmpdir(); + let testDir: string; + + beforeAll(async () => { + testDir = await fs.promises.mkdtemp(path.join(tmp, 'forge-native-')); + }); + + afterAll(async () => { + await fs.promises.rm(testDir, { recursive: true }); + }); + + describe('isNativePackage', () => { + it('detects packages with binding.gyp', async () => { + const pkgDir = path.join(testDir, 'pkg-gyp'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'binding.gyp'), + '{}', + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects packages with prebuilds/ directory', async () => { + const pkgDir = path.join(testDir, 'pkg-prebuilds'); + await fs.promises.mkdir(path.join(pkgDir, 'prebuilds'), { + recursive: true, + }); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects packages with .node files in build/Release/', async () => { + const pkgDir = path.join(testDir, 'pkg-node-file'); + const buildDir = path.join(pkgDir, 'build', 'Release'); + await fs.promises.mkdir(buildDir, { recursive: true }); + await fs.promises.writeFile( + path.join(buildDir, 'addon.node'), + '', + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects packages that depend on bindings', async () => { + const pkgDir = path.join(testDir, 'pkg-bindings-dep'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ dependencies: { bindings: '^1.5.0' } }), + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects packages that depend on node-gyp-build', async () => { + const pkgDir = path.join(testDir, 'pkg-gyp-build-dep'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ dependencies: { 'node-gyp-build': '^4.0.0' } }), + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects packages that depend on prebuild-install', async () => { + const pkgDir = path.join(testDir, 'pkg-prebuild-dep'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ dependencies: { 'prebuild-install': '^7.0.0' } }), + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('returns false for regular JS packages', async () => { + const pkgDir = path.join(testDir, 'pkg-js-only'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ dependencies: { lodash: '^4.0.0' } }), + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(false); + }); + + it('returns false for packages with no markers', async () => { + const pkgDir = path.join(testDir, 'pkg-empty'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + + expect(isNativePackage(pkgDir)).toEqual(false); + }); + + it('returns false for non-existent directories', () => { + expect(isNativePackage(path.join(testDir, 'does-not-exist'))).toEqual( + false, + ); + }); + }); + + describe('detectNativePackages', () => { + let projectDir: string; + + beforeAll(async () => { + projectDir = path.join(testDir, 'project'); + const nm = path.join(projectDir, 'node_modules'); + + // Native package with binding.gyp + const nativePkg = path.join(nm, 'better-sqlite3'); + await fs.promises.mkdir(nativePkg, { recursive: true }); + await fs.promises.writeFile( + path.join(nativePkg, 'binding.gyp'), + '{}', + 'utf-8', + ); + + // Regular JS package + const jsPkg = path.join(nm, 'lodash'); + await fs.promises.mkdir(jsPkg, { recursive: true }); + await fs.promises.writeFile( + path.join(jsPkg, 'package.json'), + JSON.stringify({ name: 'lodash' }), + 'utf-8', + ); + + // Scoped native package + const scopedPkg = path.join(nm, '@serialport', 'bindings-cpp'); + await fs.promises.mkdir( + path.join(scopedPkg, 'prebuilds', 'darwin-arm64'), + { recursive: true }, + ); + + // Scoped non-native package + const scopedJs = path.join(nm, '@serialport', 'parser-readline'); + await fs.promises.mkdir(scopedJs, { recursive: true }); + await fs.promises.writeFile( + path.join(scopedJs, 'package.json'), + JSON.stringify({ name: '@serialport/parser-readline' }), + 'utf-8', + ); + + // Hidden directory (should be skipped) + await fs.promises.mkdir(path.join(nm, '.cache'), { recursive: true }); + }); + + it('detects native packages and ignores JS packages', () => { + const result = detectNativePackages(projectDir); + + expect(result).toContain('better-sqlite3'); + expect(result).not.toContain('lodash'); + }); + + it('detects scoped native packages', () => { + const result = detectNativePackages(projectDir); + + expect(result).toContain('@serialport/bindings-cpp'); + expect(result).not.toContain('@serialport/parser-readline'); + }); + + it('skips hidden directories', () => { + const result = detectNativePackages(projectDir); + + expect(result).not.toContain('.cache'); + }); + + it('returns empty array for missing node_modules', () => { + const result = detectNativePackages(path.join(testDir, 'no-project')); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index bf9f5152c1..3651b6bc5d 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -1,3 +1,4 @@ +import { builtinModules } from 'node:module'; import { spawn } from 'node:child_process'; import path from 'node:path'; @@ -105,6 +106,8 @@ export default class VitePlugin extends PluginBase { private servers: vite.ViteDevServer[] = []; + private externalModules = new Set(); + // Matches the format of the default Vite logger private timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', @@ -189,21 +192,42 @@ export default class VitePlugin extends PluginBase { return task?.newListr( [ { - title: 'Building main and preload targets...', + title: 'Building Vite targets...', task: async (_ctx, subtask) => { - const results = await this.build(subtask); - return results; + return subtask.newListr( + [ + { + title: 'Building main and preload targets...', + task: async (_ctx, subtask) => { + const results = await this.build(subtask); + return results; + }, + }, + { + title: 'Building renderer targets...', + task: async (_ctx, subtask) => { + const results = await this.buildRenderer(subtask); + return results; + }, + }, + ], + { concurrent: true }, + ); }, }, { - title: 'Building renderer targets...', + title: 'Scanning for externalized dependencies...', task: async (_ctx, subtask) => { - const results = await this.buildRenderer(subtask); - return results; + await this.scanExternalModules(); + if (this.externalModules.size > 0) { + subtask.title = `Detected externalized dependencies: ${[...this.externalModules].join(', ')}`; + } else { + subtask.title = 'No externalized dependencies detected'; + } }, }, ], - { concurrent: true }, + { concurrent: false }, ); }, 'Building production Vite bundles'), ], @@ -241,8 +265,22 @@ Your packaged app may be larger than expected if you dont ignore everything othe // `file` always starts with `/` // @see - https://github.com/electron/packager/blob/v18.1.3/src/copy-filter.ts#L89-L93 - // Collect the files built by Vite - return !file.startsWith('/.vite'); + if (file.startsWith('/.vite')) return false; + if (file === '/package.json') return false; + + // Include node_modules that were externalized by the Vite build. + // The set is populated during prePackage after the Vite build completes. + if (file.startsWith('/node_modules')) { + if (file === '/node_modules') return false; + const bare = file.slice('/node_modules/'.length); + const segments = bare.split('/'); + const name = segments[0].startsWith('@') + ? `${segments[0]}/${segments[1]}` + : segments[0]; + return !this.externalModules.has(name); + } + + return true; }; return forgeConfig; }; @@ -268,6 +306,82 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); }); }; + private static readonly IGNORED_EXTERNALS = new Set([ + 'electron', + 'electron/main', + 'electron/renderer', + 'electron/common', + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), + ]); + + private static readonly REQUIRE_PATTERN = + /require\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g; + + static getPackageNameFromRequire(specifier: string): string | null { + if ( + specifier.startsWith('.') || + specifier.startsWith('/') || + VitePlugin.IGNORED_EXTERNALS.has(specifier) + ) { + return null; + } + + const segments = specifier.split('/'); + if (segments[0].startsWith('@')) { + return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null; + } + return segments[0]; + } + + async scanExternalModules(): Promise { + this.externalModules.clear(); + + const buildDir = path.join(this.baseDir, 'build'); + if (!(await fs.pathExists(buildDir))) return; + + const files = await fs.readdir(buildDir); + for (const file of files) { + if (!file.endsWith('.js')) continue; + const content = await fs.readFile(path.join(buildDir, file), 'utf-8'); + + let match; + while ((match = VitePlugin.REQUIRE_PATTERN.exec(content)) !== null) { + const name = VitePlugin.getPackageNameFromRequire(match[1]); + if (name) { + this.externalModules.add(name); + } + } + } + + // Walk transitive production dependencies so flat node_modules layouts work + const visited = new Set(); + const queue = [...this.externalModules]; + while (queue.length > 0) { + const pkg = queue.pop()!; + if (visited.has(pkg)) continue; + visited.add(pkg); + + const pkgJsonPath = path.join( + this.projectDir, + 'node_modules', + pkg, + 'package.json', + ); + if (!(await fs.pathExists(pkgJsonPath))) continue; + + const pkgJson = await fs.readJson(pkgJsonPath); + for (const dep of Object.keys(pkgJson.dependencies ?? {})) { + this.externalModules.add(dep); + if (!visited.has(dep)) { + queue.push(dep); + } + } + } + + d('externalized modules:', [...this.externalModules]); + } + /** * Serializable snapshot of the plugin config to pass to subprocess workers. * We only include build[] and renderer[] — the worker needs the full renderer diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index ab6d1bc2d1..85899fe388 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,5 +1,6 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; +import { detectNativePackages } from '../detect-native-modules.js'; import { external, getBuildConfig, @@ -12,12 +13,13 @@ export function getConfig( userConfig: UserConfig = {}, ): UserConfig { const { forgeConfigSelf } = forgeEnv; + const nativePackages = detectNativePackages(forgeEnv.root); const define = getBuildDefine(forgeEnv); const config: UserConfig = { build: { copyPublicDir: false, rollupOptions: { - external: [...external, 'electron/main'], + external: [...external, 'electron/main', ...nativePackages], }, }, plugins: [pluginHotRestart('restart')], diff --git a/packages/plugin/vite/src/config/vite.preload.config.ts b/packages/plugin/vite/src/config/vite.preload.config.ts index 29fd823eab..52c15d1bba 100644 --- a/packages/plugin/vite/src/config/vite.preload.config.ts +++ b/packages/plugin/vite/src/config/vite.preload.config.ts @@ -1,5 +1,6 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; +import { detectNativePackages } from '../detect-native-modules.js'; import { external, getBuildConfig, @@ -11,11 +12,12 @@ export function getConfig( userConfig: UserConfig = {}, ): UserConfig { const { forgeConfigSelf } = forgeEnv; + const nativePackages = detectNativePackages(forgeEnv.root); const config: UserConfig = { build: { copyPublicDir: false, rollupOptions: { - external: [...external, 'electron/renderer'], + external: [...external, 'electron/renderer', ...nativePackages], // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. input: forgeConfigSelf.entry, output: { diff --git a/packages/plugin/vite/src/detect-native-modules.ts b/packages/plugin/vite/src/detect-native-modules.ts new file mode 100644 index 0000000000..40b381761e --- /dev/null +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import debug from 'debug'; + +const d = debug('electron-forge:plugin:vite:native-modules'); + +const NATIVE_DEPENDENCY_MARKERS = [ + 'bindings', + 'node-gyp-build', + 'prebuild-install', +]; + +export function isNativePackage(pkgDir: string): boolean { + if (fs.existsSync(path.join(pkgDir, 'binding.gyp'))) return true; + + if (fs.existsSync(path.join(pkgDir, 'prebuilds'))) return true; + + const buildRelease = path.join(pkgDir, 'build', 'Release'); + if (fs.existsSync(buildRelease)) { + try { + const files = fs.readdirSync(buildRelease); + if (files.some((f) => f.endsWith('.node'))) return true; + } catch { + /* ignore read errors */ + } + } + + const pkgJsonPath = path.join(pkgDir, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const deps = Object.keys(pkg.dependencies ?? {}); + if (deps.some((dep) => NATIVE_DEPENDENCY_MARKERS.includes(dep))) { + return true; + } + } catch { + /* ignore parse errors */ + } + } + + return false; +} + +export function detectNativePackages(projectDir: string): string[] { + const nodeModulesDir = path.join(projectDir, 'node_modules'); + if (!fs.existsSync(nodeModulesDir)) return []; + + const results: string[] = []; + + let entries: string[]; + try { + entries = fs.readdirSync(nodeModulesDir); + } catch { + return []; + } + + for (const entry of entries) { + if (entry.startsWith('.')) continue; + + if (entry.startsWith('@')) { + const scopeDir = path.join(nodeModulesDir, entry); + let scopeEntries: string[]; + try { + scopeEntries = fs.readdirSync(scopeDir); + } catch { + continue; + } + for (const sub of scopeEntries) { + const name = `${entry}/${sub}`; + if (isNativePackage(path.join(scopeDir, sub))) { + results.push(name); + } + } + } else { + if (isNativePackage(path.join(nodeModulesDir, entry))) { + results.push(entry); + } + } + } + + d('detected native packages:', results); + return results; +} From 629d52b02e62a012da23a2a059e9e96cb85de6d5 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 20 Apr 2026 22:43:42 -0700 Subject: [PATCH 2/6] one pass --- packages/plugin/vite/spec/VitePlugin.spec.ts | 177 +----------------- .../vite/spec/detect-native-modules.spec.ts | 103 ++++++++++ packages/plugin/vite/src/VitePlugin.ts | 89 +-------- .../plugin/vite/src/detect-native-modules.ts | 66 +++++-- 4 files changed, 162 insertions(+), 273 deletions(-) diff --git a/packages/plugin/vite/spec/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index 710d10a9a4..1e21eb964b 100644 --- a/packages/plugin/vite/spec/VitePlugin.spec.ts +++ b/packages/plugin/vite/spec/VitePlugin.spec.ts @@ -145,50 +145,17 @@ describe('VitePlugin', async () => { ); }); - it('allows externalized modules through the ignore function', async () => { + it('blocks unknown modules through the ignore function', async () => { plugin = new VitePlugin(baseConfig); const config = await plugin.resolveForgeConfig( {} as ResolvedForgeConfig, ); const ignore = config.packagerConfig.ignore as IgnoreFunction; - // Simulate what scanExternalModules does - // Access the private set via the public scanExternalModules method indirectly - // by writing a fixture and scanning it (tested separately below). - // Here we verify the ignore function blocks unknown modules: expect(ignore('/node_modules/unknown-pkg')).toEqual(true); expect(ignore('/node_modules/unknown-pkg/index.js')).toEqual(true); }); - it('allows scoped packages in node_modules when externalized', async () => { - plugin = new VitePlugin(baseConfig); - const scanDir = await fs.promises.mkdtemp(path.join(tmp, 'vite-scan-')); - plugin.setDirectories(scanDir); - - const buildDir = path.join(scanDir, '.vite', 'build'); - await fs.promises.mkdir(buildDir, { recursive: true }); - await fs.promises.writeFile( - path.join(buildDir, 'main.js'), - 'const x = require("@serialport/bindings-cpp");', - 'utf-8', - ); - - await plugin.scanExternalModules(); - const config = await plugin.resolveForgeConfig( - {} as ResolvedForgeConfig, - ); - const ignore = config.packagerConfig.ignore as IgnoreFunction; - - expect( - ignore( - '/node_modules/@serialport/bindings-cpp/build/Release/bindings.node', - ), - ).toEqual(false); - expect(ignore('/node_modules/@serialport/other-pkg')).toEqual(true); - - await fs.promises.rm(scanDir, { recursive: true }); - }); - it('ignores source map files by default', async () => { const viteConfig = { ...baseConfig }; plugin = new VitePlugin(viteConfig); @@ -266,146 +233,4 @@ describe('VitePlugin', async () => { }); }); }); - - describe('getPackageNameFromRequire', () => { - it('extracts simple package names', () => { - expect(VitePlugin.getPackageNameFromRequire('better-sqlite3')).toEqual( - 'better-sqlite3', - ); - expect(VitePlugin.getPackageNameFromRequire('mssql')).toEqual('mssql'); - }); - - it('extracts scoped package names', () => { - expect( - VitePlugin.getPackageNameFromRequire('@serialport/bindings-cpp'), - ).toEqual('@serialport/bindings-cpp'); - expect( - VitePlugin.getPackageNameFromRequire('@electron/rebuild/lib/something'), - ).toEqual('@electron/rebuild'); - }); - - it('extracts package name from deep imports', () => { - expect( - VitePlugin.getPackageNameFromRequire('better-sqlite3/lib/binding'), - ).toEqual('better-sqlite3'); - }); - - it('returns null for relative paths', () => { - expect(VitePlugin.getPackageNameFromRequire('./foo')).toBeNull(); - expect(VitePlugin.getPackageNameFromRequire('../bar')).toBeNull(); - expect(VitePlugin.getPackageNameFromRequire('/absolute/path')).toBeNull(); - }); - - it('returns null for Node.js builtins', () => { - expect(VitePlugin.getPackageNameFromRequire('fs')).toBeNull(); - expect(VitePlugin.getPackageNameFromRequire('path')).toBeNull(); - expect(VitePlugin.getPackageNameFromRequire('node:crypto')).toBeNull(); - }); - - it('returns null for electron', () => { - expect(VitePlugin.getPackageNameFromRequire('electron')).toBeNull(); - expect(VitePlugin.getPackageNameFromRequire('electron/main')).toBeNull(); - expect( - VitePlugin.getPackageNameFromRequire('electron/renderer'), - ).toBeNull(); - expect( - VitePlugin.getPackageNameFromRequire('electron/common'), - ).toBeNull(); - }); - - it('returns null for incomplete scoped packages', () => { - expect(VitePlugin.getPackageNameFromRequire('@scope')).toBeNull(); - }); - }); - - describe('scanExternalModules', () => { - let scanDir: string; - let plugin: VitePlugin; - - beforeAll(async () => { - scanDir = await fs.promises.mkdtemp(path.join(tmp, 'vite-scan-ext-')); - plugin = new VitePlugin(baseConfig); - plugin.setDirectories(scanDir); - }); - - it('finds externalized require calls in built output', async () => { - const buildDir = path.join(scanDir, '.vite', 'build'); - await fs.promises.mkdir(buildDir, { recursive: true }); - await fs.promises.writeFile( - path.join(buildDir, 'main.js'), - [ - 'const sqlite = require("better-sqlite3");', - 'const fs = require("node:fs");', - 'const path = require("path");', - 'const electron = require("electron");', - 'const serial = require("@serialport/bindings-cpp");', - 'const local = require("./local");', - 'const mssql = require("mssql");', - 'const backtick = require(`backtick-pkg`);', - ].join('\n'), - 'utf-8', - ); - - await plugin.scanExternalModules(); - const config = await plugin.resolveForgeConfig({} as ResolvedForgeConfig); - const ignore = config.packagerConfig.ignore as IgnoreFunction; - - // These should be included - expect(ignore('/node_modules/better-sqlite3')).toEqual(false); - expect( - ignore( - '/node_modules/better-sqlite3/build/Release/better_sqlite3.node', - ), - ).toEqual(false); - expect(ignore('/node_modules/@serialport/bindings-cpp')).toEqual(false); - expect(ignore('/node_modules/mssql')).toEqual(false); - expect(ignore('/node_modules/backtick-pkg')).toEqual(false); - - // These should still be excluded - expect(ignore('/node_modules/typescript')).toEqual(true); - expect(ignore('/node_modules/vite')).toEqual(true); - }); - - it('handles missing build directory gracefully', async () => { - const emptyDir = await fs.promises.mkdtemp(path.join(tmp, 'vite-empty-')); - const emptyPlugin = new VitePlugin(baseConfig); - emptyPlugin.setDirectories(emptyDir); - - await emptyPlugin.scanExternalModules(); - const config = await emptyPlugin.resolveForgeConfig( - {} as ResolvedForgeConfig, - ); - const ignore = config.packagerConfig.ignore as IgnoreFunction; - - expect(ignore('/node_modules/anything')).toEqual(true); - - await fs.promises.rm(emptyDir, { recursive: true }); - }); - - it('only scans .js files', async () => { - const buildDir = path.join(scanDir, '.vite', 'build'); - await fs.promises.writeFile( - path.join(buildDir, 'main.js.map'), - '{"sources": ["require(\\"should-not-match\\")"]}', - 'utf-8', - ); - - const freshPlugin = new VitePlugin(baseConfig); - freshPlugin.setDirectories(scanDir); - await freshPlugin.scanExternalModules(); - const config = await freshPlugin.resolveForgeConfig( - {} as ResolvedForgeConfig, - ); - const ignore = config.packagerConfig.ignore as IgnoreFunction; - - // should-not-match must not be included (came from a .map file) - expect(ignore('/node_modules/should-not-match')).toEqual(true); - // but better-sqlite3 from main.js should still be found - expect(ignore('/node_modules/better-sqlite3')).toEqual(false); - }); - - afterAll(async () => { - await fs.promises.rm(scanDir, { recursive: true }); - }); - }); }); diff --git a/packages/plugin/vite/spec/detect-native-modules.spec.ts b/packages/plugin/vite/spec/detect-native-modules.spec.ts index f99e61a042..b496a0643e 100644 --- a/packages/plugin/vite/spec/detect-native-modules.spec.ts +++ b/packages/plugin/vite/spec/detect-native-modules.spec.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { detectNativePackages, isNativePackage, + walkTransitiveDependencies, } from '../src/detect-native-modules'; describe('detect-native-modules', () => { @@ -189,4 +190,106 @@ describe('detect-native-modules', () => { expect(result).toEqual([]); }); }); + + describe('walkTransitiveDependencies', () => { + let projectDir: string; + + beforeAll(async () => { + projectDir = path.join(testDir, 'transitive-project'); + const nm = path.join(projectDir, 'node_modules'); + + // Native package with a production dependency + const nativePkg = path.join(nm, 'better-sqlite3'); + await fs.promises.mkdir(nativePkg, { recursive: true }); + await fs.promises.writeFile( + path.join(nativePkg, 'package.json'), + JSON.stringify({ + name: 'better-sqlite3', + dependencies: { bindings: '^1.5.0', 'prebuild-install': '^7.0.0' }, + }), + 'utf-8', + ); + + // Transitive dep with its own dependency + const bindingsPkg = path.join(nm, 'bindings'); + await fs.promises.mkdir(bindingsPkg, { recursive: true }); + await fs.promises.writeFile( + path.join(bindingsPkg, 'package.json'), + JSON.stringify({ + name: 'bindings', + dependencies: { 'file-uri-to-path': '^1.0.0' }, + }), + 'utf-8', + ); + + // Leaf dep (no further dependencies) + const leafPkg = path.join(nm, 'file-uri-to-path'); + await fs.promises.mkdir(leafPkg, { recursive: true }); + await fs.promises.writeFile( + path.join(leafPkg, 'package.json'), + JSON.stringify({ name: 'file-uri-to-path' }), + 'utf-8', + ); + + // prebuild-install (no package.json to test missing gracefully) + await fs.promises.mkdir(path.join(nm, 'prebuild-install'), { + recursive: true, + }); + }); + + it('walks transitive production dependencies', () => { + const result = walkTransitiveDependencies(projectDir, ['better-sqlite3']); + + expect(result).toContain('better-sqlite3'); + expect(result).toContain('bindings'); + expect(result).toContain('file-uri-to-path'); + expect(result).toContain('prebuild-install'); + }); + + it('handles missing packages gracefully', () => { + const result = walkTransitiveDependencies(projectDir, [ + 'nonexistent-pkg', + ]); + + expect(result).toEqual(new Set(['nonexistent-pkg'])); + }); + + it('handles circular dependencies', async () => { + const nm = path.join(projectDir, 'node_modules'); + + const circA = path.join(nm, 'circ-a'); + await fs.promises.mkdir(circA, { recursive: true }); + await fs.promises.writeFile( + path.join(circA, 'package.json'), + JSON.stringify({ + name: 'circ-a', + dependencies: { 'circ-b': '^1.0.0' }, + }), + 'utf-8', + ); + + const circB = path.join(nm, 'circ-b'); + await fs.promises.mkdir(circB, { recursive: true }); + await fs.promises.writeFile( + path.join(circB, 'package.json'), + JSON.stringify({ + name: 'circ-b', + dependencies: { 'circ-a': '^1.0.0' }, + }), + 'utf-8', + ); + + const result = walkTransitiveDependencies(projectDir, ['circ-a']); + + expect(result).toContain('circ-a'); + expect(result).toContain('circ-b'); + expect(result.size).toEqual(2); + }); + + it('returns empty set for empty input', () => { + const result = walkTransitiveDependencies(projectDir, []); + + expect(result.size).toEqual(0); + }); + }); }); diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 413b420d03..e9af37eca0 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -1,4 +1,3 @@ -import { builtinModules } from 'node:module'; import { spawn } from 'node:child_process'; import path from 'node:path'; @@ -10,6 +9,10 @@ import { Listr, PRESET_TIMER } from 'listr2'; import * as vite from 'vite'; import { viteDevServerUrls } from './config/vite.base.config.js'; +import { + detectNativePackages, + walkTransitiveDependencies, +} from './detect-native-modules.js'; import ViteConfigGenerator from './ViteConfig.js'; import type { VitePluginConfig } from './Config.js'; @@ -277,9 +280,13 @@ export default class VitePlugin extends PluginBase { }, }, { - title: 'Scanning for externalized dependencies...', + title: 'Detecting native dependencies...', task: async (_ctx, subtask) => { - await this.scanExternalModules(); + const nativePackages = detectNativePackages(this.projectDir); + this.externalModules = walkTransitiveDependencies( + this.projectDir, + nativePackages, + ); if (this.externalModules.size > 0) { subtask.title = `Detected externalized dependencies: ${[...this.externalModules].join(', ')}`; } else { @@ -367,82 +374,6 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); }); }; - private static readonly IGNORED_EXTERNALS = new Set([ - 'electron', - 'electron/main', - 'electron/renderer', - 'electron/common', - ...builtinModules, - ...builtinModules.map((m) => `node:${m}`), - ]); - - private static readonly REQUIRE_PATTERN = - /require\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g; - - static getPackageNameFromRequire(specifier: string): string | null { - if ( - specifier.startsWith('.') || - specifier.startsWith('/') || - VitePlugin.IGNORED_EXTERNALS.has(specifier) - ) { - return null; - } - - const segments = specifier.split('/'); - if (segments[0].startsWith('@')) { - return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null; - } - return segments[0]; - } - - async scanExternalModules(): Promise { - this.externalModules.clear(); - - const buildDir = path.join(this.baseDir, 'build'); - if (!(await fs.pathExists(buildDir))) return; - - const files = await fs.readdir(buildDir); - for (const file of files) { - if (!file.endsWith('.js')) continue; - const content = await fs.readFile(path.join(buildDir, file), 'utf-8'); - - let match; - while ((match = VitePlugin.REQUIRE_PATTERN.exec(content)) !== null) { - const name = VitePlugin.getPackageNameFromRequire(match[1]); - if (name) { - this.externalModules.add(name); - } - } - } - - // Walk transitive production dependencies so flat node_modules layouts work - const visited = new Set(); - const queue = [...this.externalModules]; - while (queue.length > 0) { - const pkg = queue.pop()!; - if (visited.has(pkg)) continue; - visited.add(pkg); - - const pkgJsonPath = path.join( - this.projectDir, - 'node_modules', - pkg, - 'package.json', - ); - if (!(await fs.pathExists(pkgJsonPath))) continue; - - const pkgJson = await fs.readJson(pkgJsonPath); - for (const dep of Object.keys(pkgJson.dependencies ?? {})) { - this.externalModules.add(dep); - if (!visited.has(dep)) { - queue.push(dep); - } - } - } - - d('externalized modules:', [...this.externalModules]); - } - /** * Serializable snapshot of the plugin config to pass to subprocess workers. * We only include build[] and renderer[] — the worker needs the full renderer diff --git a/packages/plugin/vite/src/detect-native-modules.ts b/packages/plugin/vite/src/detect-native-modules.ts index 40b381761e..6cd070c758 100644 --- a/packages/plugin/vite/src/detect-native-modules.ts +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -11,31 +11,33 @@ const NATIVE_DEPENDENCY_MARKERS = [ 'prebuild-install', ]; +function readJsonSafe(filePath: string): Record | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + export function isNativePackage(pkgDir: string): boolean { if (fs.existsSync(path.join(pkgDir, 'binding.gyp'))) return true; if (fs.existsSync(path.join(pkgDir, 'prebuilds'))) return true; - const buildRelease = path.join(pkgDir, 'build', 'Release'); - if (fs.existsSync(buildRelease)) { - try { - const files = fs.readdirSync(buildRelease); - if (files.some((f) => f.endsWith('.node'))) return true; - } catch { - /* ignore read errors */ - } + try { + const files = fs.readdirSync(path.join(pkgDir, 'build', 'Release')); + if (files.some((f) => f.endsWith('.node'))) return true; + } catch { + // Directory doesn't exist or isn't readable } - const pkgJsonPath = path.join(pkgDir, 'package.json'); - if (fs.existsSync(pkgJsonPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); - const deps = Object.keys(pkg.dependencies ?? {}); - if (deps.some((dep) => NATIVE_DEPENDENCY_MARKERS.includes(dep))) { - return true; - } - } catch { - /* ignore parse errors */ + const pkg = readJsonSafe(path.join(pkgDir, 'package.json')); + if (pkg) { + const deps = Object.keys( + (pkg.dependencies as Record) ?? {}, + ); + if (deps.some((dep) => NATIVE_DEPENDENCY_MARKERS.includes(dep))) { + return true; } } @@ -82,3 +84,31 @@ export function detectNativePackages(projectDir: string): string[] { d('detected native packages:', results); return results; } + +export function walkTransitiveDependencies( + projectDir: string, + packages: string[], +): Set { + const all = new Set(packages); + const queue = [...packages]; + + while (queue.length > 0) { + const pkg = queue.pop()!; + const pkgJson = readJsonSafe( + path.join(projectDir, 'node_modules', pkg, 'package.json'), + ); + if (!pkgJson) continue; + + for (const dep of Object.keys( + (pkgJson.dependencies as Record) ?? {}, + )) { + if (!all.has(dep)) { + all.add(dep); + queue.push(dep); + } + } + } + + d('native packages with transitive deps:', [...all]); + return all; +} From 3a7efe3e7e7a70743cba0987b92d1d3f0c63af32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:53:44 +0000 Subject: [PATCH 3/6] fix(plugin-vite): detect napi-rs modules and resolve nested or symlinked node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend native module detection to cover the napi-rs / prebuilt-binary convention: a package is treated as native when its package.json has a napi field, or when one of its optionalDependencies resolves to an installed platform package (a .node main, a root-level .node binary, or os/cpu constraints with a nested .node binary — e.g. sharp, @parcel/watcher, @libsql/client). Replace the top-level-only dependency resolution in walkTransitiveDependencies with Node-style resolution (nearest node_modules first, then ancestor directories), so npm conflict-nesting and workspace-hoisted layouts resolve correctly. Optional dependencies are now walked too, silently skipping platform packages that are not installed. Symlinked package directories (pnpm's default layout, npm link) are followed via realpath, with a warning about the remaining pnpm limitations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KhNR823EZtFRRnQVJBMo1t --- .../vite/spec/detect-native-modules.spec.ts | 341 ++++++++++++++++++ .../plugin/vite/src/detect-native-modules.ts | 168 ++++++++- 2 files changed, 498 insertions(+), 11 deletions(-) diff --git a/packages/plugin/vite/spec/detect-native-modules.spec.ts b/packages/plugin/vite/spec/detect-native-modules.spec.ts index b496a0643e..225b357f99 100644 --- a/packages/plugin/vite/spec/detect-native-modules.spec.ts +++ b/packages/plugin/vite/spec/detect-native-modules.spec.ts @@ -117,6 +117,145 @@ describe('detect-native-modules', () => { false, ); }); + + it('detects packages with a napi field (napi-rs)', async () => { + const pkgDir = path.join(testDir, 'pkg-napi-field'); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: 'pkg-napi-field', + napi: { binaryName: 'thing' }, + }), + 'utf-8', + ); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + describe('napi-rs platform packages via optionalDependencies', () => { + let projectDir: string; + let nm: string; + + beforeAll(async () => { + projectDir = path.join(testDir, 'napi-project'); + nm = path.join(projectDir, 'node_modules'); + + // Platform package whose `main` points at a .node binary + // (e.g. @libsql/darwin-arm64, @parcel/watcher-linux-x64-glibc). + const mainNodePkg = path.join(nm, '@app', 'native-main-node'); + await fs.promises.mkdir(mainNodePkg, { recursive: true }); + await fs.promises.writeFile( + path.join(mainNodePkg, 'package.json'), + JSON.stringify({ + name: '@app/native-main-node', + main: 'index.node', + os: ['linux'], + cpu: ['x64'], + }), + 'utf-8', + ); + + // Platform package with a .node binary at the package root. + const rootNodePkg = path.join(nm, 'native-root-node'); + await fs.promises.mkdir(rootNodePkg, { recursive: true }); + await fs.promises.writeFile( + path.join(rootNodePkg, 'package.json'), + JSON.stringify({ name: 'native-root-node', main: 'index.js' }), + 'utf-8', + ); + await fs.promises.writeFile( + path.join(rootNodePkg, 'addon.node'), + '', + 'utf-8', + ); + + // Platform package with os/cpu constraints and a nested binary + // (e.g. @img/sharp-darwin-arm64 ships lib/sharp-*.node). + const nestedNodePkg = path.join(nm, '@app', 'native-nested-node'); + await fs.promises.mkdir(path.join(nestedNodePkg, 'lib'), { + recursive: true, + }); + await fs.promises.writeFile( + path.join(nestedNodePkg, 'package.json'), + JSON.stringify({ + name: '@app/native-nested-node', + main: 'index.cjs', + os: ['darwin'], + cpu: ['arm64'], + }), + 'utf-8', + ); + await fs.promises.writeFile( + path.join(nestedNodePkg, 'lib', 'binary.node'), + '', + 'utf-8', + ); + + // Plain JS optional dependency (should not mark the parent native). + const jsOptPkg = path.join(nm, 'plain-js-opt'); + await fs.promises.mkdir(jsOptPkg, { recursive: true }); + await fs.promises.writeFile( + path.join(jsOptPkg, 'package.json'), + JSON.stringify({ name: 'plain-js-opt', main: 'index.js' }), + 'utf-8', + ); + }); + + const writeWrapper = async ( + name: string, + optionalDependencies: Record, + ) => { + const pkgDir = path.join(nm, name); + await fs.promises.mkdir(pkgDir, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name, optionalDependencies }), + 'utf-8', + ); + return pkgDir; + }; + + it('detects a wrapper whose optional dependency has a .node main', async () => { + const pkgDir = await writeWrapper('wrapper-main-node', { + '@app/native-main-node': '^1.0.0', + }); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects a wrapper whose optional dependency ships a root .node file', async () => { + const pkgDir = await writeWrapper('wrapper-root-node', { + 'native-root-node': '^1.0.0', + }); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('detects a wrapper whose optional dependency has os/cpu constraints and a nested .node file', async () => { + const pkgDir = await writeWrapper('wrapper-nested-node', { + '@app/native-nested-node': '^1.0.0', + }); + + expect(isNativePackage(pkgDir)).toEqual(true); + }); + + it('tolerates uninstalled optional dependencies', async () => { + const pkgDir = await writeWrapper('wrapper-missing-opt', { + 'not-installed-anywhere': '^1.0.0', + }); + + expect(isNativePackage(pkgDir)).toEqual(false); + }); + + it('does not flag wrappers whose optional dependencies are plain JS', async () => { + const pkgDir = await writeWrapper('wrapper-js-opt', { + 'plain-js-opt': '^1.0.0', + }); + + expect(isNativePackage(pkgDir)).toEqual(false); + }); + }); }); describe('detectNativePackages', () => { @@ -291,5 +430,207 @@ describe('detect-native-modules', () => { expect(result.size).toEqual(0); }); + + it('walks optionalDependencies and skips uninstalled ones', async () => { + const optProjectDir = path.join(testDir, 'optional-project'); + const nm = path.join(optProjectDir, 'node_modules'); + + const wrapper = path.join(nm, 'napi-wrapper'); + await fs.promises.mkdir(wrapper, { recursive: true }); + await fs.promises.writeFile( + path.join(wrapper, 'package.json'), + JSON.stringify({ + name: 'napi-wrapper', + dependencies: { 'detect-libc': '^2.0.0' }, + optionalDependencies: { + 'napi-wrapper-linux-x64': '^1.0.0', + 'napi-wrapper-darwin-arm64': '^1.0.0', + }, + }), + 'utf-8', + ); + + const detectLibc = path.join(nm, 'detect-libc'); + await fs.promises.mkdir(detectLibc, { recursive: true }); + await fs.promises.writeFile( + path.join(detectLibc, 'package.json'), + JSON.stringify({ name: 'detect-libc' }), + 'utf-8', + ); + + // Only the platform package for the "current" platform is installed. + const platformPkg = path.join(nm, 'napi-wrapper-linux-x64'); + await fs.promises.mkdir(platformPkg, { recursive: true }); + await fs.promises.writeFile( + path.join(platformPkg, 'package.json'), + JSON.stringify({ name: 'napi-wrapper-linux-x64', main: 'index.node' }), + 'utf-8', + ); + + const result = walkTransitiveDependencies(optProjectDir, [ + 'napi-wrapper', + ]); + + expect(result).toContain('napi-wrapper'); + expect(result).toContain('detect-libc'); + expect(result).toContain('napi-wrapper-linux-x64'); + // The platform package for the other platform is not installed and + // must be skipped silently. + expect(result).not.toContain('napi-wrapper-darwin-arm64'); + }); + + it('resolves nested node_modules before hoisted ones', async () => { + const nestedProjectDir = path.join(testDir, 'nested-project'); + const nm = path.join(nestedProjectDir, 'node_modules'); + + // native-a depends on dep-b@2, nested because the hoisted dep-b is v1. + const nativeA = path.join(nm, 'native-a'); + await fs.promises.mkdir(nativeA, { recursive: true }); + await fs.promises.writeFile( + path.join(nativeA, 'package.json'), + JSON.stringify({ + name: 'native-a', + dependencies: { 'dep-b': '^2.0.0' }, + }), + 'utf-8', + ); + + // Nested copy of dep-b, with its own dependency. + const nestedDepB = path.join(nativeA, 'node_modules', 'dep-b'); + await fs.promises.mkdir(nestedDepB, { recursive: true }); + await fs.promises.writeFile( + path.join(nestedDepB, 'package.json'), + JSON.stringify({ + name: 'dep-b', + version: '2.0.0', + dependencies: { 'dep-c': '^1.0.0' }, + }), + 'utf-8', + ); + + // Hoisted (conflicting) copy of dep-b that must NOT be used. + const hoistedDepB = path.join(nm, 'dep-b'); + await fs.promises.mkdir(hoistedDepB, { recursive: true }); + await fs.promises.writeFile( + path.join(hoistedDepB, 'package.json'), + JSON.stringify({ + name: 'dep-b', + version: '1.0.0', + dependencies: { 'wrong-dep': '^1.0.0' }, + }), + 'utf-8', + ); + + const depC = path.join(nm, 'dep-c'); + await fs.promises.mkdir(depC, { recursive: true }); + await fs.promises.writeFile( + path.join(depC, 'package.json'), + JSON.stringify({ name: 'dep-c' }), + 'utf-8', + ); + + const result = walkTransitiveDependencies(nestedProjectDir, ['native-a']); + + expect(result).toContain('native-a'); + expect(result).toContain('dep-b'); + // dep-c comes from the nested dep-b@2. + expect(result).toContain('dep-c'); + // wrong-dep would only appear if the hoisted dep-b@1 had been used. + expect(result).not.toContain('wrong-dep'); + }); + + it('resolves dependencies hoisted above the project dir (workspaces)', async () => { + const workspaceRoot = path.join(testDir, 'workspace-root'); + const appDir = path.join(workspaceRoot, 'apps', 'my-app'); + const appNm = path.join(appDir, 'node_modules'); + const rootNm = path.join(workspaceRoot, 'node_modules'); + + const nativePkg = path.join(appNm, 'native-pkg'); + await fs.promises.mkdir(nativePkg, { recursive: true }); + await fs.promises.writeFile( + path.join(nativePkg, 'package.json'), + JSON.stringify({ + name: 'native-pkg', + dependencies: { 'hoisted-dep': '^1.0.0' }, + }), + 'utf-8', + ); + + // The dependency lives in the workspace root's node_modules. + const hoistedDep = path.join(rootNm, 'hoisted-dep'); + await fs.promises.mkdir(hoistedDep, { recursive: true }); + await fs.promises.writeFile( + path.join(hoistedDep, 'package.json'), + JSON.stringify({ name: 'hoisted-dep' }), + 'utf-8', + ); + + const result = walkTransitiveDependencies(appDir, ['native-pkg']); + + expect(result).toContain('native-pkg'); + expect(result).toContain('hoisted-dep'); + }); + }); + + describe('symlinked packages (pnpm-style layouts)', () => { + let projectDir: string; + + beforeAll(async () => { + projectDir = path.join(testDir, 'pnpm-project'); + const nm = path.join(projectDir, 'node_modules'); + + // Real package contents live in the virtual store. + const storePkgDir = path.join( + nm, + '.pnpm', + 'native-pkg@1.0.0', + 'node_modules', + ); + const realNativePkg = path.join(storePkgDir, 'native-pkg'); + await fs.promises.mkdir(realNativePkg, { recursive: true }); + await fs.promises.writeFile( + path.join(realNativePkg, 'binding.gyp'), + '{}', + 'utf-8', + ); + await fs.promises.writeFile( + path.join(realNativePkg, 'package.json'), + JSON.stringify({ + name: 'native-pkg', + dependencies: { 'store-helper': '^1.0.0' }, + }), + 'utf-8', + ); + + // The dependency is only reachable as a sibling in the virtual store, + // not from the top-level node_modules. + const storeHelper = path.join(storePkgDir, 'store-helper'); + await fs.promises.mkdir(storeHelper, { recursive: true }); + await fs.promises.writeFile( + path.join(storeHelper, 'package.json'), + JSON.stringify({ name: 'store-helper' }), + 'utf-8', + ); + + // Top-level node_modules only contains a symlink to the store. + await fs.promises.symlink( + realNativePkg, + path.join(nm, 'native-pkg'), + 'junction', + ); + }); + + it('detects native packages through top-level symlinks', () => { + const result = detectNativePackages(projectDir); + + expect(result).toContain('native-pkg'); + }); + + it('resolves transitive dependencies through the real path', () => { + const result = walkTransitiveDependencies(projectDir, ['native-pkg']); + + expect(result).toContain('native-pkg'); + expect(result).toContain('store-helper'); + }); }); }); diff --git a/packages/plugin/vite/src/detect-native-modules.ts b/packages/plugin/vite/src/detect-native-modules.ts index 6cd070c758..74fac0163f 100644 --- a/packages/plugin/vite/src/detect-native-modules.ts +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -19,7 +19,106 @@ function readJsonSafe(filePath: string): Record | null { } } +/** + * Resolve symlinks so that packages installed through symlinking package + * managers (pnpm's default layout, or `npm link`ed packages) are inspected at + * their real location. Falls back to the input path if it cannot be resolved. + */ +function realpathSafe(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +/** + * Resolve the on-disk directory of a dependency the way Node's module + * resolution does: look in `fromDir/node_modules/` first, then walk up + * the ancestor directories checking each `node_modules` along the way. + * + * This handles npm conflict-nesting (nested node_modules), monorepo/workspace + * hoisting (dependencies installed above the project root), and — because + * `fromDir` is resolved to its real path — pnpm's virtual store layout, where + * a package's dependencies live in a sibling `node_modules` inside `.pnpm`. + */ +export function resolvePackageDir( + fromDir: string, + name: string, +): string | null { + let dir = realpathSafe(fromDir); + for (;;) { + // Node never looks inside node_modules/node_modules. + if (path.basename(dir) !== 'node_modules') { + const candidate = path.join(dir, 'node_modules', name); + if (fs.existsSync(candidate)) { + return realpathSafe(candidate); + } + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +function hasNodeFileInDir(dir: string): boolean { + try { + return fs.readdirSync(dir).some((f) => f.endsWith('.node')); + } catch { + return false; + } +} + +/** + * Whether a package looks like a platform-specific prebuilt binary package — + * the convention used by napi-rs and similar tooling, where the JS entry + * package lists per-platform packages in `optionalDependencies` (e.g. + * `sharp` → `@img/sharp-darwin-arm64`, `libsql` → `@libsql/darwin-arm64`, + * `@parcel/watcher` → `@parcel/watcher-linux-x64-glibc`). + */ +export function isNapiPlatformPackage(pkgDir: string): boolean { + const pkg = readJsonSafe(path.join(pkgDir, 'package.json')); + + // e.g. @libsql/darwin-arm64 has `"main": "index.node"`. + if (pkg && typeof pkg.main === 'string' && pkg.main.endsWith('.node')) { + return true; + } + + // A `.node` binary shipped at the package root. + if (hasNodeFileInDir(pkgDir)) { + return true; + } + + // Platform packages constrain `os` + `cpu` and may nest the binary one + // level deep (e.g. @img/sharp-darwin-arm64 ships lib/sharp-*.node). + if (pkg && Array.isArray(pkg.os) && Array.isArray(pkg.cpu)) { + try { + for (const entry of fs.readdirSync(pkgDir, { withFileTypes: true })) { + if ( + !entry.isDirectory() || + entry.name.startsWith('.') || + entry.name === 'node_modules' + ) { + continue; + } + if (hasNodeFileInDir(path.join(pkgDir, entry.name))) { + return true; + } + } + } catch { + // Directory isn't readable + } + } + + return false; +} + export function isNativePackage(pkgDir: string): boolean { + // Follow symlinks (pnpm layout, npm link) to the real package contents. + pkgDir = realpathSafe(pkgDir); + if (fs.existsSync(path.join(pkgDir, 'binding.gyp'))) return true; if (fs.existsSync(path.join(pkgDir, 'prebuilds'))) return true; @@ -33,21 +132,49 @@ export function isNativePackage(pkgDir: string): boolean { const pkg = readJsonSafe(path.join(pkgDir, 'package.json')); if (pkg) { + // napi-rs projects declare a `napi` config block in package.json. + if (pkg.napi != null) return true; + const deps = Object.keys( (pkg.dependencies as Record) ?? {}, ); if (deps.some((dep) => NATIVE_DEPENDENCY_MARKERS.includes(dep))) { return true; } + + // Prebuilt-binary convention: platform-specific packages listed as + // optionalDependencies. Packages for other platforms are routinely not + // installed, so any that fail to resolve are skipped. + const optionalDeps = Object.keys( + (pkg.optionalDependencies as Record) ?? {}, + ); + for (const dep of optionalDeps) { + const depDir = resolvePackageDir(pkgDir, dep); + if (depDir && isNapiPlatformPackage(depDir)) { + return true; + } + } } return false; } +let warnedAboutPnpm = false; + export function detectNativePackages(projectDir: string): string[] { const nodeModulesDir = path.join(projectDir, 'node_modules'); if (!fs.existsSync(nodeModulesDir)) return []; + if (!warnedAboutPnpm && fs.existsSync(path.join(nodeModulesDir, '.pnpm'))) { + warnedAboutPnpm = true; + console.warn( + '[@electron-forge/plugin-vite] Detected a pnpm-style symlinked node_modules layout. ' + + 'Native module detection follows the top-level symlinks, but support for pnpm layouts is limited: ' + + 'transitive dependencies of native modules that only exist inside the .pnpm virtual store may not be copied into the packaged app. ' + + 'If native dependencies are missing at runtime, set node-linker=hoisted in your .npmrc.', + ); + } + const results: string[] = []; let entries: string[]; @@ -90,23 +217,42 @@ export function walkTransitiveDependencies( packages: string[], ): Set { const all = new Set(packages); - const queue = [...packages]; + const queue: { name: string; dir: string | null }[] = packages.map( + (name) => ({ + name, + dir: resolvePackageDir(projectDir, name), + }), + ); while (queue.length > 0) { - const pkg = queue.pop()!; - const pkgJson = readJsonSafe( - path.join(projectDir, 'node_modules', pkg, 'package.json'), - ); + const { dir } = queue.pop()!; + if (!dir) continue; + + const pkgJson = readJsonSafe(path.join(dir, 'package.json')); if (!pkgJson) continue; - for (const dep of Object.keys( - (pkgJson.dependencies as Record) ?? {}, - )) { - if (!all.has(dep)) { + const enqueue = ( + deps: Record | undefined, + optional: boolean, + ) => { + for (const dep of Object.keys(deps ?? {})) { + if (all.has(dep)) continue; + const depDir = resolvePackageDir(dir, dep); + if (!depDir && optional) { + // Optional dependencies for other platforms/architectures are + // routinely absent from the install — skip them silently. + continue; + } all.add(dep); - queue.push(dep); + queue.push({ name: dep, dir: depDir }); } - } + }; + + enqueue(pkgJson.dependencies as Record | undefined, false); + enqueue( + pkgJson.optionalDependencies as Record | undefined, + true, + ); } d('native packages with transitive deps:', [...all]); From 9fc9fd7e39c46ce856d7ba5b12e067a563dbfdfe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:54:21 +0000 Subject: [PATCH 4/6] fix(plugin-vite): compose user packagerConfig.ignore instead of skipping the copy allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the plugin backed off entirely when the user supplied their own packagerConfig.ignore, which silently broke the native module copy step: the externalized modules were never allowlisted, so the packaged app was missing them at runtime. The plugin now composes the two: a path is ignored if either the user's ignore (function, RegExp, or array) or the plugin's logic excludes it, but the paths the plugin needs to keep — /.vite, /package.json, the /node_modules root and the externalized native module directories — always survive, because without them the packaged app cannot start. Also keep bare scope directories (e.g. /node_modules/@serialport) when an allowlisted module lives under them; ignoring the scope directory itself would prevent packager from ever descending into it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KhNR823EZtFRRnQVJBMo1t --- packages/plugin/vite/spec/VitePlugin.spec.ts | 92 ++++++++++++++++- packages/plugin/vite/src/VitePlugin.ts | 102 +++++++++++++------ 2 files changed, 162 insertions(+), 32 deletions(-) diff --git a/packages/plugin/vite/spec/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index 1e21eb964b..3fa69eab50 100644 --- a/packages/plugin/vite/spec/VitePlugin.spec.ts +++ b/packages/plugin/vite/spec/VitePlugin.spec.ts @@ -107,15 +107,103 @@ describe('VitePlugin', async () => { expect(config.packagerConfig.ignore).toBeTypeOf('function'); }); + // Populate the plugin's private set of externalized modules, normally + // filled in during the prePackage hook. + const setExternalModules = (target: VitePlugin, modules: string[]) => { + (target as unknown as { externalModules: Set }).externalModules = + new Set(modules); + }; + describe('packagerConfig.ignore', () => { - it('does not overwrite an existing ignore value', async () => { + it('composes an existing RegExp ignore value with the plugin ignore', async () => { const config = await plugin.resolveForgeConfig({ packagerConfig: { ignore: /test/, }, } as ResolvedForgeConfig); - expect(config.packagerConfig.ignore).toEqual(/test/); + // The user value is composed into a function rather than left as-is. + expect(config.packagerConfig.ignore).toBeTypeOf('function'); + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + // Plugin keep-list still survives... + expect(ignore('/.vite/build/main.js')).toEqual(false); + expect(ignore('/package.json')).toEqual(false); + // ...even when the user pattern matches it. + expect(ignore('/.vite/build/test.js')).toEqual(false); + // Everything else is still excluded. + expect(ignore('/test/foo.js')).toEqual(true); + expect(ignore('/src/main.ts')).toEqual(true); + }); + + it('composes a user ignore function with the plugin allowlist', async () => { + plugin = new VitePlugin(baseConfig); + const userIgnore = (file: string) => + file.includes('secret') || file.startsWith('/node_modules'); + const config = await plugin.resolveForgeConfig({ + packagerConfig: { + ignore: userIgnore, + }, + } as ResolvedForgeConfig); + setExternalModules(plugin, [ + 'better-sqlite3', + '@serialport/bindings-cpp', + ]); + + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + // The plugin's keep-list always wins, even though the user's ignore + // excludes all of node_modules — otherwise the externalized native + // modules would be missing from the packaged app. + expect(ignore('/node_modules')).toEqual(false); + expect(ignore('/node_modules/better-sqlite3')).toEqual(false); + expect( + ignore('/node_modules/better-sqlite3/build/Release/addon.node'), + ).toEqual(false); + expect(ignore('/node_modules/@serialport')).toEqual(false); + expect(ignore('/node_modules/@serialport/bindings-cpp')).toEqual(false); + expect( + ignore('/node_modules/@serialport/bindings-cpp/prebuilds/x.node'), + ).toEqual(false); + expect(ignore('/.vite/build/secret.js')).toEqual(false); + + // Paths outside the keep-list follow user or plugin exclusions. + expect(ignore('/node_modules/lodash')).toEqual(true); + expect(ignore('/node_modules/@scope/other')).toEqual(true); + expect(ignore('/secret.txt')).toEqual(true); + expect(ignore('/src/main.ts')).toEqual(true); + }); + + it('composes an array of RegExps', async () => { + plugin = new VitePlugin(baseConfig); + const config = await plugin.resolveForgeConfig({ + packagerConfig: { + ignore: [/foo/, /bar/], + }, + } as ResolvedForgeConfig); + + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + expect(ignore('/.vite')).toEqual(false); + expect(ignore('/foo')).toEqual(true); + expect(ignore('/bar')).toEqual(true); + expect(ignore('/baz')).toEqual(true); + }); + + it('keeps allowlisted native modules without a user ignore', async () => { + plugin = new VitePlugin(baseConfig); + const config = await plugin.resolveForgeConfig( + {} as ResolvedForgeConfig, + ); + setExternalModules(plugin, ['better-sqlite3']); + + const ignore = config.packagerConfig.ignore as IgnoreFunction; + + expect(ignore('/node_modules/better-sqlite3')).toEqual(false); + expect(ignore('/node_modules/better-sqlite3/lib/index.js')).toEqual( + false, + ); + expect(ignore('/node_modules/lodash')).toEqual(true); }); it('ignores everything but .vite and package.json', async () => { diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 8e1efb6278..c562217d85 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -20,6 +20,7 @@ import type { VitePluginConfig } from './Config.js'; import type { ForgeListrTask, ForgeMultiHookMap, + ForgePackagerOptions, ResolvedForgeConfig, } from '@electron-forge/shared-types'; import type { ChildProcess } from 'node:child_process'; @@ -149,6 +150,24 @@ function spawnViteBuildWatch( return { child, firstBuild }; } +/** + * Normalize the packager `ignore` option (function, RegExp, or array of + * RegExps/patterns) into a predicate so the plugin can compose the user's + * ignore with its own. + */ +function normalizeIgnore( + ignore: ForgePackagerOptions['ignore'], +): ((file: string) => boolean) | undefined { + if (ignore == null) return undefined; + if (typeof ignore === 'function') { + return ignore as (file: string) => boolean; + } + const patterns = (Array.isArray(ignore) ? ignore : [ignore]).map((pattern) => + pattern instanceof RegExp ? pattern : new RegExp(String(pattern)), + ); + return (file: string) => patterns.some((pattern) => pattern.test(file)); +} + function entryToDisplay(entry: LibraryOptions['entry']): string { if (typeof entry === 'string') return entry; if (Array.isArray(entry)) return entry.join(' '); @@ -312,46 +331,69 @@ export default class VitePlugin extends PluginBase { }; }; + /** + * Whether the plugin needs `file` to survive the packager copy step for the + * packaged app to work: the Vite output (`/.vite`), the app's + * `/package.json`, the `/node_modules` directory itself, and the directories + * of native modules (plus their transitive dependencies) that were + * externalized from the Vite bundle. `this.externalModules` is populated + * during prePackage after the Vite build completes. + * + * `file` always starts with `/` + * @see - https://github.com/electron/packager/blob/v18.1.3/src/copy-filter.ts#L89-L93 + */ + private mustKeepForPackage = (file: string): boolean => { + if (file.startsWith('/.vite')) return true; + if (file === '/package.json') return true; + + if (file === '/node_modules') return true; + if (file.startsWith('/node_modules/')) { + const bare = file.slice('/node_modules/'.length); + const segments = bare.split('/'); + if (segments[0].startsWith('@')) { + // A bare scope directory (e.g. `/node_modules/@serialport`) must be + // kept whenever any allowlisted module lives under it — if the + // directory itself is ignored, packager never descends into it. + if (segments.length === 1) { + for (const name of this.externalModules) { + if (name.startsWith(`${segments[0]}/`)) return true; + } + return false; + } + return this.externalModules.has(`${segments[0]}/${segments[1]}`); + } + return this.externalModules.has(segments[0]); + } + + return false; + }; + resolveForgeConfig = async ( forgeConfig: ResolvedForgeConfig, ): Promise => { forgeConfig.packagerConfig ??= {}; - if (forgeConfig.packagerConfig.ignore) { - if (typeof forgeConfig.packagerConfig.ignore !== 'function') { - console.error( - styleText( - 'yellow', - `You have set packagerConfig.ignore, the Electron Forge Vite plugin normally sets this automatically. - -Your packaged app may be larger than expected if you dont ignore everything other than the '.vite' folder`, - ), - ); - } - return forgeConfig; - } + const userIgnore = normalizeIgnore(forgeConfig.packagerConfig.ignore); + // Compose the user's ignore with the plugin's own logic instead of + // deferring to the user entirely: a path is ignored if either the user's + // ignore or the plugin's ignore excludes it. The plugin's keep-list is + // checked first and always wins, because without the Vite output, the + // app's package.json and the externalized native modules the packaged app + // cannot start at all — a user ignore pattern like /node_modules/ or /^\/(?!\.vite)/ + // (both common before this plugin handled ignore automatically) would + // otherwise silently strip files the plugin just externalized for. forgeConfig.packagerConfig.ignore = (file: string) => { if (!file) return false; - // `file` always starts with `/` - // @see - https://github.com/electron/packager/blob/v18.1.3/src/copy-filter.ts#L89-L93 - - if (file.startsWith('/.vite')) return false; - if (file === '/package.json') return false; - - // Include node_modules that were externalized by the Vite build. - // The set is populated during prePackage after the Vite build completes. - if (file.startsWith('/node_modules')) { - if (file === '/node_modules') return false; - const bare = file.slice('/node_modules/'.length); - const segments = bare.split('/'); - const name = segments[0].startsWith('@') - ? `${segments[0]}/${segments[1]}` - : segments[0]; - return !this.externalModules.has(name); - } + // Paths the plugin must keep always survive, even if the user's ignore + // matches them — see comment above. + if (this.mustKeepForPackage(file)) return false; + + if (userIgnore?.(file)) return true; + // The plugin's default behaviour: everything that is not on the + // keep-list is excluded from the packaged app. return true; }; return forgeConfig; From 4fc8d4d213dccb3be5c3a476ce1bafb8dd0adf9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:56:10 +0000 Subject: [PATCH 5/6] feat(plugin-vite): add nativeModules include/exclude overrides Add a nativeModules option to the plugin config as a manual escape hatch for the automatic native module detection: - include forces packages into the externalize-and-copy set (rollup external list, packager copy allowlist, and their transitive dependencies). - exclude removes detected packages so Vite bundles them normally. The overrides are applied both in the main/preload Vite config generation (which runs in subprocess workers, so nativeModules is now part of the serialized worker config) and in the prePackage detection that feeds the packager ignore allowlist. Documented in the README. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KhNR823EZtFRRnQVJBMo1t --- packages/plugin/vite/README.md | 29 +++++++++++++ .../vite/spec/detect-native-modules.spec.ts | 41 +++++++++++++++++++ packages/plugin/vite/src/Config.ts | 12 ++++++ packages/plugin/vite/src/VitePlugin.ts | 18 +++++--- .../vite/src/config/vite.main.config.ts | 12 ++++-- .../vite/src/config/vite.preload.config.ts | 12 ++++-- .../plugin/vite/src/detect-native-modules.ts | 34 +++++++++++++++ 7 files changed, 146 insertions(+), 12 deletions(-) diff --git a/packages/plugin/vite/README.md b/packages/plugin/vite/README.md index b0eba8c88a..58fd66f3fa 100644 --- a/packages/plugin/vite/README.md +++ b/packages/plugin/vite/README.md @@ -36,3 +36,32 @@ module.exports = { ] }; ``` + +### Native Node modules + +The plugin automatically detects native Node modules (e.g. `better-sqlite3`, `serialport`, `sharp`), externalizes them from the Vite bundle, and copies them — along with their transitive dependencies — into the packaged app. + +If detection misses a package (or flags one it shouldn't), use the `nativeModules` option to override it: + +```javascript +// forge.config.js + +module.exports = { + plugins: [ + { + name: '@electron-forge/plugin-vite', + config: { + build: [], + renderer: [], + nativeModules: { + // Force packages to be treated as native: externalized from the + // bundle and copied into the packaged app with their dependencies. + include: ['some-native-package'], + // Remove packages from the detected set so Vite bundles them normally. + exclude: ['false-positive-package'] + } + } + } + ] +}; +``` diff --git a/packages/plugin/vite/spec/detect-native-modules.spec.ts b/packages/plugin/vite/spec/detect-native-modules.spec.ts index 225b357f99..c7f097c532 100644 --- a/packages/plugin/vite/spec/detect-native-modules.spec.ts +++ b/packages/plugin/vite/spec/detect-native-modules.spec.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { + applyNativeModuleOverrides, detectNativePackages, isNativePackage, walkTransitiveDependencies, @@ -633,4 +634,44 @@ describe('detect-native-modules', () => { expect(result).toContain('store-helper'); }); }); + + describe('applyNativeModuleOverrides', () => { + it('returns the detected list when no overrides are given', () => { + expect(applyNativeModuleOverrides(['a', 'b'])).toEqual(['a', 'b']); + expect(applyNativeModuleOverrides(['a', 'b'], {})).toEqual(['a', 'b']); + }); + + it('adds packages from include', () => { + const result = applyNativeModuleOverrides(['a'], { + include: ['b', '@scope/c'], + }); + + expect(result).toContain('a'); + expect(result).toContain('b'); + expect(result).toContain('@scope/c'); + }); + + it('does not duplicate already-detected includes', () => { + const result = applyNativeModuleOverrides(['a'], { include: ['a'] }); + + expect(result).toEqual(['a']); + }); + + it('removes packages from exclude', () => { + const result = applyNativeModuleOverrides(['a', 'b'], { + exclude: ['b'], + }); + + expect(result).toEqual(['a']); + }); + + it('applies exclude after include', () => { + const result = applyNativeModuleOverrides(['a'], { + include: ['b'], + exclude: ['b'], + }); + + expect(result).toEqual(['a']); + }); + }); }); diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..e7b34f3b0d 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -1,3 +1,4 @@ +import type { NativeModulesConfig } from './detect-native-modules.js'; import type { LibraryOptions } from 'vite'; export interface VitePluginBuildConfig { @@ -49,4 +50,15 @@ export interface VitePluginConfig { * @defaultValue `true` */ concurrent?: boolean | number; + + /** + * Manual overrides for native Node module detection. + * + * The plugin automatically detects native modules, externalizes them from + * the Vite bundle, and copies them (along with their transitive + * dependencies) into the packaged app. Use `include` to force packages into + * that set when detection misses them, and `exclude` to remove detected + * packages so they are bundled by Vite instead. + */ + nativeModules?: NativeModulesConfig; } diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index c562217d85..9f34ea1213 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -11,6 +11,7 @@ import * as vite from 'vite'; import { viteDevServerUrls } from './config/vite.base.config.js'; import { + applyNativeModuleOverrides, detectNativePackages, walkTransitiveDependencies, } from './detect-native-modules.js'; @@ -35,7 +36,7 @@ const subprocessWorkerPath = path.resolve( ); function spawnViteBuild( - pluginConfig: Pick, + pluginConfig: Pick, kind: 'build' | 'renderer', index: number, projectDir: string, @@ -85,7 +86,7 @@ function spawnViteBuild( } function spawnViteBuildWatch( - pluginConfig: Pick, + pluginConfig: Pick, index: number, projectDir: string, devServerUrls: Record, @@ -302,7 +303,10 @@ export default class VitePlugin extends PluginBase { { title: 'Detecting native dependencies...', task: async (_ctx, subtask) => { - const nativePackages = detectNativePackages(this.projectDir); + const nativePackages = applyNativeModuleOverrides( + detectNativePackages(this.projectDir), + this.config.nativeModules, + ); this.externalModules = walkTransitiveDependencies( this.projectDir, nativePackages, @@ -422,16 +426,18 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); /** * Serializable snapshot of the plugin config to pass to subprocess workers. - * We only include build[] and renderer[] — the worker needs the full renderer - * list for defines even when building a single main target. + * We only include build[], renderer[] and nativeModules — the worker needs + * the full renderer list for defines even when building a single main + * target, and the nativeModules overrides to build the rollup external list. */ private get serializableConfig(): Pick< VitePluginConfig, - 'build' | 'renderer' + 'build' | 'renderer' | 'nativeModules' > { return { build: this.config.build, renderer: this.config.renderer, + nativeModules: this.config.nativeModules, }; } diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index 85899fe388..46458f7c1f 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,6 +1,9 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; -import { detectNativePackages } from '../detect-native-modules.js'; +import { + applyNativeModuleOverrides, + detectNativePackages, +} from '../detect-native-modules.js'; import { external, getBuildConfig, @@ -12,8 +15,11 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; - const nativePackages = detectNativePackages(forgeEnv.root); + const { forgeConfig, forgeConfigSelf } = forgeEnv; + const nativePackages = applyNativeModuleOverrides( + detectNativePackages(forgeEnv.root), + forgeConfig.nativeModules, + ); const define = getBuildDefine(forgeEnv); const config: UserConfig = { build: { diff --git a/packages/plugin/vite/src/config/vite.preload.config.ts b/packages/plugin/vite/src/config/vite.preload.config.ts index 52c15d1bba..da1d1f386f 100644 --- a/packages/plugin/vite/src/config/vite.preload.config.ts +++ b/packages/plugin/vite/src/config/vite.preload.config.ts @@ -1,6 +1,9 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; -import { detectNativePackages } from '../detect-native-modules.js'; +import { + applyNativeModuleOverrides, + detectNativePackages, +} from '../detect-native-modules.js'; import { external, getBuildConfig, @@ -11,8 +14,11 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; - const nativePackages = detectNativePackages(forgeEnv.root); + const { forgeConfig, forgeConfigSelf } = forgeEnv; + const nativePackages = applyNativeModuleOverrides( + detectNativePackages(forgeEnv.root), + forgeConfig.nativeModules, + ); const config: UserConfig = { build: { copyPublicDir: false, diff --git a/packages/plugin/vite/src/detect-native-modules.ts b/packages/plugin/vite/src/detect-native-modules.ts index 74fac0163f..9902046a28 100644 --- a/packages/plugin/vite/src/detect-native-modules.ts +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -11,6 +11,20 @@ const NATIVE_DEPENDENCY_MARKERS = [ 'prebuild-install', ]; +export interface NativeModulesConfig { + /** + * Package names to always treat as native. They are externalized from the + * Vite bundle and copied (with their transitive dependencies) into the + * packaged app, even if automatic detection misses them. + */ + include?: string[]; + /** + * Package names to remove from the automatically detected set. They are + * bundled by Vite like any other JavaScript dependency. + */ + exclude?: string[]; +} + function readJsonSafe(filePath: string): Record | null { try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); @@ -212,6 +226,26 @@ export function detectNativePackages(projectDir: string): string[] { return results; } +/** + * Apply the user's manual `nativeModules` overrides to a detected package + * list: `include` entries are added, `exclude` entries are removed. + */ +export function applyNativeModuleOverrides( + detected: string[], + overrides?: NativeModulesConfig, +): string[] { + if (!overrides) return detected; + + const result = new Set(detected); + for (const name of overrides.include ?? []) { + result.add(name); + } + for (const name of overrides.exclude ?? []) { + result.delete(name); + } + return [...result]; +} + export function walkTransitiveDependencies( projectDir: string, packages: string[], From e08baeea6c2dfb2ac21bee638884e14929049124 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:20:09 +0000 Subject: [PATCH 6/6] fix(plugin-vite): keep debug import out of consumer typecheck graph The vite-typescript template's declarations.d.ts references @electron-forge/plugin-vite/forge-vite-env, which imports src/Config.ts from the published package sources. Importing NativeModulesConfig from detect-native-modules.ts pulled that implementation file (and its untyped 'debug' import) into the scaffolded app's tsc graph, failing 'npm run typecheck' with TS7016 since consumers don't have @types/debug. Move the NativeModulesConfig interface into Config.ts alongside the other plugin config interfaces and invert the import, so the consumer graph stays limited to the type-only config surface as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KhNR823EZtFRRnQVJBMo1t --- packages/plugin/vite/src/Config.ts | 15 ++++++++++++++- .../plugin/vite/src/detect-native-modules.ts | 16 ++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index e7b34f3b0d..c0d6882ef8 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -1,6 +1,19 @@ -import type { NativeModulesConfig } from './detect-native-modules.js'; import type { LibraryOptions } from 'vite'; +export interface NativeModulesConfig { + /** + * Package names to always treat as native. They are externalized from the + * Vite bundle and copied (with their transitive dependencies) into the + * packaged app, even if automatic detection misses them. + */ + include?: string[]; + /** + * Package names to remove from the automatically detected set. They are + * bundled by Vite like any other JavaScript dependency. + */ + exclude?: string[]; +} + export interface VitePluginBuildConfig { /** * Alias of `build.lib.entry` in `config`. diff --git a/packages/plugin/vite/src/detect-native-modules.ts b/packages/plugin/vite/src/detect-native-modules.ts index 9902046a28..9ce80bcbe5 100644 --- a/packages/plugin/vite/src/detect-native-modules.ts +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import debug from 'debug'; +import type { NativeModulesConfig } from './Config.js'; + const d = debug('electron-forge:plugin:vite:native-modules'); const NATIVE_DEPENDENCY_MARKERS = [ @@ -11,20 +13,6 @@ const NATIVE_DEPENDENCY_MARKERS = [ 'prebuild-install', ]; -export interface NativeModulesConfig { - /** - * Package names to always treat as native. They are externalized from the - * Vite bundle and copied (with their transitive dependencies) into the - * packaged app, even if automatic detection misses them. - */ - include?: string[]; - /** - * Package names to remove from the automatically detected set. They are - * bundled by Vite like any other JavaScript dependency. - */ - exclude?: string[]; -} - function readJsonSafe(filePath: string): Record | null { try { return JSON.parse(fs.readFileSync(filePath, 'utf-8'));