diff --git a/bin/bytecode-compiler.cjs b/bin/bytecode-compiler.cjs new file mode 100644 index 0000000..3ede42a --- /dev/null +++ b/bin/bytecode-compiler.cjs @@ -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') } +} diff --git a/bin/electron-bytecode-main.cjs b/bin/electron-bytecode-main.cjs new file mode 100644 index 0000000..873f6ba --- /dev/null +++ b/bin/electron-bytecode-main.cjs @@ -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,').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')) diff --git a/bin/electron-bytecode-preload.cjs b/bin/electron-bytecode-preload.cjs new file mode 100644 index 0000000..de56fe0 --- /dev/null +++ b/bin/electron-bytecode-preload.cjs @@ -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') diff --git a/bin/rpc-peer.cjs b/bin/rpc-peer.cjs new file mode 100644 index 0000000..b4cdf73 --- /dev/null +++ b/bin/rpc-peer.cjs @@ -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 diff --git a/src/plugins/bytecode.ts b/src/plugins/bytecode.ts index 31d864b..287052e 100644 --- a/src/plugins/bytecode.ts +++ b/src/plugins/bytecode.ts @@ -1,5 +1,5 @@ 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 LibraryOptions, type Rolldown, normalizePath } from 'vite' @@ -7,136 +7,67 @@ import * as babel from '@babel/core' import MagicString from 'magic-string' import { getElectronPath } from '../electron' import { toRelativePath } from '../utils' +import { ElectronCompiler } from './electronCompiler' // 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.cjs') + return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode-main.cjs') } -function compileToBytecode(code: string): Promise { - 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: ElectronCompiler, 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 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)$/ @@ -190,6 +121,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { const bytecodeModuleLoader = 'bytecode-loader.cjs' let supported = false + let isPreload = false return { name: 'vite:bytecode', @@ -199,7 +131,8 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { if (supported) { 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) { config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.')) return @@ -253,8 +186,14 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { const bundles = Object.keys(output) - await Promise.all( - bundles.map(async name => { + 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 @@ -274,7 +213,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { } } if (bytecodeChunks.includes(name)) { - const bytecodeBuffer = await compileToBytecode(_code) + const bytecodeBuffer = await compileToBytecode(compiler, _code) this.emitFile({ type: 'asset', fileName: name + 'c', @@ -312,18 +251,21 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { for (const importerId of dynamicImporters) idsToHandle.add(importerId) } } - _code = hasBytecodeMoudle - ? _code.replace( - /("use strict";)|('use strict';)/, - `${useStrict}\n${getBytecodeLoaderBlock(chunk.fileName)}` - ) - : _code + 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)) { this.emitFile({ diff --git a/src/plugins/electronCompiler.ts b/src/plugins/electronCompiler.ts new file mode 100644 index 0000000..4cd36d4 --- /dev/null +++ b/src/plugins/electronCompiler.ts @@ -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 +} + +export class ElectronCompiler { + private ps?: ChildProcess + private rpcMap = new Map() + private invocationId = 0 + private starting?: Promise + private stopping?: Promise + 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 { + 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 { + 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 { + return this.request('compile', code) + } + + async request(cmd: string, ...args: unknown[]): Promise { + if (!this.ready || this.stopping) throw new Error('ElectronCompiler is not ready; await start() first') + return this.sendRequest(cmd, args) as Promise + } + + private sendRequest(cmd: string, args: unknown[]): Promise { + 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 { + 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(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 + } +}