mirror of
https://github.com/alex8088/electron-vite.git
synced 2026-11-02 14:51:51 +08:00
refactor: compile bytecode using the Electron main process or renderer process rather then via the ELECTRON_RUN_AS_NODE
This commit is contained in:
parent
ac2cdd1a21
commit
e691efbd82
15
bin/bytecode-compiler.cjs
Normal file
15
bin/bytecode-compiler.cjs
Normal 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') }
|
||||
}
|
||||
107
bin/electron-bytecode-main.cjs
Normal file
107
bin/electron-bytecode-main.cjs
Normal 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'))
|
||||
10
bin/electron-bytecode-preload.cjs
Normal file
10
bin/electron-bytecode-preload.cjs
Normal 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
89
bin/rpc-peer.cjs
Normal 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
|
||||
@ -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<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: ElectronCompiler, 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(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({
|
||||
|
||||
171
src/plugins/electronCompiler.ts
Normal file
171
src/plugins/electronCompiler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user