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/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index a67d91ea61..3fa69eab50 100644 --- a/packages/plugin/vite/spec/VitePlugin.spec.ts +++ b/packages/plugin/vite/spec/VitePlugin.spec.ts @@ -107,18 +107,106 @@ 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('ignores everything but files in .vite', async () => { + 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 () => { const config = await plugin.resolveForgeConfig( {} as ResolvedForgeConfig, ); @@ -126,8 +214,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..c7f097c532 --- /dev/null +++ b/packages/plugin/vite/spec/detect-native-modules.spec.ts @@ -0,0 +1,677 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + applyNativeModuleOverrides, + 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, + ); + }); + + 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', () => { + 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); + }); + + 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'); + }); + }); + + 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..c0d6882ef8 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -1,5 +1,19 @@ 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`. @@ -49,4 +63,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 7219b25051..9f34ea1213 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -10,12 +10,18 @@ import { Listr, PRESET_TIMER } from 'listr2'; import * as vite from 'vite'; import { viteDevServerUrls } from './config/vite.base.config.js'; +import { + applyNativeModuleOverrides, + detectNativePackages, + walkTransitiveDependencies, +} from './detect-native-modules.js'; import ViteConfigGenerator from './ViteConfig.js'; import type { VitePluginConfig } from './Config.js'; import type { ForgeListrTask, ForgeMultiHookMap, + ForgePackagerOptions, ResolvedForgeConfig, } from '@electron-forge/shared-types'; import type { ChildProcess } from 'node:child_process'; @@ -30,7 +36,7 @@ const subprocessWorkerPath = path.resolve( ); function spawnViteBuild( - pluginConfig: Pick, + pluginConfig: Pick, kind: 'build' | 'renderer', index: number, projectDir: string, @@ -80,7 +86,7 @@ function spawnViteBuild( } function spawnViteBuildWatch( - pluginConfig: Pick, + pluginConfig: Pick, index: number, projectDir: string, devServerUrls: Record, @@ -145,6 +151,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(' '); @@ -174,6 +198,8 @@ export default class VitePlugin extends PluginBase { private servers: vite.ViteDevServer[] = []; + private externalModules = new Set(); + init = (dir: string): void => { this.setDirectories(dir); @@ -251,21 +277,49 @@ 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 = applyNativeModuleOverrides( + detectNativePackages(this.projectDir), + this.config.nativeModules, + ); + 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'), ], @@ -281,33 +335,70 @@ 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 + // 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; - // Collect the files built by Vite - return !file.startsWith('/.vite'); + // The plugin's default behaviour: everything that is not on the + // keep-list is excluded from the packaged app. + return true; }; return forgeConfig; }; @@ -335,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 ab6d1bc2d1..46458f7c1f 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,5 +1,9 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; +import { + applyNativeModuleOverrides, + detectNativePackages, +} from '../detect-native-modules.js'; import { external, getBuildConfig, @@ -11,13 +15,17 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { forgeConfig, forgeConfigSelf } = forgeEnv; + const nativePackages = applyNativeModuleOverrides( + detectNativePackages(forgeEnv.root), + forgeConfig.nativeModules, + ); 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..da1d1f386f 100644 --- a/packages/plugin/vite/src/config/vite.preload.config.ts +++ b/packages/plugin/vite/src/config/vite.preload.config.ts @@ -1,5 +1,9 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; +import { + applyNativeModuleOverrides, + detectNativePackages, +} from '../detect-native-modules.js'; import { external, getBuildConfig, @@ -10,12 +14,16 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { forgeConfig, forgeConfigSelf } = forgeEnv; + const nativePackages = applyNativeModuleOverrides( + detectNativePackages(forgeEnv.root), + forgeConfig.nativeModules, + ); 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..9ce80bcbe5 --- /dev/null +++ b/packages/plugin/vite/src/detect-native-modules.ts @@ -0,0 +1,282 @@ +import fs from 'node:fs'; +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 = [ + 'bindings', + 'node-gyp-build', + 'prebuild-install', +]; + +function readJsonSafe(filePath: string): Record | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return 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; + + 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) { + // 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[]; + 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; +} + +/** + * 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[], +): Set { + const all = new Set(packages); + const queue: { name: string; dir: string | null }[] = packages.map( + (name) => ({ + name, + dir: resolvePackageDir(projectDir, name), + }), + ); + + while (queue.length > 0) { + const { dir } = queue.pop()!; + if (!dir) continue; + + const pkgJson = readJsonSafe(path.join(dir, 'package.json')); + if (!pkgJson) continue; + + 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({ 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]); + return all; +}