diff --git a/packages/plugin/vite/README.md b/packages/plugin/vite/README.md index b0eba8c88a..af85895784 100644 --- a/packages/plugin/vite/README.md +++ b/packages/plugin/vite/README.md @@ -36,3 +36,26 @@ module.exports = { ] }; ``` + +### Node.js integration + +Set `nodeIntegration: true` on a renderer entry to make Electron and Node.js +imports available in both the Vite development server and production builds. +The option configures Vite only; the matching `BrowserWindow` must also use +`webPreferences: { nodeIntegration: true, contextIsolation: false }`. + +```javascript +renderer: [ + { + name: 'main_window', + config: 'vite.renderer.config.mjs', + nodeIntegration: true + } +]; +``` + +Enabling Node.js integration gives renderer code direct access to the local +system. Do not use it for remote or otherwise untrusted content. Prefer a +preload script and `contextBridge` when possible. See Electron's +[security recommendations](https://www.electronjs.org/docs/latest/tutorial/security) +for more information. diff --git a/packages/plugin/vite/spec/ViteConfig.spec.ts b/packages/plugin/vite/spec/ViteConfig.spec.ts index 76069df91d..48a2ef026e 100644 --- a/packages/plugin/vite/spec/ViteConfig.spec.ts +++ b/packages/plugin/vite/spec/ViteConfig.spec.ts @@ -117,4 +117,26 @@ describe('ViteConfigGenerator', () => { expect(rendererConfig.resolve).toEqual({ preserveSymlinks: true }); expect(rendererConfig.clearScreen).toBe(false); }); + + it('getRendererConfig:renderer with Node.js integration', async () => { + const forgeConfig: VitePluginConfig = { + build: [], + renderer: [ + { + name: 'main_window', + config: path.join(configRoot, 'vite.renderer.config.mjs'), + nodeIntegration: true, + }, + ], + }; + const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); + const rendererConfig = (await generator.getRendererConfig())[0]; + + expect( + rendererConfig.plugins?.map((plugin) => (plugin as Plugin).name), + ).toEqual([ + '@electron-forge/plugin-vite:expose-renderer', + '@electron-forge/plugin-vite:node-integration', + ]); + }); }); diff --git a/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts new file mode 100644 index 0000000000..8cf4397181 --- /dev/null +++ b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts @@ -0,0 +1,215 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as vite from 'vite'; +import { build, createServer, resolveConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { pluginNodeIntegration } from '../../src/config/vite.node-integration.config'; + +describe('pluginNodeIntegration', () => { + let root: string; + + beforeEach(async () => { + const temporaryRoot = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'electron-forge-vite-node-integration-'), + ); + root = await fs.promises.realpath(temporaryRoot); + await fs.promises.writeFile( + path.join(root, 'renderer.js'), + ` +import electron, { ipcRenderer } from 'electron'; +import * as fs from 'node:fs'; +import { join } from 'node:path'; + +window.audit = async () => ({ + electron: electron.ipcRenderer === ipcRenderer, + exists: fs.existsSync(join(process.cwd(), 'package.json')), + platform: (await import('node:os')).platform(), +}); +`, + ); + }); + + afterEach(async () => { + await fs.promises.rm(root, { recursive: true, force: true }); + }); + + it('preserves Node and Electron imports in production builds', async () => { + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [pluginNodeIntegration()], + build: { + minify: false, + write: false, + rollupOptions: { input: path.join(root, 'renderer.js') }, + }, + }); + const output = (Array.isArray(result) ? result : [result]) + .flatMap((buildResult) => buildResult.output) + .filter((item) => item.type === 'chunk') + .map((item) => item.code) + .join('\n'); + + // Assert the *behaviour* -- each module is required at runtime rather than + // bundled -- not one bundler's spelling of it. Rollup emits the shim's + // `require("electron")` verbatim; Rolldown (Vite 8+) rewrites it into an + // interop wrapper that ends in `}))("electron")` and reaches `require` + // through `require.apply(this, arguments)`. Matching a literal `require(` + // passes on Rollup and silently fails on Rolldown even when the output is + // correct, so match the requested specifier next to a `require` mention. + for (const specifier of ['electron', 'node:fs', 'node:path', 'node:os']) { + expect(output).toContain(JSON.stringify(specifier)); + } + expect(output).toMatch(/require/); + // The real regression this guards: if the shim's require gets resolved or + // tree-shaken away, Vite substitutes its empty browser stub instead. + expect(output).not.toContain('__vite-browser-external'); + // ...and it must not bundle the npm `electron` package, which outside + // Electron is the installer stub, not the API. + expect(output).not.toContain('Downloading Electron binary'); + }); + + it('serves Node and Electron imports through runtime shims', async () => { + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [pluginNodeIntegration()], + server: { middlewareMode: true }, + }); + + try { + const result = await server.transformRequest('/renderer.js'); + expect(result?.code).toContain( + '/@id/__x00__electron-forge-node-integration:electron', + ); + expect(result?.code).toContain( + '/@id/__x00__electron-forge-node-integration:node:fs', + ); + expect(result?.code).not.toContain('__vite-browser-external'); + } finally { + await server.close(); + } + }); + + it('re-exports every Electron API in the shipped export list', async () => { + // `electronExportNames` in the plugin is written out by hand, and a name + // missing from it is a hard build failure rather than a degraded import: + // Rollup rejects `"X" is not exported by ":electron"` for a + // real-but-unlisted API exactly as it does for a name that never existed. + // `ServiceWorkerMain` was missing and is the regression this pins. + // + // This asserts against the `.d.ts` Electron ships rather than + // `Object.keys(require('electron'))`, even though the runtime is the better + // source of truth, because these specs run under plain Node -- where + // Electron's package resolves to its installer stub and `require('electron')` + // is the *executable path string*. That is the same reason the plugin needs a + // written-out list at all. `ServiceWorkerMain` is therefore added on top of + // the parsed names: the typings declare it only as a `type`, while Electron + // 39's main process exports it as a real constructor, so the typings alone + // would silently drop it again. + // These specs compile with `"module": "commonjs"` (tsconfig.test.json), so + // `import.meta.url` is a TS1343 error here and `__dirname` is the portable + // way to anchor a path. + // + // The typings are located by path rather than `require.resolve('electron')` + // because `electron` is a devDependency of the workspace ROOT, not of this + // package: resolving it as a bare specifier from here is a genuine + // `n/no-extraneous-require` error, and the rule keys on the specifier, so + // passing `paths` does not satisfy it. Adding the dependency to this package + // just for one spec would be worse. + const typingsPath = path.join( + __dirname, + '..', + '..', + '..', + '..', + '..', + 'node_modules', + 'electron', + 'electron.d.ts', + ); + const typings = await fs.promises.readFile(typingsPath, 'utf8'); + const namespace = typings.slice( + typings.indexOf('namespace CrossProcessExports'), + ); + const documented = new Set([ + ...[...namespace.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*:/g)].map( + (match) => match[1], + ), + ...[...namespace.matchAll(/\bclass\s+([A-Za-z_$][\w$]*)\s/g)].map( + (match) => match[1], + ), + 'ServiceWorkerMain', + ]); + // Guards against a silently-empty set turning this into a test that cannot + // fail: parsing nothing would make the generated module import nothing. + expect(documented.size).toBeGreaterThan(20); + + await fs.promises.writeFile( + path.join(root, 'every-api.js'), + [...documented] + .map( + (name, index) => `import { ${name} as api${index} } from 'electron';`, + ) + .join('\n') + + `\nwindow.apis = [${[...documented].map((_name, index) => `api${index}`).join(', ')}];\n`, + ); + + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [pluginNodeIntegration()], + build: { + minify: false, + write: false, + rollupOptions: { input: path.join(root, 'every-api.js') }, + }, + }), + ).resolves.toBeDefined(); + }); + + it('keeps user dependency and Rollup settings', async () => { + const userIgnore = (id: string) => id === 'custom-module'; + const config = await resolveConfig( + { + configFile: false, + plugins: [pluginNodeIntegration()], + optimizeDeps: { exclude: ['custom-dependency'] }, + build: { + commonjsOptions: { ignore: userIgnore }, + rollupOptions: { output: { entryFileNames: 'custom.js' } }, + }, + }, + 'build', + ); + const ignore = config.build.commonjsOptions.ignore; + + expect(config.optimizeDeps.exclude).toContain('custom-dependency'); + expect(config.optimizeDeps.exclude).toContain('node:fs'); + expect(ignore).toBeTypeOf('function'); + expect((ignore as (id: string) => boolean)('custom-module')).toBe(true); + expect((ignore as (id: string) => boolean)('node:fs')).toBe(true); + // The user's own output settings must survive untouched on every Vite. + expect(config.build.rollupOptions.output).toMatchObject({ + entryFileNames: 'custom.js', + }); + // `output.freeze` is Rollup-only. Vite 8 bundles Rolldown, which never emits + // `Object.freeze`, so the plugin deliberately omits the key there -- setting + // it would be a type error against Rolldown's `OutputOptions` and a no-op at + // runtime. Assert whichever behaviour the installed Vite calls for, so this + // spec keeps its teeth on Vite 6/7 instead of being loosened for both. + const output = config.build.rollupOptions.output as { freeze?: boolean }; + if ((vite as { rolldownVersion?: string }).rolldownVersion === undefined) { + expect(output.freeze).toBe(false); + } else { + expect(output).not.toHaveProperty('freeze'); + } + }); +}); diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..86ed99f6b0 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -25,6 +25,16 @@ export interface VitePluginRendererConfig { * Vite config file path. */ config: string; + /** + * Preserve Electron and Node.js imports for a renderer that has Node.js + * integration enabled. + * + * This does not change BrowserWindow preferences. The corresponding window + * must use `nodeIntegration: true` and `contextIsolation: false`. + * + * @defaultValue false + */ + nodeIntegration?: boolean; } export interface VitePluginConfig { diff --git a/packages/plugin/vite/src/config/vite.node-integration.config.ts b/packages/plugin/vite/src/config/vite.node-integration.config.ts new file mode 100644 index 0000000000..ab95651834 --- /dev/null +++ b/packages/plugin/vite/src/config/vite.node-integration.config.ts @@ -0,0 +1,239 @@ +import { builtinModules, createRequire } from 'node:module'; +import path from 'node:path'; + +import * as vite from 'vite'; + +import type { Plugin, UserConfig } from 'vite'; + +const electronModules = ['electron', 'electron/common', 'electron/renderer']; +const originalFsModules = ['original-fs', 'node:original-fs']; +const nodeIntegrationModules = new Set([ + ...electronModules, + ...originalFsModules, + ...builtinModules, + ...builtinModules + .filter((moduleName) => !moduleName.startsWith('node:')) + .map((moduleName) => `node:${moduleName}`), +]); +const virtualModulePrefix = '\0electron-forge-node-integration:'; +const identifierPattern = /^[$A-Z_][0-9A-Z_$]*$/i; +const nodeRequire = createRequire( + path.join(process.cwd(), '__electron_forge_vite.cjs'), +); + +// Electron's package cannot expose these names to Vite while it runs in Node: +// requiring it outside Electron returns the executable path instead. +// +// The list therefore has to be written out, and a name missing from it is a hard +// build failure -- Rollup reports `"X" is not exported by +// ":electron"`, exactly as it would for a name that does not exist at +// all, so a genuine API and a typo fail identically. +// +// Maintaining it: take the runtime value exports, NOT the names in +// `electron.d.ts`. The two disagree. `ServiceWorkerMain` is only a `type` inside +// the typings' `CrossProcessExports` namespace, yet in Electron 39.2.6 the main +// process really does export it as a constructor (`typeof === 'function'`), so +// deriving this list from the typings silently drops it. The runtime is the +// ground truth for a bundler shim: +// +// Object.keys(require('electron')) // in the main process, and again in a +// // nodeIntegration renderer +// +// The union of both processes is what belongs here. Names that resolve only in +// one process are still safe to list: the shim re-exports whatever the running +// process actually has, and a main-only API simply reads as `undefined` in a +// renderer -- which is what plain `require('electron')` does there too. +const electronExportNames = [ + 'app', + 'autoUpdater', + 'BaseWindow', + 'BrowserView', + 'BrowserWindow', + 'clipboard', + 'contentTracing', + 'contextBridge', + 'crashReporter', + 'deprecate', + 'desktopCapturer', + 'dialog', + 'globalShortcut', + 'ImageView', + 'inAppPurchase', + 'ipcMain', + 'IpcMainServiceWorker', + 'ipcRenderer', + 'Menu', + 'MenuItem', + 'MessageChannelMain', + 'MessagePortMain', + 'nativeImage', + 'nativeTheme', + 'net', + 'netLog', + 'Notification', + 'parentPort', + 'powerMonitor', + 'powerSaveBlocker', + 'process', + 'protocol', + 'pushNotifications', + 'safeStorage', + 'screen', + 'ServiceWorkerMain', + 'session', + 'ShareMenu', + 'sharedTexture', + 'shell', + 'systemPreferences', + 'TouchBar', + 'Tray', + 'utilityProcess', + 'View', + 'webContents', + 'WebContentsView', + 'webFrame', + 'webFrameMain', + 'webUtils', +]; + +function getExportNames(source: string) { + if (electronModules.includes(source)) return electronExportNames; + + const introspectionSource = originalFsModules.includes(source) + ? 'node:fs' + : source; + return Object.getOwnPropertyNames(nodeRequire(introspectionSource)); +} + +function createRuntimeShim(source: string) { + const exports = [...new Set(getExportNames(source))] + .filter( + (name) => + name !== 'default' && + name !== '__esModule' && + identifierPattern.test(name), + ) + .map((name, index) => ({ binding: `export_${index}`, name })); + const declarations = exports + .map( + ({ binding, name }) => + `const ${binding} = moduleValue[${JSON.stringify(name)}];`, + ) + .join('\n'); + const namedExports = exports + .map(({ binding, name }) => ` ${binding} as ${name},`) + .join('\n'); + + // `require` is called directly rather than through an alias. Rolldown (Vite 8+) + // only rewrites syntactically-direct `require(...)` calls into its + // external-module interop; assigning it first (`const runtimeRequire = require`) + // and calling the alias produces a bundle with no `require` at all, and every + // Electron export silently becomes `undefined`. Rollup accepted either form, so + // this reads as a cosmetic difference and is not one. + return ` +const moduleValue = require(${JSON.stringify(source)}); +const defaultExport = moduleValue?.default ?? moduleValue; +${declarations} +export { + defaultExport as default, +${namedExports} +}; +`; +} + +function configureNodeIntegration(config: UserConfig) { + config.optimizeDeps ??= {}; + config.optimizeDeps.exclude = [ + ...new Set([ + ...(config.optimizeDeps.exclude ?? []), + ...nodeIntegrationModules, + ]), + ]; + + config.build ??= {}; + config.build.commonjsOptions ??= {}; + const userIgnore = config.build.commonjsOptions.ignore; + config.build.commonjsOptions.ignore = + typeof userIgnore === 'function' + ? (id) => nodeIntegrationModules.has(id) || userIgnore(id) + : [...new Set([...(userIgnore ?? []), ...nodeIntegrationModules])]; + + config.build.rollupOptions ??= {}; + const { output } = config.build.rollupOptions; + // Rollup freezes the namespace object it builds for a CommonJS module, so the + // `require` shims below would hand back a frozen `electron` namespace and any + // consumer that assigns onto it (a common pattern in test setups) would throw + // in strict mode. `output.freeze: false` opts out of that. + // + // Only Rollup has the option: Vite 8 bundles Rolldown, which never emits + // `Object.freeze` at all, so the property is absent from its `OutputOptions` + // and setting it would be both a type error and a no-op. `freeze` is therefore + // written through a cast and only when the running Vite is Rollup-based. + if (rollupSupportsFreeze()) { + if (Array.isArray(output)) { + for (const outputConfig of output) applyFreeze(outputConfig); + } else { + const merged = { ...output }; + applyFreeze(merged); + config.build.rollupOptions.output = merged; + } + } +} + +function applyFreeze(outputConfig: object) { + const freezable = outputConfig as { freeze?: boolean }; + freezable.freeze ??= false; +} + +/** + * Whether the bundler behind `build.rollupOptions` understands `output.freeze`. + * True for Rollup (Vite 6 and 7), false for Rolldown (Vite 8+), which never + * emits `Object.freeze` and so does not need the opt-out. + * + * Vite only exports `rolldownVersion` from the Rolldown-based builds, so its + * presence is the direct signal; a version-number check would need updating + * every time Vite changes bundler. + */ +function rollupSupportsFreeze() { + return (vite as { rolldownVersion?: string }).rolldownVersion === undefined; +} + +export function pluginNodeIntegration(): Plugin { + return { + name: '@electron-forge/plugin-vite:node-integration', + enforce: 'pre', + config(config) { + configureNodeIntegration(config); + }, + resolveId(source, importer) { + // Requests coming from inside our own shim must stay external. The shim's + // body is `runtimeRequire("electron")`, which is meant to reach Electron's + // runtime `require` at execution time, so the bundler has to leave it as a + // `require` call rather than resolving it. + // + // Rollup left it alone by default. Rolldown (Vite 8+) does not, and both of + // the other outcomes are silent: + // - claim it here again, and the virtual module resolves to itself: + // `init_x = __esmMin(() => { moduleValue = (init_x(), ...) })`. The + // self-call is swallowed by `__esmMin`'s `fn = 0` guard, so instead of + // recursing it yields `undefined` for every Electron export. + // - decline it, and Rolldown resolves `electron` to the npm package -- + // which outside Electron is the *installer stub* -- and bundles + // `getElectronPath()` plus a "Downloading Electron binary..." branch + // into the renderer, with its own `fs`/`child_process` shimmed to + // `module.exports = {}` by `__vite-browser-external`. + // Marking it external is what keeps a real `require` in the output. + if (importer?.startsWith(virtualModulePrefix)) { + return { id: source, external: true }; + } + if (nodeIntegrationModules.has(source)) { + return `${virtualModulePrefix}${source}`; + } + }, + load(id) { + if (id.startsWith(virtualModulePrefix)) { + return createRuntimeShim(id.slice(virtualModulePrefix.length)); + } + }, + }; +} diff --git a/packages/plugin/vite/src/config/vite.renderer.config.ts b/packages/plugin/vite/src/config/vite.renderer.config.ts index 2a05cbc8a9..79dc52fd9a 100644 --- a/packages/plugin/vite/src/config/vite.renderer.config.ts +++ b/packages/plugin/vite/src/config/vite.renderer.config.ts @@ -1,6 +1,7 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; import { pluginExposeRenderer } from './vite.base.config'; +import { pluginNodeIntegration } from './vite.node-integration.config'; // https://vitejs.dev/config export function getConfig( @@ -18,7 +19,10 @@ export function getConfig( copyPublicDir: true, outDir: `.vite/renderer/${name}`, }, - plugins: [pluginExposeRenderer(name)], + plugins: [ + pluginExposeRenderer(name), + ...(forgeConfigSelf.nodeIntegration ? [pluginNodeIntegration()] : []), + ], resolve: { preserveSymlinks: true, },