From c1e49c2e3552fd85aa1a07ba5197c7e54456f590 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 20 Apr 2026 22:05:33 -0700 Subject: [PATCH 1/2] 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/2] 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; +}