From c1e1ca0471a2d4166ee773433eb414067f7ff902 Mon Sep 17 00:00:00 2001 From: alex8088 <244096523@qq.com> Date: Tue, 12 Dec 2023 22:40:37 +0800 Subject: [PATCH] feat: support ESM in Electron --- ...tron-bytecode.js => electron-bytecode.cjs} | 0 src/config.ts | 10 +- src/electron.ts | 5 + src/plugins/bytecode.ts | 25 ++- src/plugins/electron.ts | 193 ++++++++++++------ src/plugins/esm.ts | 66 ++++++ 6 files changed, 230 insertions(+), 69 deletions(-) rename bin/{electron-bytecode.js => electron-bytecode.cjs} (100%) create mode 100644 src/plugins/esm.ts diff --git a/bin/electron-bytecode.js b/bin/electron-bytecode.cjs similarity index 100% rename from bin/electron-bytecode.js rename to bin/electron-bytecode.cjs diff --git a/src/config.ts b/src/config.ts index 29bd9f2..967b222 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ import { build } from 'esbuild' import { electronMainVitePlugin, electronPreloadVitePlugin, electronRendererVitePlugin } from './plugins/electron' import assetPlugin from './plugins/asset' import workerPlugin from './plugins/worker' +import esmShimPlugin from './plugins/esm' import { isObject, dynamicImport } from './utils' export { defineConfig as defineViteConfig } from 'vite' @@ -131,7 +132,12 @@ export async function resolveConfig( resetOutDir(mainViteConfig, outDir, 'main') } - mergePlugins(mainViteConfig, [...electronMainVitePlugin({ root }), assetPlugin(), workerPlugin()]) + mergePlugins(mainViteConfig, [ + ...electronMainVitePlugin({ root }), + assetPlugin(), + workerPlugin(), + esmShimPlugin() + ]) loadResult.config.main = mainViteConfig loadResult.config.main.configFile = false @@ -143,7 +149,7 @@ export async function resolveConfig( if (outDir) { resetOutDir(preloadViteConfig, outDir, 'preload') } - mergePlugins(preloadViteConfig, [...electronPreloadVitePlugin({ root }), assetPlugin()]) + mergePlugins(preloadViteConfig, [...electronPreloadVitePlugin({ root }), assetPlugin(), esmShimPlugin()]) loadResult.config.preload = preloadViteConfig loadResult.config.preload.configFile = false diff --git a/src/electron.ts b/src/electron.ts index 030d762..7a34c97 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -36,6 +36,11 @@ const getElectronMajorVer = (): string => { return majorVer } +export function supportESM(): boolean { + const majorVer = getElectronMajorVer() + return parseInt(majorVer) >= 28 +} + export function getElectronPath(): string { let electronExecPath = process.env.ELECTRON_EXEC_PATH || '' if (!electronExecPath) { diff --git a/src/plugins/bytecode.ts b/src/plugins/bytecode.ts index 6884bf6..88ecf2e 100644 --- a/src/plugins/bytecode.ts +++ b/src/plugins/bytecode.ts @@ -15,7 +15,7 @@ import { toRelativePath } from '../utils' const _require = createRequire(import.meta.url) function getBytecodeCompilerPath(): string { - return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode.js') + return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode.cjs') } function compileToBytecode(code: string): Promise { @@ -98,7 +98,7 @@ const bytecodeModuleLoaderCode = [ ` ret |= buffer[0];`, ` return ret;`, `};`, - `Module._extensions[".jsc"] = function (module, filename) {`, + `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.");`, @@ -181,7 +181,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { } const useStrict = '"use strict";' - const bytecodeModuleLoader = 'bytecode-loader.js' + const bytecodeModuleLoader = 'bytecode-loader.cjs' let config: ResolvedConfig let useInRenderer = false @@ -196,7 +196,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { config = resolvedConfig useInRenderer = config.plugins.some(p => p.name === 'vite:electron-renderer-preset-config') if (useInRenderer) { - config.logger.warn(colors.yellow('bytecodePlugin is not support renderers')) + config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.')) } }, transform(code, id): void | { code: string; map: SourceMapInput } { @@ -226,7 +226,16 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { } } }, - renderChunk(code, chunk): { code: string } | null { + renderChunk(code, chunk, options): { code: string } | null { + if (options.format === 'es') { + config.logger.warn( + colors.yellow( + 'bytecodePlugin does not support ES module, please remove "type": "module" ' + + 'in package.json or set the "build.rollupOptions.output.format" option to "cjs".' + ) + ) + return null + } if (useInRenderer) { return null } @@ -240,8 +249,8 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { } return null }, - generateBundle(): void { - if (!useInRenderer && bytecodeRequired) { + generateBundle(options): void { + if (options.format !== 'es' && !useInRenderer && bytecodeRequired) { this.emitFile({ type: 'asset', source: bytecodeModuleLoaderCode.join('\n') + '\n', @@ -251,7 +260,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null { } }, async writeBundle(options, output): Promise { - if (useInRenderer || !bytecodeRequired) { + if (options.format === 'es' || useInRenderer || !bytecodeRequired) { return } diff --git a/src/plugins/electron.ts b/src/plugins/electron.ts index b5accf0..407d985 100644 --- a/src/plugins/electron.ts +++ b/src/plugins/electron.ts @@ -2,8 +2,10 @@ import path from 'node:path' import fs from 'node:fs' import { builtinModules } from 'node:module' import colors from 'picocolors' -import { type Plugin, mergeConfig, normalizePath } from 'vite' -import { getElectronNodeTarget, getElectronChromeTarget } from '../electron' +import { type Plugin, type LibraryOptions, mergeConfig, normalizePath } from 'vite' +import type { OutputOptions } from 'rollup' +import { getElectronNodeTarget, getElectronChromeTarget, supportESM } from '../electron' +import { loadPackageData } from '../utils' export interface ElectronPluginOptions { root?: string @@ -37,6 +39,17 @@ function processEnvDefine(): Record { } } +function resolveBuildOutputs( + outputs: OutputOptions | OutputOptions[] | undefined, + libOptions: LibraryOptions | false +): OutputOptions | OutputOptions[] | undefined { + if (libOptions && !Array.isArray(outputs)) { + const libFormats = libOptions.formats || [] + return libFormats.map(format => ({ ...outputs, format })) + } + return outputs +} + export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[] { return [ { @@ -48,6 +61,10 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ const nodeTarget = getElectronNodeTarget() + const pkg = loadPackageData() || { type: 'commonjs' } + + const format = pkg.type && pkg.type === 'module' && supportESM() ? 'es' : 'cjs' + const defaultConfig = { resolve: { browserField: false, @@ -60,9 +77,7 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ assetsDir: 'chunks', rollupOptions: { external: ['electron', ...builtinModules.flatMap(m => [m, `node:${m}`])], - output: { - entryFileNames: '[name].js' - } + output: {} }, reportCompressedSize: false, minify: false @@ -72,12 +87,21 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ const build = config.build || {} const rollupOptions = build.rollupOptions || {} if (!rollupOptions.input) { + const libOptions = build.lib + const outputOptions = rollupOptions.output defaultConfig.build['lib'] = { entry: findLibEntry(root, 'main'), - formats: ['cjs'] + formats: + libOptions && libOptions.formats && libOptions.formats.length > 0 + ? [] + : [ + outputOptions && !Array.isArray(outputOptions) && outputOptions.format + ? outputOptions.format + : format + ] } } else { - defaultConfig.build.rollupOptions.output['format'] = 'cjs' + defaultConfig.build.rollupOptions.output['format'] = format } defaultConfig.build.rollupOptions.output['assetFileNames'] = path.posix.join( @@ -100,6 +124,8 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ config.build.copyPublicDir = false // module preload polyfill does not apply to nodejs (main process) config.build.modulePreload = false + // enable ssr build + config.build.ssr = true } }, { @@ -109,37 +135,45 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ configResolved(config): void { const build = config.build if (!build.target) { - throw new Error('build target required for the electron vite main config') + throw new Error('build.target option is required in the electron vite main config.') } else { const targets = Array.isArray(build.target) ? build.target : [build.target] if (targets.some(t => !t.startsWith('node'))) { - throw new Error('the electron vite main config build target must be node') + throw new Error('The electron vite main config build.target option must be "node?".') } } - const lib = build.lib - if (!lib) { - const rollupOptions = build.rollupOptions - if (!rollupOptions?.input) { - throw new Error('build lib field required for the electron vite main config') + const libOptions = build.lib + const rollupOptions = build.rollupOptions + + if (!(libOptions && libOptions.entry) && !rollupOptions?.input) { + throw new Error( + 'An entry point is required in the electron vite main config, ' + + 'which can be specified using "build.lib.entry" or "build.rollupOptions.input".' + ) + } + + const resolvedOutputs = resolveBuildOutputs(rollupOptions.output, libOptions) + + if (resolvedOutputs) { + const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs] + if (outputs.length > 1) { + throw new Error('The electron vite main config does not support multiple outputs.') } else { - const output = rollupOptions?.output - if (output) { - const formats = Array.isArray(output) ? output : [output] - if (formats.some(f => f.format !== 'cjs')) { - throw new Error('the electron vite main config output format must be cjs') + const outpout = outputs[0] + if (['es', 'cjs'].includes(outpout.format || '')) { + if (outpout.format === 'es' && !supportESM()) { + throw new Error( + 'The electron vite main config output format does not support "es", ' + + 'you can upgrade electron to the latest version or switch to "cjs" format.' + ) } + } else { + throw new Error( + `The electron vite main config output format must be "cjs"${supportESM() ? ' or "es"' : ''}.` + ) } } - } else { - if (!lib.entry) { - throw new Error('build entry field required for the electron vite main config') - } - if (!lib.formats) { - throw new Error('build format field required for the electron vite main config') - } else if (!lib.formats.includes('cjs')) { - throw new Error('the electron vite main config build lib format must be cjs') - } } } } @@ -157,6 +191,10 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug const nodeTarget = getElectronNodeTarget() + const pkg = loadPackageData() || { type: 'commonjs' } + + const format = pkg.type && pkg.type === 'module' && supportESM() ? 'es' : 'cjs' + const defaultConfig = { build: { outDir: path.resolve(root, 'out', 'preload'), @@ -164,9 +202,7 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug assetsDir: 'chunks', rollupOptions: { external: ['electron', ...builtinModules.flatMap(m => [m, `node:${m}`])], - output: { - entryFileNames: '[name].js' - } + output: {} }, reportCompressedSize: false, minify: false @@ -176,12 +212,21 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug const build = config.build || {} const rollupOptions = build.rollupOptions || {} if (!rollupOptions.input) { + const libOptions = build.lib + const outputOptions = rollupOptions.output defaultConfig.build['lib'] = { entry: findLibEntry(root, 'preload'), - formats: ['cjs'] + formats: + libOptions && libOptions.formats && libOptions.formats.length > 0 + ? [] + : [ + outputOptions && !Array.isArray(outputOptions) && outputOptions.format + ? outputOptions.format + : format + ] } } else { - defaultConfig.build.rollupOptions.output['format'] = 'cjs' + defaultConfig.build.rollupOptions.output['format'] = format } defaultConfig.build.rollupOptions.output['assetFileNames'] = path.posix.join( @@ -192,6 +237,26 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug const buildConfig = mergeConfig(defaultConfig.build, build) config.build = buildConfig + const resolvedOutputs = resolveBuildOutputs(config.build.rollupOptions!.output, config.build.lib || false) + + if (resolvedOutputs) { + const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs] + + if (outputs.find(({ format }) => format === 'es')) { + if (Array.isArray(config.build.rollupOptions!.output)) { + config.build.rollupOptions!.output.forEach(output => { + if (output.format === 'es') { + output['entryFileNames'] = '[name].mjs' + output['chunkFileNames'] = '[name]-[hash].mjs' + } + }) + } else { + config.build.rollupOptions!.output!['entryFileNames'] = '[name].mjs' + config.build.rollupOptions!.output!['chunkFileNames'] = '[name]-[hash].mjs' + } + } + } + config.define = config.define || {} config.define = { ...processEnvDefine(), ...config.define } @@ -202,6 +267,8 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug config.build.copyPublicDir = false // module preload polyfill does not apply to nodejs (preload scripts) config.build.modulePreload = false + // enable ssr build + config.build.ssr = true } }, { @@ -211,37 +278,45 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug configResolved(config): void { const build = config.build if (!build.target) { - throw new Error('build target required for the electron vite preload config') + throw new Error('build.target option is required in the electron vite preload config.') } else { const targets = Array.isArray(build.target) ? build.target : [build.target] if (targets.some(t => !t.startsWith('node'))) { - throw new Error('the electron vite preload config build target must be node') + throw new Error('The electron vite preload config build.target must be "node?".') } } - const lib = build.lib - if (!lib) { - const rollupOptions = build.rollupOptions - if (!rollupOptions?.input) { - throw new Error('build lib field required for the electron vite preload config') + const libOptions = build.lib + const rollupOptions = build.rollupOptions + + if (!(libOptions && libOptions.entry) && !rollupOptions?.input) { + throw new Error( + 'An entry point is required in the electron vite preload config, ' + + 'which can be specified using "build.lib.entry" or "build.rollupOptions.input".' + ) + } + + const resolvedOutputs = resolveBuildOutputs(rollupOptions.output, libOptions) + + if (resolvedOutputs) { + const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs] + if (outputs.length > 1) { + throw new Error('The electron vite preload config does not support multiple outputs.') } else { - const output = rollupOptions?.output - if (output) { - const formats = Array.isArray(output) ? output : [output] - if (formats.some(f => f.format !== 'cjs')) { - throw new Error('the electron vite preload config output format must be cjs') + const outpout = outputs[0] + if (['es', 'cjs'].includes(outpout.format || '')) { + if (outpout.format === 'es' && !supportESM()) { + throw new Error( + 'The electron vite preload config output format does not support "es", ' + + 'you can upgrade electron to the latest version or switch to "cjs" format.' + ) } + } else { + throw new Error( + `The electron vite preload config output format must be "cjs"${supportESM() ? ' or "es"' : ''}.` + ) } } - } else { - if (!lib.entry) { - throw new Error('build entry field required for the electron vite preload config') - } - if (!lib.formats) { - throw new Error('build format field required for the electron vite preload config') - } else if (!lib.formats.includes('cjs')) { - throw new Error('the electron vite preload config lib format must be cjs') - } } } } @@ -305,23 +380,23 @@ export function electronRendererVitePlugin(options?: ElectronPluginOptions): Plu enforce: 'post', configResolved(config): void { if (config.base !== './' && config.base !== '/') { - config.logger.warn(colors.yellow('should not set base field for the electron vite renderer config')) + config.logger.warn(colors.yellow('(!) Should not set "base" option for the electron vite renderer config.')) } const build = config.build if (!build.target) { - throw new Error('build target required for the electron vite renderer config') + throw new Error('build.target option is required in the electron vite renderer config.') } else { const targets = Array.isArray(build.target) ? build.target : [build.target] if (targets.some(t => !t.startsWith('chrome') && !/^es((202\d{1})|next)$/.test(t))) { - throw new Error('the electron vite renderer config build target must be chrome? or es?') + throw new Error('The electron vite renderer config build.target must be "chrome?" or "es?".') } } const rollupOptions = build.rollupOptions if (!rollupOptions.input) { - config.logger.warn(colors.yellow(`index.html file is not found in ${colors.dim('/src/renderer')} directory`)) - throw new Error('build rollupOptions input field required for the electron vite renderer config') + config.logger.warn(colors.yellow(`index.html file is not found in ${colors.dim('/src/renderer')} directory.`)) + throw new Error('build.rollupOptions.input option is required in the electron vite renderer config.') } } } diff --git a/src/plugins/esm.ts b/src/plugins/esm.ts new file mode 100644 index 0000000..887909f --- /dev/null +++ b/src/plugins/esm.ts @@ -0,0 +1,66 @@ +/* + * The core of this plugin was conceived by pi0 and is taken from the following repository: + * https://github.com/unjs/unbuild/blob/main/src/builder/plugins/cjs.ts + * license: https://github.com/unjs/unbuild/blob/main/LICENSE + */ + +import MagicString from 'magic-string' +import type { SourceMapInput } from 'rollup' +import type { Plugin } from 'vite' + +const CJSyntaxRe = /__filename|__dirname|require\(|require\.resolve\(/ + +const CJSShim = ` +// -- CommonJS Shims -- +import __cjs_url__ from 'node:url'; +import __cjs_path__ from 'node:path'; +import __cjs_mod__ from 'node:module'; +const __filename = __cjs_url__.fileURLToPath(import.meta.url); +const __dirname = __cjs_path__.dirname(__filename); +const require = __cjs_mod__.createRequire(import.meta.url); +` + +const ESMStaticImportRe = + /(?<=\s|^|;)import\s*([\s"']*(?[\p{L}\p{M}\w\t\n\r $*,/{}@.]+)from\s*)?["']\s*(?(?<="\s*)[^"]*[^\s"](?=\s*")|(?<='\s*)[^']*[^\s'](?=\s*'))\s*["'][\s;]*/gmu + +interface StaticImport { + end: number +} + +function findStaticImports(code: string): StaticImport[] { + const matches: StaticImport[] = [] + for (const match of code.matchAll(ESMStaticImportRe)) { + matches.push({ end: (match.index || 0) + match[0].length }) + } + return matches +} + +export default function esmShimPlugin(): Plugin { + let sourcemap: boolean | 'inline' | 'hidden' = false + return { + name: 'vite:esm-shim', + apply: 'build', + enforce: 'post', + configResolved(config): void { + sourcemap = config.build.sourcemap + }, + renderChunk(code, _chunk, options): { code: string; map?: SourceMapInput } | null { + if (options.format === 'es') { + if (code.includes(CJSShim) || !CJSyntaxRe.test(code)) { + return null + } + + const lastESMImport = findStaticImports(code).pop() + const indexToAppend = lastESMImport ? lastESMImport.end : 0 + const s = new MagicString(code) + s.appendRight(indexToAppend, CJSShim) + return { + code: s.toString(), + map: sourcemap ? s.generateMap({ hires: true }) : null + } + } + + return null + } + } +}