diff --git a/packages/plugin/vite/spec/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index a67d91ea61..1e21eb964b 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,34 @@ 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('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; + + expect(ignore('/node_modules/unknown-pkg')).toEqual(true); + expect(ignore('/node_modules/unknown-pkg/index.js')).toEqual(true); }); it('ignores source map files by default', async () => { 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..b496a0643e --- /dev/null +++ b/packages/plugin/vite/spec/detect-native-modules.spec.ts @@ -0,0 +1,295 @@ +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, + walkTransitiveDependencies, +} 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([]); + }); + }); + + 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 764d0408a7..e9af37eca0 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -9,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'; @@ -173,6 +177,8 @@ export default class VitePlugin extends PluginBase { private servers: vite.ViteDevServer[] = []; + private externalModules = new Set(); + init = (dir: string): void => { this.setDirectories(dir); @@ -250,21 +256,46 @@ 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: 'Detecting native dependencies...', task: async (_ctx, subtask) => { - const results = await this.buildRenderer(subtask); - return results; + 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 { + subtask.title = 'No externalized dependencies detected'; + } }, }, ], - { concurrent: true }, + { concurrent: false }, ); }, 'Building production Vite bundles'), ], @@ -302,8 +333,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; }; 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..6cd070c758 --- /dev/null +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -0,0 +1,114 @@ +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', +]; + +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; + + 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 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; + } + } + + 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; +} + +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; +}