diff --git a/bin/electron-bytecode-main.cjs b/bin/electron-bytecode-main.cjs index 873f6ba..6962094 100644 --- a/bin/electron-bytecode-main.cjs +++ b/bin/electron-bytecode-main.cjs @@ -11,6 +11,7 @@ const channel = 'electron-vite:bytecode:rpc' const readyChannel = 'electron-vite:bytecode:ready' let win let rendererRPC +let rendererStarting let stopping = false 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(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 -}) +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 => { diff --git a/src/build.ts b/src/build.ts index af1a57d..e1f5165 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,5 +1,13 @@ 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 { + 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. @@ -12,17 +20,30 @@ export async function build(inlineConfig: InlineConfig = {}): Promise { return } - // Build targets in order: main -> preload -> renderer - const buildTargets = ['main', 'preload', 'renderer'] as const + const mainBytecode = await useBytecode(config.config.main) + const preloadBytecode = await useBytecode(config.config.preload) + const bytecodeBuild = mainBytecode || preloadBytecode ? new BytecodeBuild() : undefined - 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 => { + // 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 (!bytecodeBuild) return buildTargets() + + try { + await bytecodeBuild.start() + await runWithBytecodeBuild(bytecodeBuild, buildTargets) + } finally { + await bytecodeBuild.stop() + } } diff --git a/src/bytecode/build.ts b/src/bytecode/build.ts new file mode 100644 index 0000000..5ba65dd --- /dev/null +++ b/src/bytecode/build.ts @@ -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() + +export class BytecodeBuild { + private readonly compiler = new BytecodeCompiler() + + start(): Promise { + return this.compiler.start() + } + + compile(target: BytecodeTarget, code: string): Promise { + return this.compiler.compile(target, code) + } + + stop(): Promise { + return this.compiler.stop() + } +} + +export function runWithBytecodeBuild(build: BytecodeBuild, callback: () => Promise): Promise { + return activeBuild.run(build, callback) +} + +export function getBytecodeBuild(): BytecodeBuild | undefined { + return activeBuild.getStore() +} diff --git a/src/plugins/electronCompiler.ts b/src/bytecode/compiler.ts similarity index 80% rename from src/plugins/electronCompiler.ts rename to src/bytecode/compiler.ts index 764f454..a29efcf 100644 --- a/src/plugins/electronCompiler.ts +++ b/src/bytecode/compiler.ts @@ -1,12 +1,11 @@ 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 = { - path: string - args: string[] - env?: NodeJS.ProcessEnv - timeout?: number - shutdownTimeout?: number -} +const require = createRequire(import.meta.url) +const RPC_TIMEOUT = 30_000 +const SHUTDOWN_TIMEOUT = 3_000 export type BytecodeCompileResult = { sourceLength: number @@ -14,7 +13,9 @@ export type BytecodeCompileResult = { cachedData: string } -type ElectronResponse = { +export type BytecodeTarget = 'main' | 'preload' + +type CompilerResponse = { type: 'response' invocationId: number result?: unknown @@ -27,7 +28,7 @@ type PendingRequest = { timer: ReturnType } -export class ElectronCompiler { +export class BytecodeCompiler { private ps?: ChildProcess private rpcMap = new Map() private invocationId = 0 @@ -36,16 +37,8 @@ export class ElectronCompiler { private ready = false 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 { - 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.ready) return Promise.resolve() 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 { - 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 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'], windowsHide: true, env @@ -71,7 +69,7 @@ export class ElectronCompiler { 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 const pending = this.rpcMap.get(message.invocationId) if (!pending) return @@ -109,12 +107,12 @@ export class ElectronCompiler { } } - compile(code: string): Promise { - return this.request('compile', code) + compile(target: BytecodeTarget, code: string): Promise { + return this.request('compile', target, code) } async request(cmd: string, ...args: unknown[]): Promise { - 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 } @@ -130,7 +128,7 @@ export class ElectronCompiler { this.rpcMap.delete(invocationId) 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 }) try { ps.send({ type: 'request', invocationId, cmd, args }, error => { @@ -164,11 +162,11 @@ export class ElectronCompiler { stop(): Promise { if (this.stopping) return this.stopping - this.rejectPending(new Error('ElectronCompiler stopped')) + this.rejectPending(new Error('BytecodeCompiler stopped')) const ps = this.ps if (!ps) return Promise.resolve() this.stopping = new Promise(resolve => { - const timer = setTimeout(() => ps.kill('SIGKILL'), this.options.shutdownTimeout ?? 3000) + const timer = setTimeout(() => ps.kill('SIGKILL'), SHUTDOWN_TIMEOUT) ps.once('close', () => { clearTimeout(timer) resolve() diff --git a/src/plugins/bytecode.ts b/src/plugins/bytecode.ts index b8f388d..7222962 100644 --- a/src/plugins/bytecode.ts +++ b/src/plugins/bytecode.ts @@ -1,29 +1,22 @@ import path from 'node:path' import { randomBytes } from 'node:crypto' -import { createRequire } from 'node:module' import colors from 'picocolors' import { type Plugin, type LibraryOptions, type Rolldown, normalizePath } from 'vite' import * as babel from '@babel/core' import MagicString from 'magic-string' -import { getElectronPath } from '../electron' 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 -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. // Keep our metadata separate from the private V8 cache header. -async function compileToBytecode(compiler: ElectronCompiler, code: string): Promise { +async function compileToBytecode(builder: BytecodeBuild, target: BytecodeTarget, code: string): Promise { 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(source) + const result = await builder.compile(target, source) const header = Buffer.alloc(28) header.write('EVBC0001', 0, 'ascii') header.writeUInt32LE(result.sourceLength, 8) @@ -189,85 +182,79 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { const bundles = Object.keys(output) - const compiler = new ElectronCompiler({ - path: getElectronPath(), - args: [getBytecodeCompilerPath()], - env: { ELECTRON_VITE_RENDERER: isPreload ? '1' : '0' } - }) - try { - await compiler.start() - 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 (s) { - _code = s.toString() - } - } - if (bytecodeChunks.includes(name)) { - const bytecodeBuffer = await compileToBytecode(compiler, _code) - this.emitFile({ - type: 'asset', - fileName: name + 'c', - source: bytecodeBuffer + const compiler = getBytecodeBuild() + if (!compiler) { + throw new Error('Bytecode compilation requires an electron-vite build session') + } + 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) { - 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 (s) { + _code = s.toString() } } + 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)) {