Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/plugin/vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 22 additions & 0 deletions packages/plugin/vite/spec/ViteConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

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');

expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("electron"\)/);
expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:fs"\)/);
expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:path"\)/);
expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:os"\)/);
expect(output).not.toContain('__vite-browser-external');
});

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('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);
expect(config.build.rollupOptions.output).toMatchObject({
entryFileNames: 'custom.js',
freeze: false,
});
});
});
10 changes: 10 additions & 0 deletions packages/plugin/vite/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
162 changes: 162 additions & 0 deletions packages/plugin/vite/src/config/vite.node-integration.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { builtinModules, createRequire } from 'node:module';
import path from 'node:path';

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.
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',
'session',
'ShareMenu',
'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} = /*#__PURE__*/ (() => moduleValue[${JSON.stringify(name)}])();`,
)
.join('\n');
const namedExports = exports
.map(({ binding, name }) => ` ${binding} as ${name},`)
.join('\n');

return `
const runtimeRequire = require;
const moduleValue = runtimeRequire(${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;
if (Array.isArray(output)) {
for (const outputConfig of output) outputConfig.freeze ??= false;
} else {
config.build.rollupOptions.output = {
...output,
freeze: output?.freeze ?? false,
};
}
}

export function pluginNodeIntegration(): Plugin {
return {
name: '@electron-forge/plugin-vite:node-integration',
enforce: 'pre',
config(config) {
configureNodeIntegration(config);
},
resolveId(source) {
if (nodeIntegrationModules.has(source)) {
return `${virtualModulePrefix}${source}`;
}
},
load(id) {
if (id.startsWith(virtualModulePrefix)) {
return createRuntimeShim(id.slice(virtualModulePrefix.length));
}
},
};
}
6 changes: 5 additions & 1 deletion packages/plugin/vite/src/config/vite.renderer.config.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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,
},
Expand Down
Loading