refactor: compile bytecode using the Electron main process or renderer process rather then via the ELECTRON_RUN_AS_NODE

This commit is contained in:
alex8088 2026-09-25 00:39:47 +08:00
parent ac2cdd1a21
commit e691efbd82
6 changed files with 466 additions and 132 deletions

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,107 @@
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 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)
})
}
// Register readiness immediately, but only resolve it after the selected backend is usable.
const initialized = app.whenReady().then(async () => {
if (process.env.ELECTRON_VITE_RENDERER === '1' || process.env.ELECTRON_VITE_RENDERER === 'true') {
await createRenderer()
parentRPC.register('compile', (...args) => rendererRPC.request('compile', ...args))
} else {
parentRPC.register('compile', compile)
}
return true
})
parentRPC.register('ready', () => initialized)
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,5 +1,5 @@
import path from 'node:path' import path from 'node:path'
import { spawn } from 'node:child_process' import { randomBytes } from 'node:crypto'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import colors from 'picocolors' import colors from 'picocolors'
import { type Plugin, type LibraryOptions, type Rolldown, normalizePath } from 'vite' import { type Plugin, type LibraryOptions, type Rolldown, normalizePath } from 'vite'
@ -7,136 +7,67 @@ import * as babel from '@babel/core'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { getElectronPath } from '../electron' import { getElectronPath } from '../electron'
import { toRelativePath } from '../utils' import { toRelativePath } from '../utils'
import { ElectronCompiler } from './electronCompiler'
// Inspired by https://github.com/bytenode/bytenode // Inspired by https://github.com/bytenode/bytenode
const _require = createRequire(import.meta.url) const _require = createRequire(import.meta.url)
function getBytecodeCompilerPath(): string { function getBytecodeCompilerPath(): string {
return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode.cjs') return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode-main.cjs')
} }
function compileToBytecode(code: string): Promise<Buffer> { // Versioned envelope: magic/version (8), UTF-16 source length (4), module ID (16), V8 cache.
return new Promise((resolve, reject) => { // Keep our metadata separate from the private V8 cache header.
let data = Buffer.from([]) async function compileToBytecode(compiler: ElectronCompiler, code: string): Promise<Buffer> {
const id = randomBytes(16)
const electronPath = getElectronPath() // Reserve enough source space for an untruncated, per-artifact placeholder identity.
const bytecodePath = getBytecodeCompilerPath() const source = code + '\n/*' + id.toString('hex') + '*/'
const result = await compiler.compile(source)
const proc = spawn(electronPath, [bytecodePath], { const header = Buffer.alloc(28)
env: { ELECTRON_RUN_AS_NODE: '1' }, header.write('EVBC0001', 0, 'ascii')
stdio: ['pipe', 'pipe', 'pipe', 'ipc'] header.writeUInt32LE(result.sourceLength, 8)
}) id.copy(header, 12)
return Buffer.concat([header, Buffer.from(result.cachedData, 'base64')])
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)
})
})
} }
const bytecodeModuleLoaderCode = [ const bytecodeModuleLoaderCode = [
`"use strict";`, '"use strict";',
`const fs = require("fs");`, 'const fs = require("node:fs");',
`const path = require("path");`, 'const path = require("node:path");',
`const vm = require("vm");`, 'const vm = require("node:vm");',
`const v8 = require("v8");`, 'const v8 = require("node:v8");',
`const Module = require("module");`, 'const Module = require("node:module");',
`v8.setFlagsFromString("--no-lazy");`, 'v8.setFlagsFromString("--no-lazy");',
`v8.setFlagsFromString("--no-flush-bytecode");`, 'v8.setFlagsFromString("--no-flush-bytecode");',
`const FLAG_HASH_OFFSET = 12;`, 'const params = ["exports", "require", "module", "__filename", "__dirname"];',
`const SOURCE_HASH_OFFSET = 8;`, 'Module._extensions[".jsc"] = Module._extensions[".cjsc"] = function (module, filename) {',
`let dummyBytecode;`, ' const data = fs.readFileSync(filename);',
`function setFlagHashHeader(bytecodeBuffer) {`, ' if (data.length <= 28 || data.toString("ascii", 0, 8) !== "EVBC0001") {',
` if (!dummyBytecode) {`, ' throw new Error("Invalid electron-vite bytecode format; rebuild the application: " + filename);',
` const script = new vm.Script("", {`, ' }',
` produceCachedData: true`, ' const length = data.readUInt32LE(8);',
` });`, ' const tag = "/*" + data.subarray(12, 28).toString("hex") + "*/";',
` dummyBytecode = script.createCachedData();`, ' if (length < tag.length || length > 0x1fffffff) {',
` }`, ' throw new Error("Invalid bytecode source length: " + filename);',
` dummyBytecode.slice(FLAG_HASH_OFFSET, FLAG_HASH_OFFSET + 4).copy(bytecodeBuffer, FLAG_HASH_OFFSET);`, ' }',
`};`, ' const placeholder = tag + " ".repeat(length - tag.length);',
`function getSourceHashHeader(bytecodeBuffer) {`, ' const compiledWrapper = vm.compileFunction(placeholder, params, {',
` return bytecodeBuffer.slice(SOURCE_HASH_OFFSET, SOURCE_HASH_OFFSET + 4);`, ' filename,',
`};`, ' cachedData: data.subarray(28)',
`function buffer2Number(buffer) {`, ' });',
` let ret = 0;`, ' if (compiledWrapper.cachedDataRejected) {',
` ret |= buffer[3] << 24;`, ' throw new Error("Invalid or incompatible cached data (cachedDataRejected): " + filename);',
` ret |= buffer[2] << 16;`, ' }',
` ret |= buffer[1] << 8;`, ' const require = function (id) { return module.require(id); };',
` ret |= buffer[0];`, ' require.resolve = function (request, options) {',
` return ret;`, ' return Module._resolveFilename(request, module, false, options);',
`};`, ' };',
`Module._extensions[".jsc"] = Module._extensions[".cjsc"] = function (module, filename) {`, ' if (process.mainModule) require.main = process.mainModule;',
` const bytecodeBuffer = fs.readFileSync(filename);`, ' require.extensions = Module._extensions;',
` if (!Buffer.isBuffer(bytecodeBuffer)) {`, ' require.cache = Module._cache;',
` throw new Error("BytecodeBuffer must be a buffer object.");`, ' return compiledWrapper.call(module.exports, module.exports, require, module, filename, path.dirname(filename));',
` }`, '};'
` 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);`,
`};`
] ]
const bytecodeChunkExtensionRE = /.(jsc|cjsc)$/ const bytecodeChunkExtensionRE = /.(jsc|cjsc)$/
@ -190,6 +121,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
const bytecodeModuleLoader = 'bytecode-loader.cjs' const bytecodeModuleLoader = 'bytecode-loader.cjs'
let supported = false let supported = false
let isPreload = false
return { return {
name: 'vite:bytecode', name: 'vite:bytecode',
@ -199,7 +131,8 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
if (supported) { if (supported) {
return return
} }
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) { if (useInRenderer) {
config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.')) config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.'))
return return
@ -253,8 +186,14 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
const bundles = Object.keys(output) const bundles = Object.keys(output)
await Promise.all( const compiler = new ElectronCompiler({
bundles.map(async name => { path: getElectronPath(),
args: [getBytecodeCompilerPath()],
env: { ELECTRON_VITE_RENDERER: isPreload ? '1' : '0' }
})
try {
await compiler.start()
for (const name of bundles) {
const chunk = output[name] const chunk = output[name]
if (chunk.type === 'chunk') { if (chunk.type === 'chunk') {
let _code = chunk.code let _code = chunk.code
@ -274,7 +213,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
} }
} }
if (bytecodeChunks.includes(name)) { if (bytecodeChunks.includes(name)) {
const bytecodeBuffer = await compileToBytecode(_code) const bytecodeBuffer = await compileToBytecode(compiler, _code)
this.emitFile({ this.emitFile({
type: 'asset', type: 'asset',
fileName: name + 'c', fileName: name + 'c',
@ -312,18 +251,21 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
for (const importerId of dynamicImporters) idsToHandle.add(importerId) for (const importerId of dynamicImporters) idsToHandle.add(importerId)
} }
} }
_code = hasBytecodeMoudle if (hasBytecodeMoudle) {
? _code.replace( const loader = getBytecodeLoaderBlock(chunk.fileName)
/("use strict";)|('use strict';)/, const strictRE = /^(#![^\n]*\n)?(["'])use strict\2;/
`${useStrict}\n${getBytecodeLoaderBlock(chunk.fileName)}` _code = strictRE.test(_code)
) ? _code.replace(strictRE, `$&\n${loader}`)
: _code : _code.replace(/^(#![^\n]*\n)?/, `$&${loader}\n`)
}
} }
chunk.code = _code chunk.code = _code
} }
} }
}) }
) } finally {
await compiler.stop()
}
if (bytecodeChunkCount && !_chunks.some(ass => ass.type === 'asset' && ass.fileName === bytecodeModuleLoader)) { if (bytecodeChunkCount && !_chunks.some(ass => ass.type === 'asset' && ass.fileName === bytecodeModuleLoader)) {
this.emitFile({ this.emitFile({

View File

@ -0,0 +1,171 @@
import { type ChildProcess, spawn } from 'node:child_process'
export type ElectronCompilerOptions = {
path: string
args: string[]
env?: NodeJS.ProcessEnv
timeout?: number
shutdownTimeout?: number
}
export type BytecodeCompileResult = {
sourceLength: number
/** Base64-encoded V8 cachedData; decode with Buffer.from(cachedData, 'base64'). */
cachedData: string
}
type ElectronResponse = {
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 ElectronCompiler {
private ps?: ChildProcess
private rpcMap = new Map<number, PendingRequest>()
private invocationId = 0
private starting?: Promise<void>
private stopping?: Promise<void>
private ready = false
constructor(readonly options: ElectronCompilerOptions) {
for (const timeout of [options.timeout, options.shutdownTimeout]) {
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new RangeError('ElectronCompiler timeouts must be positive finite numbers')
}
}
}
start(): Promise<void> {
if (this.stopping) return Promise.reject(new Error('ElectronCompiler 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, ...this.options.env, ELECTRON_VITE_RPC: 'true' }
delete env.ELECTRON_RUN_AS_NODE
const ps = spawn(this.options.path, this.options.args, {
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
windowsHide: true,
env
})
this.ps = ps
ps.on('message', (message: ElectronResponse) => {
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(code: string): Promise<BytecodeCompileResult> {
return this.request<BytecodeCompileResult>('compile', code)
}
async request<T = unknown>(cmd: string, ...args: unknown[]): Promise<T> {
if (!this.ready || this.stopping) throw new Error('ElectronCompiler 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(error)
}
const timer = setTimeout(() => fail(new Error('Electron RPC timed out: ' + cmd)), this.options.timeout ?? 30000)
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
for (const pending of this.rpcMap.values()) {
clearTimeout(pending.timer)
pending.reject(error)
}
this.rpcMap.clear()
}
stop(): Promise<void> {
if (this.stopping) return this.stopping
this.rejectPending(new Error('ElectronCompiler stopped'))
const ps = this.ps
if (!ps) return Promise.resolve()
this.stopping = new Promise<void>(resolve => {
const timer = setTimeout(() => ps.kill('SIGKILL'), this.options.shutdownTimeout ?? 3000)
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
}
}