Compare commits

..

70 Commits

Author SHA1 Message Date
alex8088
23d7bfc8ba release: v6.0.0 2026-10-15 00:01:19 +08:00
alex8088
2ca5e40433 release: v6.0.0-beta.8 2026-10-11 02:23:37 +08:00
alex8088
bffc6c95df perf(swcPlugin): skip redundant minification when using Oxc (#922) 2026-10-11 02:13:14 +08:00
alex8088
b0b33bdd5e fix(asset): use consistent file paths for asset cache lookups 2026-10-11 02:07:32 +08:00
alex8088
3ec79bbf65 perf: skip Babel loading in development and builds without bytecode 2026-10-11 02:01:18 +08:00
alex8088
19050aaec9 refactor: share and normalize build output resolution 2026-10-11 00:59:44 +08:00
alex8088
0655933704 refactor: inline bytecode build plugin name 2026-10-11 00:37:36 +08:00
alex8088
6fc103eb5b perf: short-circuit bytecode detection in builds 2026-10-10 23:00:29 +08:00
alex8088
6b0f85a2a7 chore: simplify bytecode compiler initialization 2026-10-10 02:03:25 +08:00
alex8088
fa199eb372 release: v6.0.0-beta.7 2026-10-08 02:30:56 +08:00
alex8088
b66840e2ec fix(bytecode): unable to obtain the bytecode build session in Node.js 24 (#928) 2026-10-08 02:30:44 +08:00
alex8088
1cbef3fb80 release: v6.0.0-beta.6 2026-10-07 13:41:25 +08:00
alex8088
36a0147a6d refactor!: invoke the bytecode compiler only once to compile all code 2026-10-07 13:36:10 +08:00
alex8088
4c4bdf5248 refactor: use pipe stdio for spawn Electron bytecode compliler and append stderr error 2026-10-01 22:44:18 +08:00
alex8088
395f0dae0c chore: improve jsdoc for bytecode options (#898) 2026-10-01 22:31:29 +08:00
alex8088
22b12bd102 release: v6.0.0-beta.5 2026-09-29 23:47:02 +08:00
alex8088
c104b2d15b fix(bytecode): set the regular expression's lastIndex to 0 to perform a global match for chunkAlias option 2026-09-29 23:45:48 +08:00
Beans
c047b58d5d
feat(bytecode): support RegExp for chunkAlias option (#885)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-29 23:25:21 +08:00
alex8088
55239df0ee fix(dev): improve Electron process signal handling 2026-09-29 23:23:03 +08:00
Iddo Gino
d8e7702dc5
fix(dev): forward termination signals to Electron child (#900)
Spawn the Electron binary directly in dev/preview mode but forward
SIGINT/SIGTERM/SIGHUP from the electron-vite parent to the child,
matching Electron's own CLI wrapper (node_modules/electron/cli.js).

Without this, Ctrl-C sends SIGINT to the foreground process group once:
the Node parent exits immediately (no handler) and the Electron child
gets only a single SIGINT. On macOS, an Electron app whose before-quit
handler calls preventDefault() and later calls app.quit() can stall in
AppKit's event loop in that path. Under a direct 'electron .' launch
the CLI shim forwards SIGINT as well, so Electron gets it twice and
exits cleanly. Matching that behavior here fixes the hang.

Fixes #899
2026-09-29 11:55:31 +08:00
Praveens-Pepper-AI
816c6dac61
fix(dev): exit non-zero when Electron is killed by a signal (#918)
`ChildProcess`'s `close` event fires as `(code, signal)` and `code` is null when the
child was terminated by a signal. `process.exit` ignores its second argument and
coerces null to 0, so passing the listener directly reports every signal death as a
successful exit.

Handle the two arguments explicitly so a signal death exits non-zero.
2026-09-29 10:33:35 +08:00
alex8088
98d30e57e3 release: v6.0.0-beta.4 2026-09-28 22:59:51 +08:00
alex8088
a135566fa5 fix(esmShim)!: use this.parse to find the last static import (#906) 2026-09-28 22:53:13 +08:00
alex8088
08acca3585 release: v6.0.0-beta.3 2026-09-27 18:40:44 +08:00
alex8088
68b80f0ad5 fix(deps)!: downgraded @babel/* to v7 due to Node requirements 2026-09-27 18:39:07 +08:00
alex8088
bc1ef5b023 release: v6.0.0-beta.2 2026-09-27 17:59:44 +08:00
alex8088
53bc35b240 fix(deps)!: update @babel/* to v8 2026-09-27 17:51:29 +08:00
alex8088
a7f76d8d44 fix(deps)!: update magic-string to v1 2026-09-25 01:26:57 +08:00
alex8088
2937574aa4 chore(deps): update lint-staged to v17 2026-09-25 01:12:37 +08:00
alex8088
3680d60b21 chore(deps): update all non-major dependencies 2026-09-25 01:08:22 +08:00
alex8088
8df70754ca perf: build compatibility target for Electron 42, 43, 44 2026-09-25 00:44:14 +08:00
alex8088
e691efbd82 refactor: compile bytecode using the Electron main process or renderer process rather then via the ELECTRON_RUN_AS_NODE 2026-09-25 00:39:47 +08:00
alex8088
ac2cdd1a21 fix: clone config without plugins #902 2026-09-24 19:30:27 +08:00
alex8088
811a9f2a75 refactor: use rolldown instead of esbuild to bundle config file 2026-09-24 19:18:50 +08:00
edenbuilds
608461c04c
fix(swcPlugin): also disable oxc on Vite 8 (#920)
Vite 8 defaults transform to Oxc; esbuild:false alone is ignored and
warns. SWC should disable both. Fixes #916.

Co-authored-by: edenbuilds <279970382+edenbuilds@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:10:42 +08:00
alex8088
31965d2972 perf: setup rollupOption compatibility via config factory 2026-04-18 01:03:04 +08:00
alex8088
4bede21c81 fix: remove useless preset config 2026-04-18 00:56:23 +08:00
alex8088
31bfc4886b release: v6.0.0-beta.1 2026-04-12 16:36:22 +08:00
alex8088
371b650d71 fix: chunk filename is wrong in ES mode 2026-04-12 16:24:56 +08:00
alex8088
5333d5216a fix!: compatible with rollupOptions and rolldownOptions 2026-04-12 16:18:24 +08:00
alex8088
2765eea74d refactor!: simplify resolve config and isolate user config for sub-builds 2026-04-12 02:58:42 +08:00
alex8088
d3095d24ab release: v6.0.0-beta.0 2026-04-09 23:26:25 +08:00
alex8088
a041f42e0b fix(asset): isolate asset caches per environment 2026-04-09 22:30:27 +08:00
alex8088
2b7c4c33bc fix: use the environment api in plugins 2026-04-09 21:51:42 +08:00
alex8088
517d703cbf fix: skip TTY terminal manipulations in CI environments 2026-04-09 00:50:27 +08:00
alex8088
fa84b6d6c3 refactor: ?modulePath sub-build output strategy 2026-04-09 00:38:21 +08:00
alex8088
2f1f389776 fix(deps)!: update vite to v8 (#894) 2026-04-08 02:04:25 +08:00
alex8088
a9a496dc1a fix: Typescript compiler option 'outDir' must be located inside Rollup 'dir' option 2026-03-29 15:22:06 +08:00
alex8088
201eb213ac chore(deps): update cac to v7 2026-03-29 15:20:31 +08:00
alex8088
3d2a979fb1 chore(deps): update globals to v17 2026-03-29 15:05:56 +08:00
alex8088
e58369f975 chore(deps): update eslint and @eslint/js to v10 2026-03-29 15:04:39 +08:00
alex8088
e9289f32f7 chore(deps): update all non-major dependencies 2026-03-29 14:55:56 +08:00
alex8088
78a117d5be perf: build compatibility target for Electron 40, 41 2026-03-21 14:36:18 +08:00
Michal Fudala
5a8bbe3d0e
fix(isolateEntries): handle non-TTY stdout in build reporter (#887)
The `clearLine` and `writeLine` helpers use TTY-only methods (`clearLine`, `cursorTo`,
`moveCursor`) without checking `process.stdout.isTTY`, causing builds to crash in
non-interactive environments like CI or piped output.

See error I got in my CI environment:

```
vite v7.3.1 building ssr environment for production...
✗ Build failed in 7ms
✓ 0 modules transformed.
✗ Build failed in 9ms
error during build:
[vite:isolate-entries] Could not load virtual:isolate-entries:
  [vite:transform-reporter] process.stdout.clearLine is not a function
```
2026-02-16 22:25:35 +08:00
alex8088
91368b6655 release: v5.0.0 2025-12-07 22:26:56 +08:00
alex8088
b1fd596afe docs: update 2025-11-09 23:31:08 +08:00
alex8088
438e9e7672 chore: fix jsdoc 2025-11-09 23:03:06 +08:00
alex8088
9eba4df577 release: v5.0.0-beta.3 2025-11-01 17:11:12 +08:00
alex8088
465690ab0d refactor(config)!: remove function resolution for nested config fields 2025-10-31 23:28:40 +08:00
alex8088
6aae37833e perf(buildReport): exclude node_modules from watch list 2025-10-31 21:59:15 +08:00
alex8088
9152dfc943 fix(modulePath): rewrite the build input instead of merging 2025-10-31 01:16:10 +08:00
alex8088
0276407b5b refactor: deprecated externalizeDepsPlugin and bytecodePlugin 2025-10-31 01:16:02 +08:00
alex8088
fe7e631f47 refactor(config): move the isolateEntries options to the build option 2025-10-31 01:09:44 +08:00
alex8088
08be346407 feat(config): add build.externalizeDeps and build.bytecode config options to replace externalizeDepsPlugin and bytecodePlugin 2025-10-31 01:03:34 +08:00
alex8088
3e9ded666c release: v5.0.0-beta.2 2025-10-29 23:42:04 +08:00
alex8088
1bba6766e8 perf(isolateEntries): optimize entries transformation 2025-10-29 23:35:38 +08:00
alex8088
4edffe3b9a perf(isolateEntries): transform log 2025-10-29 23:25:28 +08:00
alex8088
cfd9812a91 feat: reporter plugin for isolated builds 2025-10-29 22:32:35 +08:00
alex8088
7c7f31b2a3 fix: avoid duplicate chunk emission 2025-10-29 21:22:12 +08:00
alex8088
ae57b2489a fix(asset): normalize imported public asset chunk path 2025-10-29 21:12:13 +08:00
25 changed files with 2613 additions and 2118 deletions

View File

@ -1,32 +1,140 @@
### v5.0.0-beta.1 (_2025-10-29_)
### v6.0.0 (_2026-10-15_)
- feat(bytecode): support RegExp for chunkAlias option ([#885](https://github.com/alex8088/electron-vite/issues/885))
- refactor: share and normalize build output resolution
- refactor: inline bytecode build plugin name
- refactor!: invoke the bytecode compiler only once to compile all code
- refactor: use pipe stdio for spawn Electron bytecode compliler and append stderr error
- refactor: use rolldown instead of esbuild to bundle config file
- refactor: compile bytecode using the Electron main process or renderer process rather then via the `ELECTRON_RUN_AS_NODE`
- refactor!: simplify resolve config and isolate user config for sub-builds
- refactor: `?modulePath` sub-build output strategy
- perf(swcPlugin): skip redundant minification when using Oxc ([#922](https://github.com/alex8088/electron-vite/issues/922))
- perf: skip Babel loading in development and builds without bytecode
- perf: short-circuit bytecode detection in builds
- perf: setup rollupOption compatibility via config factory
- perf: build compatibility target for Electron 42, 43, 44
- fix(asset): use consistent file paths for asset cache lookups
- fix(bytecode): unable to obtain the bytecode build session in Node.js 24 ([#928](https://github.com/alex8088/electron-vite/issues/928))
- fix(dev): exit non-zero when Electron is killed by a signal ([#918](https://github.com/alex8088/electron-vite/issues/918))
- fix(dev): forward termination signals to Electron child ([#900](https://github.com/alex8088/electron-vite/issues/900))
- fix(dev): improve Electron process signal handling
- fix(bytecode): set the regular expression's `lastIndex` to 0 to perform a global match for chunkAlias option
- fix(esmShim)!: use `this.parse` to find the last static import ([#906](https://github.com/alex8088/electron-vite/issues/906))
- fix(deps)!: downgraded @babel/* to v7 due to Node requirements
- fix: remove useless preset config
- fix(swcPlugin): also disable oxc on Vite 8 ([#920](https://github.com/alex8088/electron-vite/issues/920))
- fix: clone config without plugins [#902](https://github.com/alex8088/electron-vite/issues/902)
- fix(deps)!: update magic-string to v1
- fix(deps)!: update @babel/* to v8
- fix!: compatible with rollupOptions and rolldownOptions
- fix: chunk filename is wrong in ES mode
- fix(asset): isolate asset caches per environment
- fix: use the environment api in plugins
- fix: skip TTY terminal manipulations in CI environments
- fix(deps)!: update vite to v8 ([#894](https://github.com/alex8088/electron-vite/issues/894))
- fix: Typescript compiler option 'outDir' must be located inside Rollup 'dir' option
- fix(isolateEntries): handle non-TTY stdout in build reporter (#887)
- chore: simplify bytecode compiler initialization
- chore: improve jsdoc for bytecode options ([#898](https://github.com/alex8088/electron-vite/issues/898))
- chore(deps): update all non-major dependencies
- chore(deps): update lint-staged to v17
- chore(deps): update cac to v7
- chore(deps): update globals to v17
- chore(deps): update eslint and @eslint/js to v10
- chore(deps): update all non-major dependencies
### v6.0.0-beta.8 (_2026-10-11_)
See [v6.0.0-beta.8 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.8/CHANGELOG.md)
### v6.0.0-beta.7 (_2026-10-08_)
See [v6.0.0-beta.7 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.7/CHANGELOG.md)
### v6.0.0-beta.6 (_2026-10-07_)
See [v6.0.0-beta.6 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.6/CHANGELOG.md)
### v6.0.0-beta.5 (_2026-09-29_)
See [v6.0.0-beta.5 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.5/CHANGELOG.md)
### v6.0.0-beta.4 (_2026-09-28_)
See [v6.0.0-beta.4 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.4/CHANGELOG.md)
### v6.0.0-beta.3 (_2026-09-27_)
See [v6.0.0-beta.3 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.3/CHANGELOG.md)
### v6.0.0-beta.2 (_2026-09-27_)
See [v6.0.0-beta.2 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.2/CHANGELOG.md)
### v6.0.0-beta.1 (_2026-04-12_)
See [v6.0.0-beta.1 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.1/CHANGELOG.md)
### v6.0.0-beta.0 (_2026-04-09_)
See [v6.0.0-beta.0 changelog](https://github.com/alex8088/electron-vite/blob/v6.0.0-beta.0/CHANGELOG.md)
### v5.0.0 (_2025-12-07_)
- feat(config): add `build.externalizeDeps` and `build.bytecode` config options to replace `externalizeDepsPlugin` and `bytecodePlugin`
- feat: reporter plugin for isolated builds
- feat: enhanced string protection
- feat: add `isolatedEntries` option for `preload` and `renderer` to build entries as standalone bundles [#154](https://github.com/alex8088/electron-vite/issues/154)
- refactor(config): move the `isolateEntries` options to the `build` option
- refactor: deprecated `externalizeDepsPlugin` and `bytecodePlugin`
- refactor(config)!: remove function resolution for nested config fields
- refactor(asset): remove redundant path normalization
- refactor: split electron plugin into preset and validator plugins
- refactor(config)!: restructure Electron Vite config interfaces
- refactor(build): simplify build logic
- refactor: replace `JSON.parse/stringify` with manual deep clone
- perf: build compatibility target for Electron 39
- fix(modulePath): prevent duplicate asset emission
- fix(modulePath): support watch mode
- chore: improve logging message clarity and consistency
### v5.0.0-beta.0 (_2025-10-19_)
- refactor(bytecodePlugin): improved bytecode bundle generation and made a new string protection plugin
- refactor(modulePath): better support for tree-shaking and code-splitting
- refactor: remove Electron 18, 19, 20, 21 build compatilibity target
- perf(buildReport): exclude node_modules from watch list
- perf(isolateEntries): transform log
- perf(isolateEntries): optimize entries transformation
- perf: build compatibility target for Electron 39
- perf(plugin): more efficient module filtering via regular expressions
- perf(plugin): no need to cache `sourcemap` option
- perf(plugin): lazily initialize `MagicString` and remove the redundant pre-check
- perf(bytecodePlugin): better way to count bytecode chunks
- perf(plugin): enhance path resolution using `import.meta.dirname` for ES modules
- fix(modulePath): rewrite the build input instead of merging
- fix(asset): normalize imported public asset chunk path
- fix: avoid duplicate chunk emission
- fix(modulePath): prevent duplicate asset emission
- fix(modulePath): support watch mode
- chore: fix jsdoc
- chore: improve logging message clarity and consistency
- chore(deps): update all non-major dependencies
- chore: update eslint config
- chore: replace `tseslint.config` with `defineConfig`
- chore: remove redundant external id
- chore: rename the file `esm` to `esmShim`
- docs: update
### v5.0.0-beta.3 (_2025-11-01_)
See [v5.0.0-beta.3 changelog](https://github.com/alex8088/electron-vite/blob/v5.0.0-beta.3/CHANGELOG.md)
### v5.0.0-beta.2 (_2025-10-30_)
See [v5.0.0-beta.2 changelog](https://github.com/alex8088/electron-vite/blob/v5.0.0-beta.2/CHANGELOG.md)
### v5.0.0-beta.1 (_2025-10-29_)
See [v5.0.0-beta.1 changelog](https://github.com/alex8088/electron-vite/blob/v5.0.0-beta.1/CHANGELOG.md)
### v5.0.0-beta.0 (_2025-10-19_)
See [v5.0.0-beta.0 changelog](https://github.com/alex8088/electron-vite/blob/v5.0.0-beta.0/CHANGELOG.md)
### v4.0.1 (_2025-09-21_)

View File

@ -28,13 +28,13 @@
## Features
- ⚡️ [Vite](https://vitejs.dev) powered and use the same way.
- 🛠 Pre-configured for Electron, don't worry about configuration.
- 💡 Optimize asset handling (Node.js addons, WebAssembly, Worker Thread, etc).
- 🚀 Fast HMR for renderer processes.
- 🔥 Hot reloading for main process and preload scripts.
- 🔌 Easy to debug in IDEs like VSCode or WebStorm.
- 🔒 Compile to v8 bytecode to protect source code.
- 🏷️ Support for TypeScript decorators.
- 🛠 Pre-configure with sensible defaults optimized for Electron.
- 💡 Optimize asset handling for Electron main process.
- 🚀 Fast HMR & hot reloading.
- 🔥 Isolated build for multi-entry application development.
- ✨ Simplify multi-threading development.
- 🔒 Compile code to v8 bytecode to protect source code.
- 🔌 Easy to debug in IDEs such as VSCode or WebStorm.
- 📦 Out-of-the-box support for TypeScript, Vue, React, Svelte, SolidJS and more.
## Usage
@ -83,7 +83,7 @@ export default {
Clone the [electron-vite-boilerplate](https://github.com/alex8088/electron-vite-boilerplate) or use the [create-electron](https://github.com/alex8088/quick-start/tree/master/packages/create-electron) tool to scaffold your project.
```bash
npm create @quick-start/electron
npm create @quick-start/electron@latest
```
Currently supported template presets include:

15
bin/bytecode-compiler.cjs Normal file
View File

@ -0,0 +1,15 @@
const vm = require('node:vm')
const v8 = require('node:v8')
v8.setFlagsFromString('--no-lazy')
v8.setFlagsFromString('--no-flush-bytecode')
const params = ['exports', 'require', 'module', '__filename', '__dirname']
module.exports = function compile(code) {
if (typeof code !== 'string') throw new TypeError('Compilation source must be a string')
const { cachedData } = vm.compileFunction(code, params, { produceCachedData: true })
if (!Buffer.isBuffer(cachedData)) throw new Error('V8 did not produce cached data')
// Explicit wire format works across both Node IPC and Electron structured clone.
return { sourceLength: code.length, cachedData: cachedData.toString('base64') }
}

View File

@ -0,0 +1,109 @@
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('node:path')
const RpcPeer = require('./rpc-peer.cjs')
const compile = require('./bytecode-compiler.cjs')
if (process.platform === 'darwin') {
app.dock.hide()
}
const channel = 'electron-vite:bytecode:rpc'
const readyChannel = 'electron-vite:bytecode:ready'
let win
let rendererRPC
let rendererStarting
let stopping = false
function fail(error) {
console.error(error)
parentRPC.close(error)
rendererRPC?.close(error)
app.exit(1)
}
const parentRPC = new RpcPeer(
message =>
new Promise((resolve, reject) => {
if (!process.connected || !process.send) return reject(new Error('Parent IPC is disconnected'))
process.send(message, error => (error ? reject(error) : resolve()))
})
)
function createRenderer() {
return new Promise((resolve, reject) => {
let ready = false
const timer = setTimeout(() => finish(new Error('Compiler preload readiness timed out')), 30000)
function finish(error) {
clearTimeout(timer)
ipcMain.removeListener(readyChannel, onReady)
if (error) reject(error)
else {
ready = true
resolve()
}
}
function onReady(event) {
if (event.sender === win.webContents) finish()
}
function onFailure(error) {
if (stopping) return
if (ready) fail(error)
else finish(error)
}
ipcMain.on(readyChannel, onReady)
win = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(__dirname, 'electron-bytecode-preload.cjs'),
sandbox: false,
contextIsolation: true
}
})
rendererRPC = new RpcPeer(message => {
if (win.isDestroyed()) throw new Error('Compiler window is closed')
win.webContents.send(channel, message)
})
ipcMain.on(channel, (event, message) => {
if (!win.isDestroyed() && event.sender === win.webContents) void rendererRPC.receive(message)
})
win.webContents.on('preload-error', (_event, _path, error) => onFailure(error))
win.webContents.on('render-process-gone', (_event, details) => {
onFailure(new Error(`Compiler renderer exited: ${details.reason}`))
})
win.on('closed', () => onFailure(new Error('Compiler window closed unexpectedly')))
win.loadURL('data:text/html,<!doctype html><html></html>').catch(onFailure)
})
}
const initialized = app.whenReady().then(() => true)
parentRPC.register('ready', () => initialized)
parentRPC.register('compile', async (target, code) => {
await initialized
if (target === 'main') return compile(code)
if (target === 'preload') {
rendererStarting ||= createRenderer()
await rendererStarting
return rendererRPC.request('compile', code)
}
throw new Error(`Invalid bytecode target: ${target}`)
})
initialized.catch(fail)
process.on('message', message => {
if (message?.type === 'shutdown') {
stopping = true
app.quit()
} else {
void parentRPC.receive(message)
}
})
process.on('disconnect', () => {
stopping = true
app.quit()
})
app.on('before-quit', () => {
stopping = true
parentRPC.close()
rendererRPC?.close()
})
if (!process.send) fail(new Error('Bytecode compiler must be launched with an IPC channel'))

View File

@ -0,0 +1,10 @@
const { ipcRenderer } = require('electron')
const RpcPeer = require('./rpc-peer.cjs')
const compile = require('./bytecode-compiler.cjs')
const channel = 'electron-vite:bytecode:rpc'
const rpc = new RpcPeer(message => ipcRenderer.send(channel, message))
rpc.register('ready', () => true)
rpc.register('compile', compile)
ipcRenderer.on(channel, (_event, message) => void rpc.receive(message))
ipcRenderer.send('electron-vite:bytecode:ready')

89
bin/rpc-peer.cjs Normal file
View File

@ -0,0 +1,89 @@
// Transport-independent peer. Messages must be JSON serializable.
class RpcPeer {
constructor(send, timeout = 30000) {
this.send = send
this.timeout = timeout
this.methods = new Map()
this.pending = new Map()
this.nextId = 0
this.closed = false
}
register(cmd, method) {
this.methods.set(cmd, method)
return this
}
request(cmd, ...args) {
if (this.closed) return Promise.reject(new Error('RPC connection is closed'))
const invocationId = this.nextId++
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(invocationId)
reject(new Error(`RPC request timed out: ${cmd}`))
}, this.timeout)
this.pending.set(invocationId, { resolve, reject, timer })
Promise.resolve()
.then(() => this.send({ type: 'request', invocationId, cmd, args }))
.catch(error => {
const pending = this.pending.get(invocationId)
if (!pending) return
clearTimeout(timer)
this.pending.delete(invocationId)
reject(error)
})
})
}
async receive(message) {
if (this.closed || !message || !Number.isSafeInteger(message.invocationId)) return
const { type, invocationId } = message
if (type === 'response') {
const pending = this.pending.get(invocationId)
if (!pending) return
this.pending.delete(invocationId)
clearTimeout(pending.timer)
if (message.error) {
const error = new Error(message.error.message)
error.name = message.error.name || 'Error'
if (message.error.stack) error.stack = message.error.stack
pending.reject(error)
} else {
pending.resolve(message.result)
}
return
}
if (type !== 'request' || typeof message.cmd !== 'string' || !Array.isArray(message.args)) return
const response = { type: 'response', invocationId }
try {
const method = this.methods.get(message.cmd)
if (!method) throw new Error(`Invalid method: ${message.cmd}`)
response.result = await method(...message.args)
} catch (error) {
response.error = {
name: error instanceof Error ? error.name : 'Error',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
}
}
if (!this.closed) {
try {
await this.send(response)
} catch (error) {
this.close(error)
}
}
}
close(error = new Error('RPC connection is closed')) {
this.closed = true
for (const pending of this.pending.values()) {
clearTimeout(pending.timer)
pending.reject(error)
}
this.pending.clear()
this.methods.clear()
}
}
module.exports = RpcPeer

View File

@ -1,6 +1,6 @@
{
"name": "electron-vite",
"version": "5.0.0-beta.1",
"version": "6.0.0",
"description": "Electron build tooling based on Vite",
"type": "module",
"main": "./dist/index.js",
@ -61,7 +61,7 @@
},
"peerDependencies": {
"@swc/core": "^1.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@swc/core": {
@ -69,40 +69,39 @@
}
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@eslint/js": "^10.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.1.4",
"@swc/core": "^1.13.5",
"@rollup/plugin-typescript": "^12.3.0",
"@swc/core": "^1.16.2",
"@types/babel__core": "^7.20.5",
"@types/node": "^22.18.11",
"eslint": "^9.37.0",
"@types/node": "^22.20.4",
"eslint": "^10.11.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.4.0",
"lint-staged": "^16.2.4",
"prettier": "^3.6.2",
"rollup": "^4.52.4",
"rollup-plugin-dts": "^6.2.3",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.12.0",
"lint-staged": "^17.5.1",
"prettier": "^3.9.9",
"rollup": "^4.63.5",
"rollup-plugin-dts": "^6.5.1",
"rollup-plugin-rm": "^1.0.2",
"simple-git-hooks": "^2.13.1",
"simple-git-hooks": "^2.14.0",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vite": "^7.1.10"
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1"
},
"dependencies": {
"@babel/core": "^7.28.4",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"cac": "^6.7.14",
"esbuild": "^0.25.11",
"magic-string": "^0.30.19",
"picocolors": "^1.1.1"
"@babel/core": "^7.29.7",
"@babel/plugin-transform-arrow-functions": "^7.29.7",
"cac": "^7.0.0",
"magic-string": "^1.4.2",
"picocolors": "^1.1.1",
"rolldown": "~1.2.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"simple-git-hooks"
]
}

2433
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ export default defineConfig([
plugins: [
rm('dist', 'buildStart'),
json(),
ts({ compilerOptions: { rootDir: 'src', declaration: true, declarationDir: 'dist/types' } }),
ts({ compilerOptions: { rootDir: 'src', outDir: 'dist', declaration: true, declarationDir: 'dist/types' } }),
resolve()
],
treeshake: {
@ -35,6 +35,7 @@ export default defineConfig([
},
{
input: 'dist/types/index.d.ts',
external: ['vite'],
output: [{ file: pkg.types, format: 'es' }],
plugins: [dts(), rm('dist/types', 'buildEnd')]
}

View File

@ -1,28 +1,56 @@
import { build as viteBuild } from 'vite'
import { type InlineConfig, resolveConfig } from './config'
import { build as viteBuild, mergeConfig } from 'vite'
import { type InlineConfig, type MainViteConfig, type PreloadViteConfig, resolveConfig } from './config'
import { BytecodeCompiler } from './bytecodeCompiler'
import { asyncFlatten } from './utils'
async function usesBytecode(config?: MainViteConfig | PreloadViteConfig): Promise<boolean> {
if (!config) return false
if (config.build?.bytecode) return true
const plugins = await asyncFlatten(config.plugins || [])
return plugins.some(plugin => plugin && typeof plugin === 'object' && plugin.name === 'vite:bytecode')
}
/**
* Bundles the electron app for production.
*/
export async function build(inlineConfig: InlineConfig = {}): Promise<void> {
process.env.NODE_ENV_ELECTRON_VITE = 'production'
const config = await resolveConfig(inlineConfig, 'build', 'production')
const bytecodeCompiler = new BytecodeCompiler()
const config = await resolveConfig(
mergeConfig(inlineConfig, {
plugins: [{ name: 'vite:electron-bytecode-build', api: { compiler: bytecodeCompiler } }]
}),
'build',
'production'
)
if (!config.config) {
return
}
// Build targets in order: main -> preload -> renderer
const buildTargets = ['main', 'preload', 'renderer'] as const
const hasBytecode = (await usesBytecode(config.config.main)) || (await usesBytecode(config.config.preload))
for (const target of buildTargets) {
const viteConfig = config.config[target]
if (viteConfig) {
// Disable watch mode in production builds
if (viteConfig.build?.watch) {
viteConfig.build.watch = null
const buildTargets = async (): Promise<void> => {
// Build targets in order: main -> preload -> renderer
for (const target of ['main', 'preload', 'renderer'] as const) {
const viteConfig = config.config?.[target]
if (viteConfig) {
// Disable watch mode in production builds
if (viteConfig.build?.watch) {
viteConfig.build.watch = null
}
await viteBuild(viteConfig)
}
await viteBuild(viteConfig)
}
}
if (!hasBytecode) return buildTargets()
try {
await bytecodeCompiler.start()
await buildTargets()
} finally {
await bytecodeCompiler.stop()
}
}

188
src/bytecodeCompiler.ts Normal file
View File

@ -0,0 +1,188 @@
import { type ChildProcess, spawn } from 'node:child_process'
import path from 'node:path'
import { createRequire } from 'node:module'
import { getElectronPath } from './electron'
const require = createRequire(import.meta.url)
const RPC_TIMEOUT = 30_000
const SHUTDOWN_TIMEOUT = 3_000
export type BytecodeCompileResult = {
sourceLength: number
/** Base64-encoded V8 cachedData; decode with Buffer.from(cachedData, 'base64'). */
cachedData: string
}
export type BytecodeTarget = 'main' | 'preload'
type CompilerResponse = {
type: 'response'
invocationId: number
result?: unknown
error?: { name: string; message: string; stack?: string }
}
type PendingRequest = {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
export class BytecodeCompiler {
private ps?: ChildProcess
private rpcMap = new Map<number, PendingRequest>()
private invocationId = 0
private starting?: Promise<void>
private stopping?: Promise<void>
private ready = false
private stderr = ''
start(): Promise<void> {
if (this.stopping) return Promise.reject(new Error('BytecodeCompiler is stopping'))
if (this.starting) return this.starting
if (this.ready) return Promise.resolve()
if (this.ps) return Promise.reject(new Error('Previous Electron process has not closed; await stop() first'))
this.starting = this.initialize().finally(() => {
this.starting = undefined
})
return this.starting
}
private async initialize(): Promise<void> {
const env: NodeJS.ProcessEnv = { ...process.env, ELECTRON_VITE_RPC: 'true' }
delete env.ELECTRON_RUN_AS_NODE
this.stderr = ''
const compilerPath = path.join(
path.dirname(require.resolve('electron-vite/package.json')),
'bin',
'electron-bytecode-main.cjs'
)
const ps = spawn(getElectronPath(), [compilerPath], {
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
windowsHide: true,
env
})
this.ps = ps
if (ps.stderr) {
ps.stderr.setEncoding('utf8')
ps.stderr.on('data', (chunk: string) => {
if (this.ps === ps) this.stderr = (this.stderr + chunk).slice(-64 * 1024)
})
}
ps.on('message', (message: CompilerResponse) => {
if (this.ps !== ps || !message || message.type !== 'response') return
const pending = this.rpcMap.get(message.invocationId)
if (!pending) return
clearTimeout(pending.timer)
this.rpcMap.delete(message.invocationId)
if (message.error) {
const error = new Error(message.error.message)
error.name = message.error.name
if (message.error.stack) error.stack = message.error.stack
pending.reject(error)
} else pending.resolve(message.result)
})
ps.on('error', error => {
if (this.ps === ps) this.rejectPending(error)
})
ps.on('disconnect', () => {
if (this.ps === ps) this.rejectPending(new Error('Electron IPC disconnected'))
})
ps.on('exit', (code, signal) => {
if (this.ps === ps) this.rejectPending(new Error('Electron exited (code ' + code + ', signal ' + signal + ')'))
})
ps.on('close', () => {
if (this.ps === ps) {
this.rejectPending(new Error('Electron process closed'))
this.ps = undefined
}
})
try {
await this.sendRequest('ready', [])
if (!ps.connected || this.stopping) throw new Error('Electron stopped during initialization')
this.ready = true
} catch (error) {
await this.stop()
throw error
}
}
compile(target: BytecodeTarget, code: string): Promise<BytecodeCompileResult> {
return this.request<BytecodeCompileResult>('compile', target, code)
}
async request<T = unknown>(cmd: string, ...args: unknown[]): Promise<T> {
if (!this.ready || this.stopping) throw new Error('BytecodeCompiler is not ready; await start() first')
return this.sendRequest(cmd, args) as Promise<T>
}
private sendRequest(cmd: string, args: unknown[]): Promise<unknown> {
const ps = this.ps
if (!ps?.connected) return Promise.reject(new Error('Electron IPC is not connected'))
const invocationId = this.invocationId++
return new Promise((resolve, reject) => {
const fail = (error: Error): void => {
const pending = this.rpcMap.get(invocationId)
if (!pending) return
clearTimeout(pending.timer)
this.rpcMap.delete(invocationId)
reject(this.withStderr(error))
}
const timer = setTimeout(() => fail(new Error('Electron RPC timed out: ' + cmd)), RPC_TIMEOUT)
this.rpcMap.set(invocationId, { resolve, reject, timer })
try {
ps.send({ type: 'request', invocationId, cmd, args }, error => {
if (error) fail(error)
})
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)))
}
})
}
private rejectPending(error: Error): void {
this.ready = false
if (this.rpcMap.size) this.withStderr(error)
for (const pending of this.rpcMap.values()) {
clearTimeout(pending.timer)
pending.reject(error)
}
this.rpcMap.clear()
}
private withStderr(error: Error): Error {
const stderr = this.stderr.trim()
if (stderr) {
const stack = error.stack
error.message += '\n' + stderr
if (stack) error.stack = stack + '\n' + stderr
}
return error
}
stop(): Promise<void> {
if (this.stopping) return this.stopping
this.rejectPending(new Error('BytecodeCompiler stopped'))
const ps = this.ps
if (!ps) return Promise.resolve()
this.stopping = new Promise<void>(resolve => {
const timer = setTimeout(() => ps.kill('SIGKILL'), SHUTDOWN_TIMEOUT)
ps.once('close', () => {
clearTimeout(timer)
resolve()
})
if (ps.connected) {
try {
ps.send({ type: 'shutdown' }, error => {
if (error) ps.kill()
})
} catch {
ps.kill()
}
} else ps.kill()
}).finally(() => {
this.stopping = undefined
})
return this.stopping
}
}

View File

@ -1,18 +1,16 @@
import path from 'node:path'
import fs from 'node:fs'
import { pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
import colors from 'picocolors'
import {
type UserConfig as ViteConfig,
type ConfigEnv,
type PluginOption,
type Plugin,
type BuildEnvironmentOptions as ViteBuildOptions,
type LogLevel,
createLogger,
mergeConfig,
normalizePath
} from 'vite'
import { build } from 'esbuild'
import {
electronMainConfigPresetPlugin,
@ -28,11 +26,14 @@ import importMetaPlugin from './plugins/importMeta'
import esmShimPlugin from './plugins/esmShim'
import modulePathPlugin from './plugins/modulePath'
import isolateEntriesPlugin from './plugins/isolateEntries'
import { isObject, isFilePathESM, deepClone } from './utils'
import { type ExternalOptions, externalizeDepsPlugin } from './plugins/externalizeDeps'
import { type BytecodeOptions, bytecodePlugin } from './plugins/bytecode'
import { isObject, isFilePathESM, cloneConfig, asyncFlatten } from './utils'
import { findConfigFile, bundleConfigFile, loadConfigFormBundledFile } from './load'
export { defineConfig as defineViteConfig } from 'vite'
interface IsolatedEntriesOption {
interface IsolatedEntriesMixin {
/**
* Build each entry point as an isolated bundle without code splitting.
*
@ -40,17 +41,56 @@ interface IsolatedEntriesOption {
* preventing automatic code splitting across entries and ensuring each
* output file is fully standalone.
*
* **Important**: When using `isolatedEntries` in `preload` config, you
* should also disable `build.externalizeDeps` to ensure third-party dependencies
* from `node_modules` are bundled together, which is required for Electron
* sandbox support.
*
* @experimental
* @default false
*/
isolatedEntries?: boolean
}
export interface MainViteConfig extends ViteConfig {}
interface ExternalizeDepsMixin {
/**
* Options pass on to `externalizeDeps` plugin in electron-vite.
*
* Automatically externalize dependencies.
*
* @default true
*/
externalizeDeps?: boolean | ExternalOptions
}
export interface PreloadViteConfig extends ViteConfig, IsolatedEntriesOption {}
interface BytecodeMixin {
/**
* Options pass on to `bytecode` plugin in electron-vite.
* https://electron-vite.org/guide/source-code-protection#options
*
* Compiles source code to v8 bytecode and works only in the `build` command.
*/
bytecode?: boolean | BytecodeOptions
}
export interface RendererViteConfig extends ViteConfig, IsolatedEntriesOption {}
interface MainBuildOptions extends ViteBuildOptions, ExternalizeDepsMixin, BytecodeMixin {}
interface PreloadBuildOptions extends ViteBuildOptions, ExternalizeDepsMixin, BytecodeMixin, IsolatedEntriesMixin {}
interface RendererBuildOptions extends ViteBuildOptions, IsolatedEntriesMixin {}
interface BaseViteConfig<T> extends Omit<ViteConfig, 'build'> {
/**
* Build specific options
*/
build?: T
}
export interface MainViteConfig extends BaseViteConfig<MainBuildOptions> {}
export interface PreloadViteConfig extends BaseViteConfig<PreloadBuildOptions> {}
export interface RendererViteConfig extends BaseViteConfig<RendererBuildOptions> {}
export interface UserConfig {
/**
@ -78,11 +118,7 @@ export type ElectronViteConfigFnPromise = (env: ConfigEnv) => Promise<UserConfig
export type ElectronViteConfigFn = (env: ConfigEnv) => UserConfig | Promise<UserConfig>
export type ElectronViteConfigExport =
| UserConfig
| Promise<UserConfig>
| ElectronViteConfigFnObject
| ElectronViteConfigFnPromise
| ElectronViteConfigFn
UserConfig | Promise<UserConfig> | ElectronViteConfigFnObject | ElectronViteConfigFnPromise | ElectronViteConfigFn
/**
* Type helper to make it easier to use `electron.vite.config.*`
@ -121,7 +157,8 @@ export async function resolveConfig(
process.env.NODE_ENV = defaultMode
let userConfig: UserConfig | undefined
const userConfig: UserConfig | undefined = {}
let configFileDependencies: string[] = []
let { configFile } = config
@ -148,110 +185,20 @@ export async function resolveConfig(
const outDir = config.build?.outDir
if (loadResult.config.main) {
const mainViteConfig: MainViteConfig = mergeConfig(loadResult.config.main, deepClone(config))
const { main, preload, renderer } = loadResult.config
mainViteConfig.mode = inlineConfig.mode || mainViteConfig.mode || defaultMode
if (outDir) {
resetOutDir(mainViteConfig, outDir, 'main')
}
const builtInMainPlugins: PluginOption[] = [
electronMainConfigPresetPlugin({ root }),
electronMainConfigValidatorPlugin(),
assetPlugin(),
workerPlugin(),
modulePathPlugin(
mergeConfig(
{
plugins: [electronMainConfigPresetPlugin({ root }), assetPlugin(), importMetaPlugin(), esmShimPlugin()]
},
mainViteConfig
)
),
importMetaPlugin(),
esmShimPlugin()
]
mainViteConfig.plugins = builtInMainPlugins.concat(mainViteConfig.plugins || [])
loadResult.config.main = mainViteConfig
if (main) {
userConfig.main = await new MainConfigFactory(main, config, { outDir, root }).build()
}
if (loadResult.config.preload) {
const preloadViteConfig: PreloadViteConfig = mergeConfig(loadResult.config.preload, deepClone(config))
preloadViteConfig.mode = inlineConfig.mode || preloadViteConfig.mode || defaultMode
if (outDir) {
resetOutDir(preloadViteConfig, outDir, 'preload')
}
const builtInPreloadPlugins: PluginOption[] = [
electronPreloadConfigPresetPlugin({ root }),
electronPreloadConfigValidatorPlugin(),
assetPlugin(),
importMetaPlugin(),
esmShimPlugin()
]
if (preloadViteConfig.isolatedEntries) {
builtInPreloadPlugins.push(
isolateEntriesPlugin(
mergeConfig(
{
plugins: [
electronPreloadConfigPresetPlugin({ root }),
assetPlugin(),
importMetaPlugin(),
esmShimPlugin()
]
},
preloadViteConfig
)
)
)
}
preloadViteConfig.plugins = builtInPreloadPlugins.concat(preloadViteConfig.plugins)
loadResult.config.preload = preloadViteConfig
if (preload) {
userConfig.preload = await new PreloadConfigFactory(preload, config, { outDir, root }).build()
}
if (loadResult.config.renderer) {
const rendererViteConfig: RendererViteConfig = mergeConfig(loadResult.config.renderer, deepClone(config))
rendererViteConfig.mode = inlineConfig.mode || rendererViteConfig.mode || defaultMode
if (outDir) {
resetOutDir(rendererViteConfig, outDir, 'renderer')
}
const builtInRendererPlugins: PluginOption[] = [
electronRendererConfigPresetPlugin({ root }),
electronRendererConfigValidatorPlugin()
]
if (rendererViteConfig.isolatedEntries) {
builtInRendererPlugins.push(
isolateEntriesPlugin(
mergeConfig(
{
plugins: [electronRendererConfigPresetPlugin({ root })]
},
rendererViteConfig
)
)
)
}
rendererViteConfig.plugins = builtInRendererPlugins.concat(rendererViteConfig.plugins || [])
loadResult.config.renderer = rendererViteConfig
if (renderer) {
userConfig.renderer = await new RendererConfigFactory(renderer, config, { outDir, root }).build()
}
userConfig = loadResult.config
configFile = loadResult.path
configFileDependencies = loadResult.dependencies
}
@ -266,6 +213,111 @@ export async function resolveConfig(
return resolved
}
export abstract class ConfigFactory<T extends MainViteConfig | PreloadViteConfig | RendererViteConfig> {
constructor(
protected readonly baseConfig: T,
protected readonly inlineConfig: InlineConfig,
protected readonly options: { outDir?: string; root?: string }
) {
baseConfig.build ??= {}
baseConfig.build.rolldownOptions ??= baseConfig.build.rollupOptions
}
async build(cleanMode?: boolean): Promise<T> {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const config = mergeConfig(cloneConfig(this.baseConfig) as any, cloneConfig(this.inlineConfig)) as T
config.mode = this.inlineConfig.mode || config.mode || process.env.NODE_ENV
if (this.options.outDir) {
resetOutDir(config, this.options.outDir, this.processType())
}
const builtinPlugins = await this.resolveBuiltinPlugins(config, cleanMode)
config.plugins = builtinPlugins.concat(config.plugins || [])
return config
}
protected abstract processType(): 'main' | 'preload' | 'renderer'
protected abstract resolveBuiltinPlugins(config: T, cleanMode?: boolean): Promise<PluginOption[]>
}
export class MainConfigFactory extends ConfigFactory<MainViteConfig> {
protected processType(): 'main' {
return 'main'
}
protected async resolveBuiltinPlugins(config: MainViteConfig, cleanMode?: boolean): Promise<PluginOption[]> {
const configDrivenPlugins: PluginOption[] = await resolveConfigDrivenPlugins(config)
return cleanMode
? [
electronMainConfigPresetPlugin({ root: this.options.root }),
assetPlugin(),
importMetaPlugin(),
esmShimPlugin(),
...configDrivenPlugins
]
: [
electronMainConfigPresetPlugin({ root: this.options.root }),
electronMainConfigValidatorPlugin(),
assetPlugin(),
workerPlugin(),
modulePathPlugin(this),
importMetaPlugin(),
esmShimPlugin(),
...configDrivenPlugins
]
}
}
export class PreloadConfigFactory extends ConfigFactory<PreloadViteConfig> {
protected processType(): 'preload' {
return 'preload'
}
protected async resolveBuiltinPlugins(config: PreloadViteConfig, cleanMode?: boolean): Promise<PluginOption[]> {
const configDrivenPlugins: PluginOption[] = await resolveConfigDrivenPlugins(config)
return cleanMode
? [
electronPreloadConfigPresetPlugin({ root: this.options.root }),
assetPlugin(),
importMetaPlugin(),
esmShimPlugin(),
...configDrivenPlugins
]
: [
electronPreloadConfigPresetPlugin({ root: this.options.root }),
electronPreloadConfigValidatorPlugin(),
assetPlugin(),
importMetaPlugin(),
esmShimPlugin(),
...configDrivenPlugins,
...(config.build?.isolatedEntries ? [isolateEntriesPlugin(this)] : [])
]
}
}
export class RendererConfigFactory extends ConfigFactory<RendererViteConfig> {
protected processType(): 'renderer' {
return 'renderer'
}
protected async resolveBuiltinPlugins(config: RendererViteConfig, cleanMode?: boolean): Promise<PluginOption[]> {
return cleanMode
? [electronRendererConfigPresetPlugin({ root: this.options.root })]
: [
electronRendererConfigPresetPlugin({ root: this.options.root }),
electronRendererConfigValidatorPlugin(),
...(config.build?.isolatedEntries ? [isolateEntriesPlugin(this)] : [])
]
}
}
function resetOutDir(config: ViteConfig, outDir: string, subOutDir: string): void {
let userOutDir = config.build?.outDir
if (outDir === userOutDir) {
@ -278,7 +330,37 @@ function resetOutDir(config: ViteConfig, outDir: string, subOutDir: string): voi
}
}
const CONFIG_FILE_NAME = 'electron.vite.config'
async function resolveConfigDrivenPlugins(config: MainViteConfig | PreloadViteConfig): Promise<PluginOption[]> {
const userPlugins = (await asyncFlatten(config.plugins || [])).filter(Boolean) as Plugin[]
const configDrivenPlugins: PluginOption[] = []
const hasExternalizeDepsPlugin = userPlugins.some(p => p.name === 'vite:externalize-deps')
if (!hasExternalizeDepsPlugin) {
const externalOptions = config.build?.externalizeDeps ?? true
if (externalOptions) {
isOptions<ExternalOptions>(externalOptions)
? configDrivenPlugins.push(externalizeDepsPlugin(externalOptions))
: configDrivenPlugins.push(externalizeDepsPlugin())
}
}
const hasBytecodePlugin = userPlugins.some(p => p.name === 'vite:bytecode')
if (!hasBytecodePlugin) {
const bytecodeOptions = config.build?.bytecode
if (bytecodeOptions) {
isOptions<BytecodeOptions>(bytecodeOptions)
? configDrivenPlugins.push(bytecodePlugin(bytecodeOptions))
: configDrivenPlugins.push(bytecodePlugin())
}
}
return configDrivenPlugins
}
function isOptions<T extends object>(value: boolean | T): value is T {
return typeof value === 'object' && value !== null
}
export async function loadConfigFromFile(
configEnv: ConfigEnv,
@ -295,9 +377,7 @@ export async function loadConfigFromFile(
throw new Error(`config file cannot be named ${configFile}.`)
}
const resolvedPath = configFile
? path.resolve(configFile)
: findConfigFile(configRoot, ['js', 'ts', 'mjs', 'cjs', 'mts', 'cts'])
const resolvedPath = configFile ? path.resolve(configFile) : findConfigFile(configRoot)
if (!resolvedPath) {
return {
@ -310,179 +390,30 @@ export async function loadConfigFromFile(
const isESM = isFilePathESM(resolvedPath)
try {
const bundled = await bundleConfigFile(resolvedPath, isESM)
const userConfig = await loadConfigFormBundledFile(configRoot, resolvedPath, bundled.code, isESM)
const { code, dependencies } = await bundleConfigFile(resolvedPath, isESM)
const configExport = await loadConfigFormBundledFile<ElectronViteConfigExport>(resolvedPath, code, isESM)
const config = await (typeof userConfig === 'function' ? userConfig(configEnv) : userConfig)
const config = await (typeof configExport === 'function' ? configExport(configEnv) : configExport)
if (!isObject(config)) {
throw new Error(`config must export or return an object`)
}
const configRequired: string[] = []
let mainConfig
if (config.main) {
const mainViteConfig = config.main
mainConfig = await (typeof mainViteConfig === 'function' ? mainViteConfig(configEnv) : mainViteConfig)
if (!isObject(mainConfig)) {
throw new Error(`main config must export or return an object`)
if (!ignoreConfigWarning) {
const missingFields = ['main', 'renderer', 'preload'].filter(field => !config[field])
if (missingFields.length > 0) {
createLogger(logLevel).warn(
`${colors.yellow(colors.bold('(!)'))} ${colors.yellow(`${missingFields.join(' and ')} config is missing`)}\n`
)
}
} else {
configRequired.push('main')
}
let rendererConfig
if (config.renderer) {
const rendererViteConfig = config.renderer
rendererConfig = await (typeof rendererViteConfig === 'function'
? rendererViteConfig(configEnv)
: rendererViteConfig)
if (!isObject(rendererConfig)) {
throw new Error(`renderer config must export or return an object`)
}
} else {
configRequired.push('renderer')
}
let preloadConfig
if (config.preload) {
const preloadViteConfig = config.preload
preloadConfig = await (typeof preloadViteConfig === 'function' ? preloadViteConfig(configEnv) : preloadViteConfig)
if (!isObject(preloadConfig)) {
throw new Error(`preload config must export or return an object`)
}
} else {
configRequired.push('preload')
}
if (!ignoreConfigWarning && configRequired.length > 0) {
createLogger(logLevel).warn(colors.yellow(`${configRequired.join(' and ')} config is missing`))
}
return {
path: normalizePath(resolvedPath),
config: {
main: mainConfig,
renderer: rendererConfig,
preload: preloadConfig
},
dependencies: bundled.dependencies
config,
dependencies
}
} catch (e) {
createLogger(logLevel).error(colors.red(`failed to load config from ${resolvedPath}`), { error: e as Error })
throw e
}
}
function findConfigFile(configRoot: string, extensions: string[]): string {
for (const ext of extensions) {
const configFile = path.resolve(configRoot, `${CONFIG_FILE_NAME}.${ext}`)
if (fs.existsSync(configFile)) {
return configFile
}
}
return ''
}
async function bundleConfigFile(fileName: string, isESM: boolean): Promise<{ code: string; dependencies: string[] }> {
const dirnameVarName = '__electron_vite_injected_dirname'
const filenameVarName = '__electron_vite_injected_filename'
const importMetaUrlVarName = '__electron_vite_injected_import_meta_url'
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: [fileName],
write: false,
target: ['node20'],
platform: 'node',
bundle: true,
format: isESM ? 'esm' : 'cjs',
sourcemap: false,
metafile: true,
define: {
__dirname: dirnameVarName,
__filename: filenameVarName,
'import.meta.url': importMetaUrlVarName
},
plugins: [
{
name: 'externalize-deps',
setup(build): void {
build.onResolve({ filter: /.*/ }, args => {
const id = args.path
if (id[0] !== '.' && !path.isAbsolute(id)) {
return {
external: true
}
}
return null
})
}
},
{
name: 'replace-import-meta',
setup(build): void {
build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async args => {
const contents = await fs.promises.readFile(args.path, 'utf8')
const injectValues =
`const ${dirnameVarName} = ${JSON.stringify(path.dirname(args.path))};` +
`const ${filenameVarName} = ${JSON.stringify(args.path)};` +
`const ${importMetaUrlVarName} = ${JSON.stringify(pathToFileURL(args.path).href)};`
return {
loader: args.path.endsWith('ts') ? 'ts' : 'js',
contents: injectValues + contents
}
})
}
}
]
})
const { text } = result.outputFiles[0]
return {
code: text,
dependencies: result.metafile ? Object.keys(result.metafile.inputs) : []
}
}
interface NodeModuleWithCompile extends NodeModule {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
_compile(code: string, filename: string): any
}
const _require = createRequire(import.meta.url)
async function loadConfigFormBundledFile(
configRoot: string,
configFile: string,
bundledCode: string,
isESM: boolean
): Promise<ElectronViteConfigExport> {
if (isESM) {
const fileNameTmp = path.resolve(configRoot, `${CONFIG_FILE_NAME}.${Date.now()}.mjs`)
fs.writeFileSync(fileNameTmp, bundledCode)
const fileUrl = pathToFileURL(fileNameTmp)
try {
return (await import(fileUrl.href)).default
} finally {
try {
fs.unlinkSync(fileNameTmp)
} catch {}
}
} else {
const extension = path.extname(configFile)
const realFileName = fs.realpathSync(configFile)
const loaderExt = extension in _require.extensions ? extension : '.js'
const defaultLoader = _require.extensions[loaderExt]!
_require.extensions[loaderExt] = (module: NodeModule, filename: string): void => {
if (filename === realFileName) {
;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
} else {
defaultLoader(module, filename)
}
}
delete _require.cache[_require.resolve(configFile)]
const raw = _require(configFile)
_require.extensions[loaderExt] = defaultLoader
return raw.__esModule ? raw.default : raw
}
}

View File

@ -69,6 +69,11 @@ export function getElectronNodeTarget(): string {
const electronVer = getElectronMajorVer()
const nodeVer = {
'44': '24.18',
'43': '24.17',
'42': '24.15',
'41': '24.14',
'40': '24.14',
'39': '22.20',
'38': '22.19',
'37': '22.16',
@ -100,6 +105,11 @@ export function getElectronChromeTarget(): string {
const electronVer = getElectronMajorVer()
const chromeVer = {
'44': '152',
'43': '150',
'42': '148',
'41': '146',
'40': '144',
'39': '142',
'38': '140',
'37': '138',
@ -155,7 +165,36 @@ export function startElectron(root: string | undefined): ChildProcess {
const entry = process.env.ELECTRON_ENTRY || '.'
const ps = spawn(electronPath, [entry].concat(args), { stdio: 'inherit' })
ps.on('close', process.exit)
// `code` is null when the child was terminated by a signal, and `process.exit` ignores
// its second argument, so passing the listener directly would report every crash as a
// successful exit. Map a signal death to a non-zero status instead.
ps.on('close', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
setupElectronSignalHandlers(ps)
return ps
}
let electronPs: ChildProcess | undefined
let signalHandlersRegistered = false
function setupElectronSignalHandlers(child: ChildProcess): void {
electronPs = child
if (signalHandlersRegistered) return
const signals: NodeJS.Signals[] =
process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGUSR2']
for (const signal of signals) {
process.on(signal, () => {
const child = electronPs
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
// A previously sent signal does not mean the child has exited.
child.kill(signal)
})
}
signalHandlersRegistered = true
}

130
src/load.ts Normal file
View File

@ -0,0 +1,130 @@
import path from 'node:path'
import fs from 'node:fs'
import { pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
import { rolldown } from 'rolldown'
import type { RolldownOutput, OutputChunk } from 'rolldown'
const CONFIG_FILE_NAME = 'electron.vite.config'
export function findConfigFile(
configRoot: string,
extensions: string[] = ['js', 'ts', 'mjs', 'cjs', 'mts', 'cts']
): string {
for (const ext of extensions) {
const configFile = path.resolve(configRoot, `${CONFIG_FILE_NAME}.${ext}`)
if (fs.existsSync(configFile)) {
return configFile
}
}
return ''
}
export async function bundleConfigFile(
fileName: string,
isESM: boolean
): Promise<{ code: string; dependencies: string[] }> {
const dirnameVarName = '__electron_vite_injected_dirname'
const filenameVarName = '__electron_vite_injected_filename'
const importMetaUrlVarName = '__electron_vite_injected_import_meta_url'
const bundle = await rolldown({
input: path.resolve(fileName),
platform: 'node',
transform: {
target: 'node20',
define: {
__dirname: dirnameVarName,
__filename: filenameVarName,
'import.meta.url': importMetaUrlVarName
}
},
plugins: [
{
name: 'externalize-deps',
resolveId(id) {
if (id[0] !== '.' && !path.isAbsolute(id)) {
return { id, external: true }
}
return null
}
},
{
name: 'inject-file-scope-variables',
transform: {
filter: { id: /\.[cm]?[jt]s$/ },
handler(code, id) {
const injectValues =
`const ${dirnameVarName} = ${JSON.stringify(path.dirname(id))};` +
`const ${filenameVarName} = ${JSON.stringify(id)};` +
`const ${importMetaUrlVarName} = ${JSON.stringify(pathToFileURL(id).href)};`
return { code: injectValues + code, map: null }
}
}
}
]
})
let result: RolldownOutput
try {
result = await bundle.generate({
format: isESM ? 'esm' : 'cjs',
sourcemap: 'inline',
sourcemapPathTransform(relative, sourcemapPath) {
return path.resolve(path.dirname(sourcemapPath), relative)
},
codeSplitting: false
})
} finally {
await bundle.close()
}
const entryChunk = result.output.find((chunk): chunk is OutputChunk => chunk.type === 'chunk' && chunk.isEntry)
if (!entryChunk) {
throw new Error(`Failed to bundle config file: ${fileName}`)
}
return {
code: entryChunk.code,
dependencies: entryChunk.moduleIds.filter(id => !id.startsWith('\0'))
}
}
interface NodeModuleWithCompile extends NodeModule {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
_compile(code: string, filename: string): any
}
const _require = createRequire(import.meta.url)
export async function loadConfigFormBundledFile<T>(fileName: string, bundledCode: string, isESM: boolean): Promise<T> {
if (isESM) {
const fileNameTmp = `${fileName}.${Date.now()}-${Math.random().toString(16).slice(2)}.mjs`
fs.writeFileSync(fileNameTmp, bundledCode)
const fileUrl = pathToFileURL(fileNameTmp)
try {
return (await import(fileUrl.href)).default
} finally {
try {
fs.unlinkSync(fileNameTmp)
} catch {}
}
} else {
const extension = path.extname(fileName)
const realFileName = fs.realpathSync(fileName)
const loaderExt = extension in _require.extensions ? extension : '.js'
const defaultLoader = _require.extensions[loaderExt]!
_require.extensions[loaderExt] = (module: NodeModule, filename: string): void => {
if (filename === realFileName) {
;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
} else {
defaultLoader(module, filename)
}
}
delete _require.cache[_require.resolve(fileName)]
const raw = _require(fileName)
_require.extensions[loaderExt] = defaultLoader
return raw.__esModule ? raw.default : raw
}
}

View File

@ -1,7 +1,6 @@
import path from 'node:path'
import fs from 'node:fs/promises'
import type { SourceMapInput } from 'rollup'
import { type Plugin } from 'vite'
import { type Plugin, type Environment, normalizePath } from 'vite'
import MagicString from 'magic-string'
import { cleanUrl, getHash, toRelativePath } from '../utils'
import { supportImportMetaPaths } from '../electron'
@ -26,23 +25,18 @@ export default async function loadWasm(file, importObject = {}) {
}
`
const assetCache = new WeakMap<Environment, Map<string, string>>()
const publicAssetPathCache = new WeakMap<Environment, Map<string, string>>()
export default function assetPlugin(): Plugin {
let publicDir = ''
let outDir = ''
const publicAssetPathCache = new Map<string, string>()
const assetCache = new Map<string, string>()
const isImportMetaPathSupported = supportImportMetaPaths()
return {
name: 'vite:node-asset',
apply: 'build',
enforce: 'pre',
buildStart(): void {
publicAssetPathCache.clear()
assetCache.clear()
},
configResolved(config): void {
publicDir = config.publicDir
outDir = config.build.outDir
publicAssetPathCache.set(this.environment, new Map())
assetCache.set(this.environment, new Map())
},
resolveId(id): string | void {
if (id === wasmHelperId) {
@ -59,15 +53,21 @@ export default function assetPlugin(): Plugin {
}
let referenceId: string
const file = cleanUrl(id)
const { publicDir } = this.environment.config
if (publicDir && file.startsWith(publicDir)) {
const hash = getHash(file)
if (!publicAssetPathCache.get(hash)) {
publicAssetPathCache.set(hash, file)
const publicAssetPathMap = publicAssetPathCache.get(this.environment)!
if (!publicAssetPathMap.get(hash)) {
publicAssetPathMap.set(hash, file)
}
referenceId = `__VITE_NODE_PUBLIC_ASSET__${hash}__`
} else {
const cached = assetCache.get(file)
const cache = assetCache.get(this.environment)!
const cached = cache.get(file)
if (cached) {
referenceId = cached
} else {
@ -78,7 +78,7 @@ export default function assetPlugin(): Plugin {
source: source as unknown as Uint8Array
})
referenceId = `__VITE_NODE_ASSET__${hash}__`
assetCache.set(file, referenceId)
cache.set(file, referenceId)
}
}
@ -105,7 +105,7 @@ export default function assetPlugin(): Plugin {
export default importObject => loadWasm(${referenceId}, importObject)`
}
},
renderChunk(code, chunk, { sourcemap }): { code: string; map: SourceMapInput } | null {
renderChunk(code, chunk, { sourcemap, dir }) {
let match: RegExpExecArray | null
let s: MagicString | undefined
@ -121,12 +121,14 @@ export default function assetPlugin(): Plugin {
})
}
const publicAssetPathMap = publicAssetPathCache.get(this.environment)!
nodePublicAssetRE.lastIndex = 0
while ((match = nodePublicAssetRE.exec(code))) {
s ||= new MagicString(code)
const [full, hash] = match
const filename = publicAssetPathCache.get(hash)!
const outputFilepath = toRelativePath(filename, path.join(outDir, chunk.fileName))
const filename = publicAssetPathMap.get(hash)!
const outputFilepath = toRelativePath(filename, normalizePath(path.join(dir!, chunk.fileName)))
const replacement = JSON.stringify(outputFilepath)
s.overwrite(match.index, match.index + full.length, replacement, {
contentOnly: true

View File

@ -1,156 +1,89 @@
import path from 'node:path'
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { createRequire } from 'node:module'
import colors from 'picocolors'
import { type Plugin, type Logger, type LibraryOptions, normalizePath } from 'vite'
import * as babel from '@babel/core'
import { type Plugin, type Rolldown, normalizePath } from 'vite'
import type * as Babel from '@babel/core'
import MagicString from 'magic-string'
import type { SourceMapInput, OutputChunk, OutputOptions } from 'rollup'
import { getElectronPath } from '../electron'
import { toRelativePath } from '../utils'
import { toRelativePath, resolveBuildOutputs } from '../utils'
import type { BytecodeCompiler, BytecodeTarget } from '../bytecodeCompiler'
// Inspired by https://github.com/bytenode/bytenode
const _require = createRequire(import.meta.url)
const require = createRequire(import.meta.url)
let babel: typeof Babel | undefined
function getBytecodeCompilerPath(): string {
return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode.cjs')
function getBytecodeCompiler(plugins: readonly Plugin[]): BytecodeCompiler | undefined {
const plugin = plugins.find(plugin => plugin.name === 'vite:electron-bytecode-build')
return (plugin?.api as { compiler?: BytecodeCompiler } | undefined)?.compiler
}
function compileToBytecode(code: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
let data = Buffer.from([])
const electronPath = getElectronPath()
const bytecodePath = getBytecodeCompilerPath()
const proc = spawn(electronPath, [bytecodePath], {
env: { ELECTRON_RUN_AS_NODE: '1' },
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
})
if (proc.stdin) {
proc.stdin.write(code)
proc.stdin.end()
}
if (proc.stdout) {
proc.stdout.on('data', chunk => {
data = Buffer.concat([data, chunk])
})
proc.stdout.on('error', err => {
console.error(err)
})
proc.stdout.on('end', () => {
resolve(data)
})
}
if (proc.stderr) {
proc.stderr.on('data', chunk => {
console.error('Error: ', chunk.toString())
})
proc.stderr.on('error', err => {
console.error('Error: ', err)
})
}
proc.addListener('message', message => console.log(message))
proc.addListener('error', err => console.error(err))
proc.on('error', err => reject(err))
proc.on('exit', () => {
resolve(data)
})
})
// Versioned envelope: magic/version (8), UTF-16 source length (4), module ID (16), V8 cache.
// Keep our metadata separate from the private V8 cache header.
async function compileToBytecode(compiler: BytecodeCompiler, target: BytecodeTarget, code: string): Promise<Buffer> {
const id = randomBytes(16)
// Reserve enough source space for an untruncated, per-artifact placeholder identity.
const source = code + '\n/*' + id.toString('hex') + '*/'
const result = await compiler.compile(target, source)
const header = Buffer.alloc(28)
header.write('EVBC0001', 0, 'ascii')
header.writeUInt32LE(result.sourceLength, 8)
id.copy(header, 12)
return Buffer.concat([header, Buffer.from(result.cachedData, 'base64')])
}
const bytecodeModuleLoaderCode = [
`"use strict";`,
`const fs = require("fs");`,
`const path = require("path");`,
`const vm = require("vm");`,
`const v8 = require("v8");`,
`const Module = require("module");`,
`v8.setFlagsFromString("--no-lazy");`,
`v8.setFlagsFromString("--no-flush-bytecode");`,
`const FLAG_HASH_OFFSET = 12;`,
`const SOURCE_HASH_OFFSET = 8;`,
`let dummyBytecode;`,
`function setFlagHashHeader(bytecodeBuffer) {`,
` if (!dummyBytecode) {`,
` const script = new vm.Script("", {`,
` produceCachedData: true`,
` });`,
` dummyBytecode = script.createCachedData();`,
` }`,
` dummyBytecode.slice(FLAG_HASH_OFFSET, FLAG_HASH_OFFSET + 4).copy(bytecodeBuffer, FLAG_HASH_OFFSET);`,
`};`,
`function getSourceHashHeader(bytecodeBuffer) {`,
` return bytecodeBuffer.slice(SOURCE_HASH_OFFSET, SOURCE_HASH_OFFSET + 4);`,
`};`,
`function buffer2Number(buffer) {`,
` let ret = 0;`,
` ret |= buffer[3] << 24;`,
` ret |= buffer[2] << 16;`,
` ret |= buffer[1] << 8;`,
` ret |= buffer[0];`,
` return ret;`,
`};`,
`Module._extensions[".jsc"] = Module._extensions[".cjsc"] = function (module, filename) {`,
` const bytecodeBuffer = fs.readFileSync(filename);`,
` if (!Buffer.isBuffer(bytecodeBuffer)) {`,
` throw new Error("BytecodeBuffer must be a buffer object.");`,
` }`,
` setFlagHashHeader(bytecodeBuffer);`,
` const length = buffer2Number(getSourceHashHeader(bytecodeBuffer));`,
` let dummyCode = "";`,
` if (length > 1) {`,
` dummyCode = "\\"" + "\\u200b".repeat(length - 2) + "\\"";`,
` }`,
` const script = new vm.Script(dummyCode, {`,
` filename: filename,`,
` lineOffset: 0,`,
` displayErrors: true,`,
` cachedData: bytecodeBuffer`,
` });`,
` if (script.cachedDataRejected) {`,
` throw new Error("Invalid or incompatible cached data (cachedDataRejected)");`,
` }`,
` const require = function (id) {`,
` return module.require(id);`,
` };`,
` require.resolve = function (request, options) {`,
` return Module._resolveFilename(request, module, false, options);`,
` };`,
` if (process.mainModule) {`,
` require.main = process.mainModule;`,
` }`,
` require.extensions = Module._extensions;`,
` require.cache = Module._cache;`,
` const compiledWrapper = script.runInThisContext({`,
` filename: filename,`,
` lineOffset: 0,`,
` columnOffset: 0,`,
` displayErrors: true`,
` });`,
` const dirname = path.dirname(filename);`,
` const args = [module.exports, require, module, filename, dirname, process, global];`,
` return compiledWrapper.apply(module.exports, args);`,
`};`
'"use strict";',
'const fs = require("node:fs");',
'const path = require("node:path");',
'const vm = require("node:vm");',
'const v8 = require("node:v8");',
'const Module = require("node:module");',
'v8.setFlagsFromString("--no-lazy");',
'v8.setFlagsFromString("--no-flush-bytecode");',
'const params = ["exports", "require", "module", "__filename", "__dirname"];',
'Module._extensions[".jsc"] = Module._extensions[".cjsc"] = function (module, filename) {',
' const data = fs.readFileSync(filename);',
' if (data.length <= 28 || data.toString("ascii", 0, 8) !== "EVBC0001") {',
' throw new Error("Invalid electron-vite bytecode format; rebuild the application: " + filename);',
' }',
' const length = data.readUInt32LE(8);',
' const tag = "/*" + data.subarray(12, 28).toString("hex") + "*/";',
' if (length < tag.length || length > 0x1fffffff) {',
' throw new Error("Invalid bytecode source length: " + filename);',
' }',
' const placeholder = tag + " ".repeat(length - tag.length);',
' const compiledWrapper = vm.compileFunction(placeholder, params, {',
' filename,',
' cachedData: data.subarray(28)',
' });',
' if (compiledWrapper.cachedDataRejected) {',
' throw new Error("Invalid or incompatible cached data (cachedDataRejected): " + filename);',
' }',
' const require = function (id) { return module.require(id); };',
' require.resolve = function (request, options) {',
' return Module._resolveFilename(request, module, false, options);',
' };',
' if (process.mainModule) require.main = process.mainModule;',
' require.extensions = Module._extensions;',
' require.cache = Module._cache;',
' return compiledWrapper.call(module.exports, module.exports, require, module, filename, path.dirname(filename));',
'};'
]
const bytecodeChunkExtensionRE = /.(jsc|cjsc)$/
export interface BytecodeOptions {
chunkAlias?: string | string[]
chunkAlias?: string | string[] | RegExp
transformArrowFunctions?: boolean
removeBundleJS?: boolean
protectedStrings?: string[]
}
/**
* Compile to v8 bytecode to protect source code.
* Compile source code to v8 bytecode.
*
* @deprecated use `build.bytecode` config option instead
*/
export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
if (process.env.NODE_ENV_ELECTRON_VITE !== 'production') {
@ -158,67 +91,75 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
}
const { chunkAlias = [], transformArrowFunctions = true, removeBundleJS = true, protectedStrings = [] } = options
const _chunkAlias = Array.isArray(chunkAlias) ? chunkAlias : [chunkAlias]
const _chunkAlias = chunkAlias instanceof RegExp ? chunkAlias : Array.isArray(chunkAlias) ? chunkAlias : [chunkAlias]
const transformAllChunks = _chunkAlias.length === 0
const isBytecodeChunk = (chunkName: string): boolean => {
return transformAllChunks || _chunkAlias.some(alias => alias === chunkName)
if (_chunkAlias instanceof RegExp) {
_chunkAlias.lastIndex = 0
return _chunkAlias.test(chunkName)
}
return _chunkAlias.length === 0 || _chunkAlias.some(alias => alias === chunkName)
}
const plugins: babel.PluginItem[] = []
const plugins: Babel.PluginItem[] = []
if (transformArrowFunctions) {
plugins.push('@babel/plugin-transform-arrow-functions')
}
if (protectedStrings.length > 0) {
plugins.push([protectStringsPlugin, { protectedStrings: new Set(protectedStrings) }])
plugins.push([protectStringsPlugin as Babel.PluginTarget, { protectedStrings: new Set(protectedStrings) }])
}
const shouldTransformBytecodeChunk = plugins.length !== 0
const _transform = (code: string, sourceMaps: boolean = false): { code: string; map?: SourceMapInput } | null => {
const re = babel.transform(code, { plugins, sourceMaps })
return re ? { code: re.code || '', map: re.map } : null
const _transform = (
code: string,
sourceMaps: boolean = false
): { code: string; map?: Rolldown.SourceMapInput } | null => {
babel ??= require('@babel/core') as typeof Babel
const re = babel.transformSync(code, { plugins, sourceMaps })
return re ? { code: re.code || '', map: re.map as Rolldown.SourceMapInput } : null
}
const useStrict = '"use strict";'
const bytecodeModuleLoader = 'bytecode-loader.cjs'
let logger: Logger
let supported = false
let isPreload = false
let bytecodeCompiler: BytecodeCompiler | undefined
return {
name: 'vite:bytecode',
apply: 'build',
enforce: 'post',
configResolved(config): void {
bytecodeCompiler = getBytecodeCompiler(config.plugins)
if (supported) {
return
}
logger = config.logger
const useInRenderer = config.plugins.some(p => p.name === 'vite:electron-renderer-preset-config')
isPreload = config.plugins.some(p => p.name === 'vite:electron-preload-config-preset')
const useInRenderer = config.plugins.some(p => p.name === 'vite:electron-renderer-config-preset')
if (useInRenderer) {
config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.'))
return
}
const build = config.build
const resolvedOutputs = resolveBuildOutputs(build.rollupOptions.output, build.lib)
if (resolvedOutputs) {
const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs]
const output = outputs[0]
const outputs = resolveBuildOutputs(build.rolldownOptions.output, build.lib)
const output = outputs[0]
if (output) {
if (output.format === 'es') {
config.logger.warn(
colors.yellow(
'bytecodePlugin does not support ES module, please remove "type": "module" ' +
'in package.json or set the "build.rollupOptions.output.format" option to "cjs".'
'in package.json or set build.rollupOptions.output.format (or build.rolldownOptions.output.format) to "cjs".'
)
)
}
supported = output.format === 'cjs' && !useInRenderer
}
},
renderChunk(code, chunk, { sourcemap }): { code: string; map?: SourceMapInput } | null {
renderChunk(code, chunk, { sourcemap }): { code: string; map?: Rolldown.SourceMapInput } | null {
if (supported && isBytecodeChunk(chunk.name) && shouldTransformBytecodeChunk) {
return _transform(code, !!sourcemap)
}
@ -229,7 +170,9 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
return
}
const _chunks = Object.values(output)
const chunks = _chunks.filter(chunk => chunk.type === 'chunk' && isBytecodeChunk(chunk.name)) as OutputChunk[]
const chunks = _chunks.filter(
chunk => chunk.type === 'chunk' && isBytecodeChunk(chunk.name)
) as Rolldown.OutputChunk[]
if (chunks.length === 0) {
return
@ -249,77 +192,79 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
const bundles = Object.keys(output)
await Promise.all(
bundles.map(async name => {
const chunk = output[name]
if (chunk.type === 'chunk') {
let _code = chunk.code
if (bytecodeRE) {
let match: RegExpExecArray | null
let s: MagicString | undefined
while ((match = bytecodeRE.exec(_code))) {
s ||= new MagicString(_code)
const [prefix, chunkName] = match
const len = prefix.length + chunkName.length
s.overwrite(match.index, match.index + len, prefix + chunkName + 'c', {
contentOnly: true
})
}
if (s) {
_code = s.toString()
}
}
if (bytecodeChunks.includes(name)) {
const bytecodeBuffer = await compileToBytecode(_code)
this.emitFile({
type: 'asset',
fileName: name + 'c',
source: bytecodeBuffer
if (!bytecodeCompiler) {
throw new Error('Bytecode compilation requires an electron-vite build')
}
for (const name of bundles) {
const chunk = output[name]
if (chunk.type === 'chunk') {
let _code = chunk.code
if (bytecodeRE) {
let match: RegExpExecArray | null
let s: MagicString | undefined
while ((match = bytecodeRE.exec(_code))) {
s ||= new MagicString(_code)
const [prefix, chunkName] = match
const len = prefix.length + chunkName.length
s.overwrite(match.index, match.index + len, prefix + chunkName + 'c', {
contentOnly: true
})
if (!removeBundleJS) {
this.emitFile({
type: 'asset',
fileName: '_' + chunk.fileName,
source: chunk.code
})
}
if (chunk.isEntry) {
const bytecodeLoaderBlock = getBytecodeLoaderBlock(chunk.fileName)
const bytecodeModuleBlock = `require("./${path.basename(name) + 'c'}");`
const code = `${useStrict}\n${bytecodeLoaderBlock}\n${bytecodeModuleBlock}\n`
chunk.code = code
} else {
delete output[chunk.fileName]
}
bytecodeChunkCount += 1
} else {
if (chunk.isEntry) {
let hasBytecodeMoudle = false
const idsToHandle = new Set([...chunk.imports, ...chunk.dynamicImports])
for (const moduleId of idsToHandle) {
if (bytecodeChunks.includes(moduleId)) {
hasBytecodeMoudle = true
break
}
const moduleInfo = this.getModuleInfo(moduleId)
if (moduleInfo && !moduleInfo.isExternal) {
const { importers, dynamicImporters } = moduleInfo
for (const importerId of importers) idsToHandle.add(importerId)
for (const importerId of dynamicImporters) idsToHandle.add(importerId)
}
}
_code = hasBytecodeMoudle
? _code.replace(
/("use strict";)|('use strict';)/,
`${useStrict}\n${getBytecodeLoaderBlock(chunk.fileName)}`
)
: _code
}
chunk.code = _code
}
if (s) {
_code = s.toString()
}
}
})
)
if (bytecodeChunks.includes(name)) {
const bytecodeBuffer = await compileToBytecode(bytecodeCompiler, isPreload ? 'preload' : 'main', _code)
this.emitFile({
type: 'asset',
fileName: name + 'c',
source: bytecodeBuffer
})
if (!removeBundleJS) {
this.emitFile({
type: 'asset',
fileName: '_' + chunk.fileName,
source: chunk.code
})
}
if (chunk.isEntry) {
const bytecodeLoaderBlock = getBytecodeLoaderBlock(chunk.fileName)
const bytecodeModuleBlock = `require("./${path.basename(name) + 'c'}");`
const code = `${useStrict}\n${bytecodeLoaderBlock}\n${bytecodeModuleBlock}\n`
chunk.code = code
} else {
delete output[chunk.fileName]
}
bytecodeChunkCount += 1
} else {
if (chunk.isEntry) {
let hasBytecodeMoudle = false
const idsToHandle = new Set([...chunk.imports, ...chunk.dynamicImports])
for (const moduleId of idsToHandle) {
if (bytecodeChunks.includes(moduleId)) {
hasBytecodeMoudle = true
break
}
const moduleInfo = this.getModuleInfo(moduleId)
if (moduleInfo) {
const { importers, dynamicImporters } = moduleInfo
for (const importerId of importers) idsToHandle.add(importerId)
for (const importerId of dynamicImporters) idsToHandle.add(importerId)
}
}
if (hasBytecodeMoudle) {
const loader = getBytecodeLoaderBlock(chunk.fileName)
const strictRE = /^(#![^\n]*\n)?(["'])use strict\2;/
_code = strictRE.test(_code)
? _code.replace(strictRE, `$&\n${loader}`)
: _code.replace(/^(#![^\n]*\n)?/, `$&${loader}\n`)
}
}
chunk.code = _code
}
}
}
if (bytecodeChunkCount && !_chunks.some(ass => ass.type === 'asset' && ass.fileName === bytecodeModuleLoader)) {
this.emitFile({
@ -333,31 +278,20 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
writeBundle(_, output): void {
if (supported) {
const bytecodeChunkCount = Object.keys(output).filter(chunk => bytecodeChunkExtensionRE.test(chunk)).length
logger.info(`${colors.green(`✓`)} ${bytecodeChunkCount} chunks compiled into bytecode.`)
this.environment.logger.info(`${colors.green(`✓`)} ${bytecodeChunkCount} chunks compiled into bytecode.`)
}
}
}
}
function resolveBuildOutputs(
outputs: OutputOptions | OutputOptions[] | undefined,
libOptions: LibraryOptions | false
): OutputOptions | OutputOptions[] | undefined {
if (libOptions && !Array.isArray(outputs)) {
const libFormats = libOptions.formats || []
return libFormats.map(format => ({ ...outputs, format }))
}
return outputs
}
interface ProtectStringsPluginState extends babel.PluginPass {
interface ProtectStringsPluginState extends Babel.PluginPass {
opts: { protectedStrings: Set<string> }
}
function protectStringsPlugin(api: typeof babel & babel.ConfigAPI): babel.PluginObj<ProtectStringsPluginState> {
function protectStringsPlugin(api: typeof Babel & Babel.ConfigAPI): Babel.PluginObj<ProtectStringsPluginState> {
const { types: t } = api
function createFromCharCodeFunction(value: string): babel.types.CallExpression {
function createFromCharCodeFunction(value: string): Babel.types.CallExpression {
const charCodes = Array.from(value).map(s => s.charCodeAt(0))
const charCodeLiterals = charCodes.map(code => t.numericLiteral(code))

View File

@ -2,10 +2,9 @@ import path from 'node:path'
import fs from 'node:fs'
import { builtinModules } from 'node:module'
import colors from 'picocolors'
import { type Plugin, type LibraryOptions, mergeConfig, normalizePath } from 'vite'
import type { OutputOptions } from 'rollup'
import { type Plugin, type UserConfig, mergeConfig, normalizePath } from 'vite'
import { getElectronNodeTarget, getElectronChromeTarget, supportESM } from '../electron'
import { loadPackageData } from '../utils'
import { loadPackageData, resolveBuildOutputs } from '../utils'
export interface ElectronPluginOptions {
root?: string
@ -39,17 +38,6 @@ function processEnvDefine(): Record<string, string> {
}
}
function resolveBuildOutputs(
outputs: OutputOptions | OutputOptions[] | undefined,
libOptions: LibraryOptions | false
): OutputOptions | OutputOptions[] | undefined {
if (libOptions && !Array.isArray(outputs)) {
const libFormats = libOptions.formats || []
return libFormats.map(format => ({ ...outputs, format }))
}
return outputs
}
export function electronMainConfigPresetPlugin(options?: ElectronPluginOptions): Plugin {
return {
name: 'vite:electron-main-config-preset',
@ -65,29 +53,24 @@ export function electronMainConfigPresetPlugin(options?: ElectronPluginOptions):
const format = pkg.type && pkg.type === 'module' && supportESM() ? 'es' : 'cjs'
const defaultConfig = {
resolve: {
browserField: false,
mainFields: ['module', 'jsnext:main', 'jsnext'],
conditions: ['node']
},
build: {
outDir: path.resolve(root, 'out', 'main'),
target: nodeTarget,
assetsDir: 'chunks',
rollupOptions: {
rolldownOptions: {
external: ['electron', /^electron\/.+/, ...builtinModules.flatMap(m => [m, `node:${m}`])],
output: {}
},
reportCompressedSize: false,
minify: false
}
}
} satisfies UserConfig
const build = config.build || {}
const rollupOptions = build.rollupOptions || {}
if (!rollupOptions.input) {
const rolldownOptions = build.rolldownOptions || {}
if (!rolldownOptions.input) {
const libOptions = build.lib
const outputOptions = rollupOptions.output
const outputOptions = rolldownOptions.output
defaultConfig.build['lib'] = {
entry: findLibEntry(root, 'main'),
formats:
@ -96,10 +79,10 @@ export function electronMainConfigPresetPlugin(options?: ElectronPluginOptions):
: [outputOptions && !Array.isArray(outputOptions) && outputOptions.format ? outputOptions.format : format]
}
} else {
defaultConfig.build.rollupOptions.output['format'] = format
defaultConfig.build.rolldownOptions.output['format'] = format
}
defaultConfig.build.rollupOptions.output['assetFileNames'] = path.posix.join(
defaultConfig.build.rolldownOptions.output['assetFileNames'] = path.posix.join(
build.assetsDir || defaultConfig.build.assetsDir,
'[name]-[hash].[ext]'
)
@ -107,8 +90,6 @@ export function electronMainConfigPresetPlugin(options?: ElectronPluginOptions):
const buildConfig = mergeConfig(defaultConfig.build, build)
config.build = buildConfig
config.resolve = mergeConfig(defaultConfig.resolve, config.resolve || {})
config.define = config.define || {}
config.define = { ...processEnvDefine(), ...config.define }
@ -123,6 +104,8 @@ export function electronMainConfigPresetPlugin(options?: ElectronPluginOptions):
config.build.ssr = true
config.build.ssrEmitAssets = true
config.ssr = { ...config.ssr, ...{ noExternal: true } }
config.build.rollupOptions = config.build.rolldownOptions
}
}
}
@ -144,25 +127,25 @@ export function electronMainConfigValidatorPlugin(): Plugin {
}
const libOptions = build.lib
const rollupOptions = build.rollupOptions
const rolldownOptions = build.rolldownOptions
if (!(libOptions && libOptions.entry) && !rollupOptions?.input) {
if (!(libOptions && libOptions.entry) && !rolldownOptions?.input) {
throw new Error(
'An entry point is required in the electron vite main config, ' +
'which can be specified using "build.lib.entry" or "build.rollupOptions.input".'
'which can be specified using build.lib.entry, build.rollupOptions.input, ' +
'or build.rolldownOptions.input.'
)
}
const resolvedOutputs = resolveBuildOutputs(rollupOptions.output, libOptions)
const outputs = resolveBuildOutputs(rolldownOptions.output, libOptions)
if (resolvedOutputs) {
const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs]
if (outputs.length > 0) {
if (outputs.length > 1) {
throw new Error('The electron vite main config does not support multiple outputs.')
} else {
const outpout = outputs[0]
if (['es', 'cjs'].includes(outpout.format || '')) {
if (outpout.format === 'es' && !supportESM()) {
const output = outputs[0]
if (['es', 'cjs'].includes(output.format || '')) {
if (output.format === 'es' && !supportESM()) {
throw new Error(
'The electron vite main config output format does not support "es", ' +
'you can upgrade electron to the latest version or switch to "cjs" format.'
@ -204,20 +187,20 @@ export function electronPreloadConfigPresetPlugin(options?: ElectronPluginOption
outDir: path.resolve(root, 'out', 'preload'),
target: nodeTarget,
assetsDir: 'chunks',
rollupOptions: {
rolldownOptions: {
external: ['electron', /^electron\/.+/, ...builtinModules.flatMap(m => [m, `node:${m}`])],
output: {}
},
reportCompressedSize: false,
minify: false
}
}
} satisfies UserConfig
const build = config.build || {}
const rollupOptions = build.rollupOptions || {}
if (!rollupOptions.input) {
const rolldownOptions = build.rolldownOptions || {}
if (!rolldownOptions.input) {
const libOptions = build.lib
const outputOptions = rollupOptions.output
const outputOptions = rolldownOptions.output
defaultConfig.build['lib'] = {
entry: findLibEntry(root, 'preload'),
formats:
@ -226,10 +209,10 @@ export function electronPreloadConfigPresetPlugin(options?: ElectronPluginOption
: [outputOptions && !Array.isArray(outputOptions) && outputOptions.format ? outputOptions.format : format]
}
} else {
defaultConfig.build.rollupOptions.output['format'] = format
defaultConfig.build.rolldownOptions.output['format'] = format
}
defaultConfig.build.rollupOptions.output['assetFileNames'] = path.posix.join(
defaultConfig.build.rolldownOptions.output['assetFileNames'] = path.posix.join(
build.assetsDir || defaultConfig.build.assetsDir,
'[name]-[hash].[ext]'
)
@ -237,23 +220,21 @@ export function electronPreloadConfigPresetPlugin(options?: ElectronPluginOption
const buildConfig = mergeConfig(defaultConfig.build, build)
config.build = buildConfig
const resolvedOutputs = resolveBuildOutputs(config.build.rollupOptions!.output, config.build.lib || false)
const outputs = resolveBuildOutputs(config.build.rolldownOptions!.output, config.build.lib)
if (resolvedOutputs) {
const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs]
if (outputs.find(({ format }) => format === 'es')) {
if (Array.isArray(config.build.rollupOptions!.output)) {
config.build.rollupOptions!.output.forEach(output => {
if (output.format === 'es') {
output['entryFileNames'] = '[name].mjs'
output['chunkFileNames'] = '[name]-[hash].mjs'
}
})
} else {
config.build.rollupOptions!.output!['entryFileNames'] = '[name].mjs'
config.build.rollupOptions!.output!['chunkFileNames'] = '[name]-[hash].mjs'
}
if (outputs.some(({ format }) => format === 'es')) {
if (Array.isArray(config.build.rolldownOptions!.output)) {
config.build.rolldownOptions!.output.forEach(output => {
if (output.format === 'es') {
output['entryFileNames'] = '[name].mjs'
output['chunkFileNames'] = '[name]-[hash].mjs'
}
})
} else {
config.build.rolldownOptions!.output!['entryFileNames'] = '[name].mjs'
config.build.rolldownOptions!.output!['chunkFileNames'] = config.build.lib
? '[name]-[hash].mjs'
: path.posix.join(build.assetsDir || defaultConfig.build.assetsDir, '[name]-[hash].mjs')
}
}
@ -272,6 +253,8 @@ export function electronPreloadConfigPresetPlugin(options?: ElectronPluginOption
config.build.ssrEmitAssets = true
config.ssr = mergeConfig(defaultConfig.ssr, config.ssr || {})
config.ssr.noExternal = true
config.build.rollupOptions = config.build.rolldownOptions
}
}
}
@ -293,25 +276,25 @@ export function electronPreloadConfigValidatorPlugin(): Plugin {
}
const libOptions = build.lib
const rollupOptions = build.rollupOptions
const rolldownOptions = build.rolldownOptions
if (!(libOptions && libOptions.entry) && !rollupOptions?.input) {
if (!(libOptions && libOptions.entry) && !rolldownOptions?.input) {
throw new Error(
'An entry point is required in the electron vite preload config, ' +
'which can be specified using "build.lib.entry" or "build.rollupOptions.input".'
'which can be specified using build.lib.entry, build.rollupOptions.input, ' +
' or build.rolldownOptions.input.'
)
}
const resolvedOutputs = resolveBuildOutputs(rollupOptions.output, libOptions)
const outputs = resolveBuildOutputs(rolldownOptions.output, libOptions)
if (resolvedOutputs) {
const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs]
if (outputs.length > 0) {
if (outputs.length > 1) {
throw new Error('The electron vite preload config does not support multiple outputs.')
} else {
const outpout = outputs[0]
if (['es', 'cjs'].includes(outpout.format || '')) {
if (outpout.format === 'es' && !supportESM()) {
const output = outputs[0]
if (['es', 'cjs'].includes(output.format || '')) {
if (output.format === 'es' && !supportESM()) {
throw new Error(
'The electron vite preload config output format does not support "es", ' +
'you can upgrade electron to the latest version or switch to "cjs" format.'
@ -358,14 +341,14 @@ export function electronRendererConfigPresetPlugin(options?: ElectronPluginOptio
outDir: path.resolve(root, 'out', 'renderer'),
target: chromeTarget,
modulePreload: { polyfill: false },
rollupOptions: {
rolldownOptions: {
input: findInput(root)
},
reportCompressedSize: false,
minify: false,
emptyOutDir: emptyOutDir()
}
}
} satisfies UserConfig
if (config.build?.outDir) {
config.build.outDir = path.resolve(root, config.build.outDir)
@ -377,6 +360,8 @@ export function electronRendererConfigPresetPlugin(options?: ElectronPluginOptio
config.envDir = config.envDir || path.resolve(root)
config.envPrefix = config.envPrefix || ['RENDERER_VITE_', 'VITE_']
config.build.rollupOptions = config.build.rolldownOptions
}
}
}
@ -402,10 +387,12 @@ export function electronRendererConfigValidatorPlugin(): Plugin {
}
}
const rollupOptions = build.rollupOptions
if (!rollupOptions.input) {
const rolldownOptions = build.rolldownOptions
if (!rolldownOptions.input) {
config.logger.warn(colors.yellow(`index.html file is not found in ${colors.dim('/src/renderer')} directory.`))
throw new Error('build.rollupOptions.input option is required in the electron vite renderer config.')
throw new Error(
'build.rollupOptions.input or build.rolldownOptions.input option is required in the electron vite renderer config.'
)
}
}
}

View File

@ -1,11 +1,4 @@
/*
* The core of this plugin was conceived by pi0 and is taken from the following repository:
* https://github.com/unjs/unbuild/blob/main/src/builder/plugins/cjs.ts
* license: https://github.com/unjs/unbuild/blob/main/LICENSE
*/
import MagicString from 'magic-string'
import type { SourceMapInput } from 'rollup'
import type { Plugin } from 'vite'
import { supportImportMetaPaths } from '../electron'
@ -14,37 +7,22 @@ const CJSyntaxRe = /__filename|__dirname|require\(|require\.resolve\(/
const CJSShim_normal = `
// -- CommonJS Shims --
import __cjs_url__ from 'node:url';
import __cjs_path__ from 'node:path';
import __cjs_mod__ from 'node:module';
const __filename = __cjs_url__.fileURLToPath(import.meta.url);
const __dirname = __cjs_path__.dirname(__filename);
const require = __cjs_mod__.createRequire(import.meta.url);
import __shims_url__ from 'node:url';
import __shims_path__ from 'node:path';
import __shims_module__ from 'node:module';
const __filename = __shims_url__.fileURLToPath(import.meta.url);
const __dirname = __shims_path__.dirname(__filename);
const require = __shims_module__.createRequire(import.meta.url);
`
const CJSShim_node_20_11 = `
// -- CommonJS Shims --
import __cjs_mod__ from 'node:module';
import __shims_module__ from 'node:module';
const __filename = import.meta.filename;
const __dirname = import.meta.dirname;
const require = __cjs_mod__.createRequire(import.meta.url);
const require = __shims_module__.createRequire(import.meta.url);
`
const ESMStaticImportRe =
/(?<=\s|^|;)import\s*([\s"']*(?<imports>[\p{L}\p{M}\w\t\n\r $*,/{}@.]+)from\s*)?["']\s*(?<specifier>(?<="\s*)[^"]*[^\s"](?=\s*")|(?<='\s*)[^']*[^\s'](?=\s*'))\s*["'][\s;]*/gmu
interface StaticImport {
end: number
}
function findStaticImports(code: string): StaticImport[] {
const matches: StaticImport[] = []
for (const match of code.matchAll(ESMStaticImportRe)) {
matches.push({ end: (match.index || 0) + match[0].length })
}
return matches
}
export default function esmShimPlugin(): Plugin {
const CJSShim = supportImportMetaPaths() ? CJSShim_node_20_11 : CJSShim_normal
@ -52,13 +30,16 @@ export default function esmShimPlugin(): Plugin {
name: 'vite:esm-shim',
apply: 'build',
enforce: 'post',
renderChunk(code, _chunk, { format, sourcemap }): { code: string; map?: SourceMapInput } | null {
renderChunk(code, _chunk, { format, sourcemap }) {
if (format === 'es') {
// Rolldown may remove unused shim bindings after this hook; referenced bindings remain available.
if (code.includes(CJSShim) || !CJSyntaxRe.test(code)) {
return null
}
const lastESMImport = findStaticImports(code).pop()
const lastESMImport = this.parse(code)
.body.filter(node => node.type === 'ImportDeclaration')
.pop()
const indexToAppend = lastESMImport ? lastESMImport.end : 0
const s = new MagicString(code)
s.appendRight(indexToAppend, CJSShim)

View File

@ -7,7 +7,9 @@ export interface ExternalOptions {
}
/**
* Automatically externalize dependencies
* Automatically externalize dependencies.
*
* @deprecated use `build.externalizeDeps` config option instead
*/
export function externalizeDepsPlugin(options: ExternalOptions = {}): Plugin | null {
const { exclude = [], include = [] } = options
@ -31,13 +33,15 @@ export function externalizeDepsPlugin(options: ExternalOptions = {}): Plugin | n
config(config): void {
const defaultConfig = {
build: {
rollupOptions: {
rolldownOptions: {
external: deps.length > 0 ? [...deps, new RegExp(`^(${deps.join('|')})/.+`)] : []
}
}
}
const buildConfig = mergeConfig(defaultConfig.build, config.build || {})
config.build = buildConfig
config.build.rollupOptions = config.build.rolldownOptions
}
}
}

View File

@ -1,6 +1,19 @@
import type { Plugin } from 'vite'
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Plugin, version } from 'vite'
export default function importMetaPlugin(): Plugin {
/**
* @deprecated Vite 8 (Rolldown) now provides native support for these
* `import.meta` properties. These properties are polyfilled when the output
* format is CJS.
*
* @see https://rolldown.rs/in-depth/non-esm-output-formats#well-known-import-meta-properties
*/
export default function importMetaPlugin(): Plugin | null {
const currentVersion = version.split('.')
const major = parseInt(currentVersion[0], 10)
if (major >= 8) {
return null
}
return {
name: 'vite:import-meta',
apply: 'build',
@ -17,5 +30,5 @@ export default function importMetaPlugin(): Plugin {
}
return null
}
}
} as any
}

View File

@ -1,13 +1,22 @@
import { type InlineConfig, type Plugin, type Logger, build as viteBuild, mergeConfig } from 'vite'
import type { InputOptions, RollupOutput } from 'rollup'
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-function-type */
import path from 'node:path'
import { type InlineConfig, type Plugin, type LogLevel, type Rolldown, build as viteBuild, mergeConfig } from 'vite'
import colors from 'picocolors'
import { cleanUrl } from '../utils'
import type { ConfigFactory, PreloadViteConfig, RendererViteConfig } from '../config'
const VIRTUAL_ENTRY_ID = '\0virtual:isolate-entries'
export default function isolateEntriesPlugin(userConfig: InlineConfig): Plugin {
let logger: Logger
const LogLevels: Record<LogLevel, number> = {
silent: 0,
error: 1,
warn: 2,
info: 3
}
export default function isolateEntriesPlugin(factory: ConfigFactory<PreloadViteConfig | RendererViteConfig>): Plugin {
let entries: string[] | { [x: string]: string }[]
let entries: string[] | Record<string, string>
let transformedCount = 0
const assetCache = new Set<string>()
@ -15,40 +24,43 @@ export default function isolateEntriesPlugin(userConfig: InlineConfig): Plugin {
return {
name: 'vite:isolate-entries',
apply: 'build',
configResolved(config): void {
logger = config.logger
},
options(opts): InputOptions | void {
options(opts): Rolldown.InputOptions | void {
const { input } = opts
if (input && typeof input === 'object') {
if ((Array.isArray(input) && input.length > 0) || Object.keys(input).length > 1) {
opts.input = VIRTUAL_ENTRY_ID
entries = input
entries = Array.isArray(input) ? input : Object.entries(input).map(([key, value]) => ({ [key]: value }))
return opts
}
}
},
buildStart(): void {
transformedCount = 0
assetCache.clear()
},
resolveId(id): string | null {
if (id === VIRTUAL_ENTRY_ID) {
return id
}
return null
},
async load(id): Promise<string | void> {
if (id === VIRTUAL_ENTRY_ID) {
const _entries = Array.isArray(entries)
? entries
: Object.entries(entries).map(([key, value]) => ({ [key]: value }))
const userConfig = await factory.build(true)
const shouldLog = LogLevels[userConfig.logLevel || 'info'] >= LogLevels.info
const watchFiles = new Set<string>()
for (const entry of _entries) {
const re = await bundleEntryFile(entry, userConfig, this.meta.watchMode)
for (const entry of entries) {
const re = await bundleEntryFile(entry, userConfig, shouldLog, transformedCount)
const outputChunks = re.bundles.output
for (const chunk of outputChunks) {
if (chunk.type === 'asset' && assetCache.has(chunk.fileName)) {
if (assetCache.has(chunk.fileName)) {
continue
}
this.emitFile({
@ -56,27 +68,43 @@ export default function isolateEntriesPlugin(userConfig: InlineConfig): Plugin {
fileName: chunk.fileName,
source: chunk.type === 'chunk' ? chunk.code : chunk.source
})
if (chunk.type === 'asset') {
assetCache.add(chunk.fileName)
assetCache.add(chunk.fileName)
}
if (this.meta.watchMode) {
const moduleIds = new Set<string>()
for (const chunk of re.bundles.output) {
if (chunk.type === 'chunk') {
for (const id of chunk.moduleIds) {
if (!id.startsWith('\0')) {
moduleIds.add(cleanUrl(id))
}
}
}
}
for (const id of moduleIds) {
this.addWatchFile(id)
}
}
for (const id of re.watchFiles) {
watchFiles.add(id)
}
transformedCount += re.transformedCount
}
for (const id of watchFiles) {
this.addWatchFile(id)
}
return `
// This is the virtual entry file
console.log(1)`
}
},
renderStart(): void {
clearLine()
logger.info(`${colors.green(`✓`)} ${transformedCount} modules transformed.`)
clearLine(-1)
this.environment.logger.info(`${colors.green(`✓`)} ${transformedCount} modules transformed.`)
},
generateBundle(_, bundle): void {
for (const chunkName in bundle) {
if (chunkName.includes('virtual_isolate-entries')) {
@ -90,59 +118,82 @@ export default function isolateEntriesPlugin(userConfig: InlineConfig): Plugin {
async function bundleEntryFile(
input: string | Record<string, string>,
config: InlineConfig,
watch: boolean
): Promise<{ bundles: RollupOutput; watchFiles: string[]; transformedCount: number }> {
const moduleIds: string[] = []
let transformedCount = 0
shouldLog: boolean,
preTransformedCount: number
): Promise<{ bundles: Rolldown.RolldownOutput; transformedCount: number }> {
const transformReporter = transformReporterPlugin(preTransformedCount, shouldLog)
const viteConfig = mergeConfig(config, {
build: {
write: false,
watch: false
},
plugins: [
{
name: 'vite:transform-counter',
transform(): void {
transformedCount++
}
} as Plugin,
...(watch
? [
{
name: 'vite:get-watch-files',
buildEnd(): void {
const allModuleIds = Array.from(this.getModuleIds())
const sourceFiles = allModuleIds.filter(id => {
const info = this.getModuleInfo(id)
return info && !info.isExternal
})
moduleIds.push(...sourceFiles)
}
} as Plugin
]
: [])
],
plugins: [transformReporter],
logLevel: 'warn',
configFile: false
}) as InlineConfig
// rewrite the input instead of merging
viteConfig.build!.rollupOptions!.input = input
viteConfig.build!.rolldownOptions!.input = input
const bundles = await viteBuild(viteConfig)
const bundles = (await viteBuild(viteConfig)) as Rolldown.RolldownOutput
return {
bundles: bundles as RollupOutput,
watchFiles: moduleIds,
transformedCount
bundles: bundles,
transformedCount: transformReporter?.api?.getTransformedCount() || 0
}
}
function clearLine(): void {
process.stdout.moveCursor(0, -1)
function transformReporterPlugin(
preTransformedCount = 0,
shouldLog = true
): Plugin<{ getTransformedCount: () => number }> {
let transformedCount = 0
const log = throttle((id, root) => {
writeLine(`transforming (${preTransformedCount + transformedCount}) ${colors.dim(path.relative(root, id))}`)
})
return {
name: 'vite:transform-reporter',
transform(_, id) {
transformedCount++
if (!shouldLog) return
if (id.includes('?')) return
log(id, this.environment.config.root)
},
api: {
getTransformedCount() {
return transformedCount
}
}
}
}
function writeLine(output: string): void {
if (!process.stdout.isTTY || process.env.CI) return
clearLine()
if (output.length < process.stdout.columns) {
process.stdout.write(output)
} else {
process.stdout.write(output.substring(0, process.stdout.columns - 1))
}
}
function clearLine(move: number = 0): void {
if (!process.stdout.isTTY || process.env.CI) return
if (move < 0) {
process.stdout.moveCursor(0, move)
}
process.stdout.clearLine(0)
process.stdout.cursorTo(0)
}
function throttle(fn: Function) {
let timerHandle: NodeJS.Timeout | null = null
return (...args: any[]) => {
if (timerHandle) return
fn(...args)
timerHandle = setTimeout(() => {
timerHandle = null
}, 100)
}
}

View File

@ -1,16 +1,15 @@
import path from 'node:path'
import { type Plugin, type InlineConfig, build as viteBuild, mergeConfig } from 'vite'
import type { SourceMapInput, RollupOutput, OutputOptions } from 'rollup'
import { type Plugin, type InlineConfig, type Rolldown, build as viteBuild, mergeConfig } from 'vite'
import MagicString from 'magic-string'
import { cleanUrl, toRelativePath } from '../utils'
import { supportImportMetaPaths } from '../electron'
import type { ConfigFactory, MainViteConfig } from '../config'
const modulePathRE = /__VITE_MODULE_PATH__([\w$]+)__/g
/**
* Resolve `?modulePath` import and return the module bundle path.
*/
export default function modulePathPlugin(config: InlineConfig): Plugin {
export default function modulePathPlugin(factory: ConfigFactory<MainViteConfig>): Plugin {
const isImportMetaPathSupported = supportImportMetaPaths()
const assetCache = new Set<string>()
return {
@ -23,15 +22,16 @@ export default function modulePathPlugin(config: InlineConfig): Plugin {
async load(id): Promise<string | void> {
if (id.endsWith('?modulePath')) {
// id resolved by Vite resolve plugin
const re = await bundleEntryFile(cleanUrl(id), config, this.meta.watchMode)
const [outputChunk, ...outputChunks] = re.bundles.output
const config = await factory.build(true)
const bundles = await bundleEntryFile(cleanUrl(id), config)
const [outputChunk, ...outputChunks] = bundles.output
const hash = this.emitFile({
type: 'asset',
fileName: outputChunk.fileName,
source: outputChunk.code
})
for (const chunk of outputChunks) {
if (chunk.type === 'asset' && assetCache.has(chunk.fileName)) {
if (assetCache.has(chunk.fileName)) {
continue
}
this.emitFile({
@ -39,12 +39,22 @@ export default function modulePathPlugin(config: InlineConfig): Plugin {
fileName: chunk.fileName,
source: chunk.type === 'chunk' ? chunk.code : chunk.source
})
if (chunk.type === 'asset') {
assetCache.add(chunk.fileName)
}
assetCache.add(chunk.fileName)
}
for (const id of re.watchFiles) {
this.addWatchFile(id)
if (this.meta.watchMode) {
const moduleIds = new Set<string>()
for (const chunk of bundles.output) {
if (chunk.type === 'chunk') {
for (const id of chunk.moduleIds) {
if (!id.startsWith('\0')) {
moduleIds.add(cleanUrl(id))
}
}
}
}
for (const id of moduleIds) {
this.addWatchFile(id)
}
}
const refId = `__VITE_MODULE_PATH__${hash}__`
const dirnameExpr = isImportMetaPathSupported ? 'import.meta.dirname' : '__dirname'
@ -53,7 +63,7 @@ export default function modulePathPlugin(config: InlineConfig): Plugin {
export default join(${dirnameExpr}, ${refId})`
}
},
renderChunk(code, chunk, { sourcemap }): { code: string; map: SourceMapInput } | null {
renderChunk(code, chunk, { sourcemap }): { code: string; map: Rolldown.SourceMapInput } | null {
let match: RegExpExecArray | null
let s: MagicString | undefined
@ -81,53 +91,36 @@ export default function modulePathPlugin(config: InlineConfig): Plugin {
}
}
async function bundleEntryFile(
input: string,
config: InlineConfig,
watch: boolean
): Promise<{ bundles: RollupOutput; watchFiles: string[] }> {
const moduleIds: string[] = []
async function bundleEntryFile(input: string, config: InlineConfig): Promise<Rolldown.RolldownOutput> {
const viteConfig = mergeConfig(config, {
build: {
rollupOptions: { input },
write: false,
watch: false
},
plugins: [
{
name: 'vite:entry-file-name',
outputOptions(output): OutputOptions {
if (typeof output.entryFileNames !== 'function' && output.entryFileNames) {
output.entryFileNames = '[name]-[hash]' + path.extname(output.entryFileNames)
}
outputOptions(output): Rolldown.OutputOptions {
// Prevent output conflicts
output.hashCharacters = 'hex'
// Assign chunkFileNames to entryFileNames to treat sub-entries as internal chunks
output.entryFileNames = output.chunkFileNames
return output
}
},
...(watch
? [
{
name: 'vite:get-watch-files',
buildEnd(): void {
const allModuleIds = Array.from(this.getModuleIds())
const sourceFiles = allModuleIds.filter(id => {
const info = this.getModuleInfo(id)
return info && !info.isExternal
})
moduleIds.push(...sourceFiles)
}
} as Plugin
]
: [])
],
}
] as Plugin[],
logLevel: 'warn',
configFile: false
})
}) as InlineConfig
// rewrite the input instead of merging
const buildOptions = viteConfig.build!
buildOptions.rolldownOptions = {
...buildOptions.rolldownOptions,
input
}
const bundles = await viteBuild(viteConfig)
return {
bundles: bundles as RollupOutput,
watchFiles: moduleIds
}
return bundles as Rolldown.RolldownOutput
}

View File

@ -79,12 +79,16 @@ export type SwcOptions = {
export function swcPlugin(options: SwcOptions = {}): Plugin {
const filter = createFilter(options.include || /\.(m?ts|[jt]sx)$/, options.exclude || /\.js$/)
let sourcemap: boolean | 'inline' = false
let minify: boolean | 'esbuild' | 'terser' = false
let minify: boolean | 'esbuild' | 'oxc' | 'terser' = false
return {
name: 'vite:swc',
config(): UserConfig {
// Vite 8 defaults to Oxc for transform; `esbuild: false` is ignored and
// warns. SWC replaces both paths when this plugin is active.
// See https://github.com/alex8088/electron-vite/issues/916
return {
esbuild: false
esbuild: false,
oxc: false
}
},
async configResolved(resolvedConfig): Promise<void> {
@ -101,7 +105,7 @@ export function swcPlugin(options: SwcOptions = {}): Plugin {
}
},
async renderChunk(code, chunk): Promise<null | { code: string; map: SourceMap }> {
if (!minify || minify === 'terser') {
if (!minify || minify === 'terser' || minify === 'oxc') {
return null
}
const result = await transformWithSWC(code, chunk.fileName, {

View File

@ -1,5 +1,4 @@
import type { Plugin } from 'vite'
import type { SourceMapInput } from 'rollup'
import MagicString from 'magic-string'
import { cleanUrl, toRelativePath } from '../utils'
@ -36,7 +35,7 @@ export default function workerPlugin(): Plugin {
}
}
},
renderChunk(code, chunk, { sourcemap }): { code: string; map: SourceMapInput } | null {
renderChunk(code, chunk, { sourcemap }) {
let match: RegExpExecArray | null
let s: MagicString | undefined

View File

@ -3,7 +3,7 @@ import path from 'node:path'
import fs from 'node:fs'
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module'
import { loadEnv as viteLoadEnv } from 'vite'
import { type LibraryOptions, type Rolldown, loadEnv as viteLoadEnv } from 'vite'
export function isObject(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
@ -32,6 +32,23 @@ export function toRelativePath(filename: string, importer: string): string {
return relPath.startsWith('.') ? relPath : `./${relPath}`
}
/**
* Resolve configured build outputs into an array.
*/
export function resolveBuildOutputs(
outputs: Rolldown.OutputOptions | Rolldown.OutputOptions[] | undefined,
libOptions: LibraryOptions | false | undefined
): Rolldown.OutputOptions[] {
if (Array.isArray(outputs)) {
return outputs
}
if (libOptions) {
const libFormats = libOptions.formats || []
return libFormats.map(format => ({ ...outputs, format }))
}
return outputs ? [outputs] : []
}
/**
* Load `.env` files within the `envDir` (default: `process.cwd()`) .
* By default, only env variables prefixed with `VITE_`, `MAIN_VITE_`, `PRELOAD_VITE_` and
@ -111,3 +128,65 @@ export function deepClone<T>(value: T): DeepWritable<T> {
}
return value as DeepWritable<T>
}
/**
* Isolate configuration data without cloning plugins or runtime instances.
* Plugins may capture their own object in hooks, so their identity must survive.
*/
export function cloneConfig<T>(config: T): T {
const seen = new WeakMap<object, unknown>()
const pluginArrays = new WeakMap<object, unknown>()
function clone(value: unknown, plugins = false): unknown {
if (value === null || typeof value !== 'object') return value
const cache = plugins ? pluginArrays : seen
if (cache.has(value)) return cache.get(value)
if (Array.isArray(value)) {
const result: unknown[] = new Array(value.length)
cache.set(value, result)
value.forEach((item, index) => {
result[index] = clone(item, plugins)
})
return result
}
// Preserve plugin objects, promises and other opaque plugin options.
if (plugins) return value
if (value instanceof RegExp) {
const result = new RegExp(value)
result.lastIndex = value.lastIndex
cache.set(value, result)
return result
}
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) return value
const result = Object.create(prototype) as Record<PropertyKey, unknown>
cache.set(value, result)
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
Object.defineProperty(result, key, {
value: clone((value as Record<PropertyKey, unknown>)[key], key === 'plugins'),
enumerable: true,
configurable: true,
writable: true
})
}
return result
}
return clone(config) as T
}
type AsyncFlatten<T extends unknown[]> = T extends (infer U)[] ? Exclude<Awaited<U>, U[]>[] : never
export async function asyncFlatten<T extends unknown[]>(arr: T): Promise<AsyncFlatten<T>> {
do {
arr = (await Promise.all(arr)).flat(Infinity) as any
} while (arr.some((v: any) => v?.then))
return arr as unknown[] as AsyncFlatten<T>
}