refactor!: invoke the bytecode compiler only once to compile all code

This commit is contained in:
alex8088 2026-10-07 13:36:10 +08:00
parent 4c4bdf5248
commit 36a0147a6d
5 changed files with 170 additions and 133 deletions

View File

@ -11,6 +11,7 @@ const channel = 'electron-vite:bytecode:rpc'
const readyChannel = 'electron-vite:bytecode:ready' const readyChannel = 'electron-vite:bytecode:ready'
let win let win
let rendererRPC let rendererRPC
let rendererStarting
let stopping = false let stopping = false
function fail(error) { function fail(error) {
@ -74,17 +75,18 @@ function createRenderer() {
}) })
} }
// Register readiness immediately, but only resolve it after the selected backend is usable. const initialized = app.whenReady().then(() => true)
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) 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) initialized.catch(fail)
process.on('message', message => { process.on('message', message => {

View File

@ -1,5 +1,13 @@
import { build as viteBuild } from 'vite' import { build as viteBuild } from 'vite'
import { type InlineConfig, resolveConfig } from './config' import { type InlineConfig, type MainViteConfig, type PreloadViteConfig, resolveConfig } from './config'
import { BytecodeBuild, runWithBytecodeBuild } from './bytecode/build'
import { asyncFlatten } from './utils'
async function useBytecode(config?: MainViteConfig | PreloadViteConfig): Promise<boolean> {
if (!config) return false
const plugins = await asyncFlatten(config.plugins || [])
return plugins.some(plugin => plugin && typeof plugin === 'object' && plugin.name === 'vite:bytecode')
}
/** /**
* Bundles the electron app for production. * Bundles the electron app for production.
@ -12,17 +20,30 @@ export async function build(inlineConfig: InlineConfig = {}): Promise<void> {
return return
} }
// Build targets in order: main -> preload -> renderer const mainBytecode = await useBytecode(config.config.main)
const buildTargets = ['main', 'preload', 'renderer'] as const const preloadBytecode = await useBytecode(config.config.preload)
const bytecodeBuild = mainBytecode || preloadBytecode ? new BytecodeBuild() : undefined
for (const target of buildTargets) { const buildTargets = async (): Promise<void> => {
const viteConfig = config.config[target] // Build targets in order: main -> preload -> renderer
if (viteConfig) { for (const target of ['main', 'preload', 'renderer'] as const) {
// Disable watch mode in production builds const viteConfig = config.config?.[target]
if (viteConfig.build?.watch) { if (viteConfig) {
viteConfig.build.watch = null // Disable watch mode in production builds
if (viteConfig.build?.watch) {
viteConfig.build.watch = null
}
await viteBuild(viteConfig)
} }
await viteBuild(viteConfig)
} }
} }
if (!bytecodeBuild) return buildTargets()
try {
await bytecodeBuild.start()
await runWithBytecodeBuild(bytecodeBuild, buildTargets)
} finally {
await bytecodeBuild.stop()
}
} }

29
src/bytecode/build.ts Normal file
View File

@ -0,0 +1,29 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { BytecodeCompiler, type BytecodeCompileResult, type BytecodeTarget } from './compiler'
// Nested isolated-entry builds inherit the compilation session from the parent build.
const activeBuild = new AsyncLocalStorage<BytecodeBuild>()
export class BytecodeBuild {
private readonly compiler = new BytecodeCompiler()
start(): Promise<void> {
return this.compiler.start()
}
compile(target: BytecodeTarget, code: string): Promise<BytecodeCompileResult> {
return this.compiler.compile(target, code)
}
stop(): Promise<void> {
return this.compiler.stop()
}
}
export function runWithBytecodeBuild<T>(build: BytecodeBuild, callback: () => Promise<T>): Promise<T> {
return activeBuild.run(build, callback)
}
export function getBytecodeBuild(): BytecodeBuild | undefined {
return activeBuild.getStore()
}

View File

@ -1,12 +1,11 @@
import { type ChildProcess, spawn } from 'node:child_process' import { type ChildProcess, spawn } from 'node:child_process'
import path from 'node:path'
import { createRequire } from 'node:module'
import { getElectronPath } from '../electron'
export type ElectronCompilerOptions = { const require = createRequire(import.meta.url)
path: string const RPC_TIMEOUT = 30_000
args: string[] const SHUTDOWN_TIMEOUT = 3_000
env?: NodeJS.ProcessEnv
timeout?: number
shutdownTimeout?: number
}
export type BytecodeCompileResult = { export type BytecodeCompileResult = {
sourceLength: number sourceLength: number
@ -14,7 +13,9 @@ export type BytecodeCompileResult = {
cachedData: string cachedData: string
} }
type ElectronResponse = { export type BytecodeTarget = 'main' | 'preload'
type CompilerResponse = {
type: 'response' type: 'response'
invocationId: number invocationId: number
result?: unknown result?: unknown
@ -27,7 +28,7 @@ type PendingRequest = {
timer: ReturnType<typeof setTimeout> timer: ReturnType<typeof setTimeout>
} }
export class ElectronCompiler { export class BytecodeCompiler {
private ps?: ChildProcess private ps?: ChildProcess
private rpcMap = new Map<number, PendingRequest>() private rpcMap = new Map<number, PendingRequest>()
private invocationId = 0 private invocationId = 0
@ -36,16 +37,8 @@ export class ElectronCompiler {
private ready = false private ready = false
private stderr = '' private stderr = ''
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> { start(): Promise<void> {
if (this.stopping) return Promise.reject(new Error('ElectronCompiler is stopping')) if (this.stopping) return Promise.reject(new Error('BytecodeCompiler is stopping'))
if (this.starting) return this.starting if (this.starting) return this.starting
if (this.ready) return Promise.resolve() if (this.ready) return Promise.resolve()
if (this.ps) return Promise.reject(new Error('Previous Electron process has not closed; await stop() first')) if (this.ps) return Promise.reject(new Error('Previous Electron process has not closed; await stop() first'))
@ -56,10 +49,15 @@ export class ElectronCompiler {
} }
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
const env: NodeJS.ProcessEnv = { ...process.env, ...this.options.env, ELECTRON_VITE_RPC: 'true' } const env: NodeJS.ProcessEnv = { ...process.env, ELECTRON_VITE_RPC: 'true' }
delete env.ELECTRON_RUN_AS_NODE delete env.ELECTRON_RUN_AS_NODE
this.stderr = '' this.stderr = ''
const ps = spawn(this.options.path, this.options.args, { 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'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
windowsHide: true, windowsHide: true,
env env
@ -71,7 +69,7 @@ export class ElectronCompiler {
if (this.ps === ps) this.stderr = (this.stderr + chunk).slice(-64 * 1024) if (this.ps === ps) this.stderr = (this.stderr + chunk).slice(-64 * 1024)
}) })
} }
ps.on('message', (message: ElectronResponse) => { ps.on('message', (message: CompilerResponse) => {
if (this.ps !== ps || !message || message.type !== 'response') return if (this.ps !== ps || !message || message.type !== 'response') return
const pending = this.rpcMap.get(message.invocationId) const pending = this.rpcMap.get(message.invocationId)
if (!pending) return if (!pending) return
@ -109,12 +107,12 @@ export class ElectronCompiler {
} }
} }
compile(code: string): Promise<BytecodeCompileResult> { compile(target: BytecodeTarget, code: string): Promise<BytecodeCompileResult> {
return this.request<BytecodeCompileResult>('compile', code) return this.request<BytecodeCompileResult>('compile', target, code)
} }
async request<T = unknown>(cmd: string, ...args: unknown[]): Promise<T> { 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') if (!this.ready || this.stopping) throw new Error('BytecodeCompiler is not ready; await start() first')
return this.sendRequest(cmd, args) as Promise<T> return this.sendRequest(cmd, args) as Promise<T>
} }
@ -130,7 +128,7 @@ export class ElectronCompiler {
this.rpcMap.delete(invocationId) this.rpcMap.delete(invocationId)
reject(this.withStderr(error)) reject(this.withStderr(error))
} }
const timer = setTimeout(() => fail(new Error('Electron RPC timed out: ' + cmd)), this.options.timeout ?? 30000) const timer = setTimeout(() => fail(new Error('Electron RPC timed out: ' + cmd)), RPC_TIMEOUT)
this.rpcMap.set(invocationId, { resolve, reject, timer }) this.rpcMap.set(invocationId, { resolve, reject, timer })
try { try {
ps.send({ type: 'request', invocationId, cmd, args }, error => { ps.send({ type: 'request', invocationId, cmd, args }, error => {
@ -164,11 +162,11 @@ export class ElectronCompiler {
stop(): Promise<void> { stop(): Promise<void> {
if (this.stopping) return this.stopping if (this.stopping) return this.stopping
this.rejectPending(new Error('ElectronCompiler stopped')) this.rejectPending(new Error('BytecodeCompiler stopped'))
const ps = this.ps const ps = this.ps
if (!ps) return Promise.resolve() if (!ps) return Promise.resolve()
this.stopping = new Promise<void>(resolve => { this.stopping = new Promise<void>(resolve => {
const timer = setTimeout(() => ps.kill('SIGKILL'), this.options.shutdownTimeout ?? 3000) const timer = setTimeout(() => ps.kill('SIGKILL'), SHUTDOWN_TIMEOUT)
ps.once('close', () => { ps.once('close', () => {
clearTimeout(timer) clearTimeout(timer)
resolve() resolve()

View File

@ -1,29 +1,22 @@
import path from 'node:path' import path from 'node:path'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
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'
import * as babel from '@babel/core' import * as babel from '@babel/core'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { getElectronPath } from '../electron'
import { toRelativePath } from '../utils' import { toRelativePath } from '../utils'
import { ElectronCompiler } from './electronCompiler' import { type BytecodeBuild, getBytecodeBuild } from '../bytecode/build'
import { type BytecodeTarget } from '../bytecode/compiler'
// Inspired by https://github.com/bytenode/bytenode // Inspired by https://github.com/bytenode/bytenode
const _require = createRequire(import.meta.url)
function getBytecodeCompilerPath(): string {
return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode-main.cjs')
}
// Versioned envelope: magic/version (8), UTF-16 source length (4), module ID (16), V8 cache. // 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. // Keep our metadata separate from the private V8 cache header.
async function compileToBytecode(compiler: ElectronCompiler, code: string): Promise<Buffer> { async function compileToBytecode(builder: BytecodeBuild, target: BytecodeTarget, code: string): Promise<Buffer> {
const id = randomBytes(16) const id = randomBytes(16)
// Reserve enough source space for an untruncated, per-artifact placeholder identity. // Reserve enough source space for an untruncated, per-artifact placeholder identity.
const source = code + '\n/*' + id.toString('hex') + '*/' const source = code + '\n/*' + id.toString('hex') + '*/'
const result = await compiler.compile(source) const result = await builder.compile(target, source)
const header = Buffer.alloc(28) const header = Buffer.alloc(28)
header.write('EVBC0001', 0, 'ascii') header.write('EVBC0001', 0, 'ascii')
header.writeUInt32LE(result.sourceLength, 8) header.writeUInt32LE(result.sourceLength, 8)
@ -189,85 +182,79 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
const bundles = Object.keys(output) const bundles = Object.keys(output)
const compiler = new ElectronCompiler({ const compiler = getBytecodeBuild()
path: getElectronPath(), if (!compiler) {
args: [getBytecodeCompilerPath()], throw new Error('Bytecode compilation requires an electron-vite build session')
env: { ELECTRON_VITE_RENDERER: isPreload ? '1' : '0' } }
}) for (const name of bundles) {
try { const chunk = output[name]
await compiler.start() if (chunk.type === 'chunk') {
for (const name of bundles) { let _code = chunk.code
const chunk = output[name] if (bytecodeRE) {
if (chunk.type === 'chunk') { let match: RegExpExecArray | null
let _code = chunk.code let s: MagicString | undefined
if (bytecodeRE) { while ((match = bytecodeRE.exec(_code))) {
let match: RegExpExecArray | null s ||= new MagicString(_code)
let s: MagicString | undefined const [prefix, chunkName] = match
while ((match = bytecodeRE.exec(_code))) { const len = prefix.length + chunkName.length
s ||= new MagicString(_code) s.overwrite(match.index, match.index + len, prefix + chunkName + 'c', {
const [prefix, chunkName] = match contentOnly: true
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(compiler, _code)
this.emitFile({
type: 'asset',
fileName: name + 'c',
source: bytecodeBuffer
}) })
if (!removeBundleJS) { }
this.emitFile({ if (s) {
type: 'asset', _code = s.toString()
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 (bytecodeChunks.includes(name)) {
const bytecodeBuffer = await compileToBytecode(compiler, 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
}
} }
} 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)) {