From bd0d8024578f66c1a0f23e5936ac78e1c4e00e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8D=89=E9=9E=8B=E6=B2=A1=E5=8F=B7?= <308487730@qq.com> Date: Sat, 1 Apr 2023 18:20:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin-vite):=20hot-restart=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/plugin/vite/src/Config.ts | 24 +++++ packages/plugin/vite/src/ViteConfig.ts | 47 +++++++--- packages/plugin/vite/src/VitePlugin.ts | 48 ++++++---- packages/plugin/vite/src/util/plugins.ts | 47 ++++++++++ packages/plugin/vite/test/ViteConfig_spec.ts | 25 ++--- .../plugin/vite/test/fixture/lib-entry.ts | 3 + .../plugin/vite/test/util/plugins_spec.ts | 92 +++++++++++++++++-- 7 files changed, 238 insertions(+), 48 deletions(-) create mode 100644 packages/plugin/vite/test/fixture/lib-entry.ts diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index 2f1ffd3601..e05abc957f 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -9,6 +9,30 @@ export interface VitePluginBuildConfig { * Vite config file path. */ config?: string; + /** + * By default, when any entry in `build` is rebuilt it will restart the Electron App. + * If you want to customize this behavior, you can pass a function and control it with the `args.restart` provided by the function. + */ + restart?: + | false + | ((args: { + /** + * Restart the entire Electron App. + */ + restart: () => void; + /** + * When a Preload script is rebuilt, users can refresh the Renderer process by `ViteDevServer` instead of restart the entire Electron App. + * + * @example + * ```ts + * renderer.find(({ config }) => config.name === 'main_window').reload(); + * ``` + */ + renderer: { + config: VitePluginRendererConfig; + reload: () => void; + }[]; + }) => void); } export interface VitePluginRendererConfig { diff --git a/packages/plugin/vite/src/ViteConfig.ts b/packages/plugin/vite/src/ViteConfig.ts index 7a65300602..3ba9f8a1a1 100644 --- a/packages/plugin/vite/src/ViteConfig.ts +++ b/packages/plugin/vite/src/ViteConfig.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import debug from 'debug'; import { ConfigEnv, loadConfigFromFile, mergeConfig, UserConfig } from 'vite'; -import { VitePluginConfig } from './Config'; +import { VitePluginBuildConfig, VitePluginConfig, VitePluginRendererConfig } from './Config'; import { externalBuiltins } from './util/plugins'; const d = debug('electron-forge:plugin:vite:viteconfig'); @@ -14,10 +14,20 @@ const d = debug('electron-forge:plugin:vite:viteconfig'); */ export type LoadResult = Awaited>; +export interface BuildConfig { + config: VitePluginBuildConfig; + vite: UserConfig; +} + +export interface RendererConfig { + config: VitePluginRendererConfig; + vite: UserConfig; +} + export default class ViteConfigGenerator { private readonly baseDir: string; - private rendererConfigCache!: Promise[]; + private rendererConfigCache!: Promise[]; constructor(private readonly pluginConfig: VitePluginConfig, private readonly projectDir: string, private readonly isProd: boolean) { this.baseDir = path.join(projectDir, '.vite'); @@ -43,20 +53,21 @@ export default class ViteConfigGenerator { async getDefines(): Promise> { const defines: Record = {}; const rendererConfigs = await this.getRendererConfig(); - for (const [index, userConfig] of rendererConfigs.entries()) { + for (const [index, { vite: viteConfig }] of rendererConfigs.entries()) { const name = this.pluginConfig.renderer[index].name; if (!name) { continue; } const NAME = name.toUpperCase().replace(/ /g, '_'); + // 🎯-①: // `server.port` is set in `launchRendererDevServers` in `VitePlugin.ts`. - defines[`${NAME}_VITE_DEV_SERVER_URL`] = this.isProd ? undefined : JSON.stringify(`http://localhost:${userConfig?.server?.port}`); + defines[`${NAME}_VITE_DEV_SERVER_URL`] = this.isProd ? undefined : JSON.stringify(`http://localhost:${viteConfig?.server?.port}`); defines[`${NAME}_VITE_NAME`] = JSON.stringify(name); } return defines; } - async getBuildConfig(watch = false): Promise { + async getBuildConfig(watch = false): Promise { if (!Array.isArray(this.pluginConfig.build)) { throw new Error('"config.build" must be an Array'); } @@ -65,8 +76,9 @@ export default class ViteConfigGenerator { const plugins = [externalBuiltins()]; const configs = this.pluginConfig.build .filter(({ entry, config }) => entry || config) - .map>(async ({ entry, config }) => { - const defaultConfig: UserConfig = { + .map>(async (buildConfig) => { + const { entry, config } = buildConfig; + let viteConfig: UserConfig = { // Ensure that each build config loads the .env file correctly. mode: this.mode, build: { @@ -90,21 +102,25 @@ export default class ViteConfigGenerator { }; if (config) { const loadResult = await this.resolveConfig(config); - return mergeConfig(defaultConfig, loadResult?.config ?? {}); + viteConfig = mergeConfig(viteConfig, loadResult?.config ?? {}); } - return defaultConfig; + return { + config: buildConfig, + vite: viteConfig, + }; }); return await Promise.all(configs); } - async getRendererConfig(): Promise { + async getRendererConfig(): Promise { if (!Array.isArray(this.pluginConfig.renderer)) { throw new Error('"config.renderer" must be an Array'); } - const configs = (this.rendererConfigCache ??= this.pluginConfig.renderer.map(async ({ name, config }) => { - const defaultConfig: UserConfig = { + const configs = (this.rendererConfigCache ??= this.pluginConfig.renderer.map(async (rendererConfig) => { + const { name, config } = rendererConfig; + let viteConfig: UserConfig = { // Ensure that each build config loads the .env file correctly. mode: this.mode, // Make sure that Electron can be loaded into the local file using `loadFile` after packaging. @@ -115,7 +131,12 @@ export default class ViteConfigGenerator { clearScreen: false, }; const loadResult = (await this.resolveConfig(config)) ?? { path: '', config: {}, dependencies: [] }; - return mergeConfig(defaultConfig, loadResult.config); + viteConfig = mergeConfig(viteConfig, loadResult.config); + + return { + config: rendererConfig, + vite: viteConfig, + }; })); return await Promise.all(configs); diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 0625915855..4f7a370875 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -9,11 +9,20 @@ import debug from 'debug'; import { RollupWatcher } from 'rollup'; import { default as vite } from 'vite'; -import { VitePluginConfig } from './Config'; +import { VitePluginConfig, VitePluginRendererConfig } from './Config'; +import { hotRestart } from './util/plugins'; import ViteConfigGenerator from './ViteConfig'; const d = debug('electron-forge:plugin:vite'); +// 🎯-②: +// Make sure the `server` is bound to the `renderer` in forge.config.ts, +// It's used to refresh the Renderer process, which one can be determined according to `config`. +export interface RendererConfigWithServer { + config: VitePluginRendererConfig; + server: vite.ViteDevServer; +} + export default class VitePlugin extends PluginBase { private static alreadyStarted = false; @@ -31,7 +40,7 @@ export default class VitePlugin extends PluginBase { private watchers: RollupWatcher[] = []; - private servers: vite.ViteDevServer[] = []; + public renderers: RendererConfigWithServer[] = []; init = (dir: string): void => { this.setDirectories(dir); @@ -105,26 +114,27 @@ export default class VitePlugin extends PluginBase { // Main process, Preload scripts and Worker process, etc. build = async (watch = false): Promise => { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; await Promise.all( ( await this.configGenerator.getBuildConfig(watch) - ).map((userConfig) => { + ).map(({ config, vite: viteConfig }) => { return new Promise((resolve, reject) => { vite .build({ // Avoid recursive builds caused by users configuring @electron-forge/plugin-vite in Vite config file. configFile: false, - ...userConfig, + ...viteConfig, plugins: [ { name: '@electron-forge/plugin-vite:start', closeBundle() { resolve(); - - // TODO: implement hot-restart here }, }, - ...(userConfig.plugins ?? []), + ...(this.isProd ? [hotRestart(config, that)] : []), + ...(viteConfig.plugins ?? []), ], }) .then((result) => { @@ -144,34 +154,38 @@ export default class VitePlugin extends PluginBase { // Renderer process buildRenderer = async (): Promise => { - for (const userConfig of await this.configGenerator.getRendererConfig()) { + for (const { vite: viteConfig } of await this.configGenerator.getRendererConfig()) { await vite.build({ configFile: false, - ...userConfig, + ...viteConfig, }); } }; launchRendererDevServers = async (): Promise => { - for (const userConfig of await this.configGenerator.getRendererConfig()) { + for (const { config, vite: viteConfig } of await this.configGenerator.getRendererConfig()) { const viteDevServer = await vite.createServer({ configFile: false, - ...userConfig, + ...viteConfig, }); await viteDevServer.listen(); viteDevServer.printUrls(); - this.servers.push(viteDevServer); + this.renderers.push({ + config, + server: viteDevServer, + }); if (viteDevServer.httpServer) { + // 🎯-①: // Make suee that `getDefines` in VitePlugin.ts gets the correct `server.port`. (#3198) const addressInfo = viteDevServer.httpServer.address(); const isAddressInfo = (x: any): x is AddressInfo => x?.address; if (isAddressInfo(addressInfo)) { - userConfig.server ??= {}; - userConfig.server.port = addressInfo.port; + viteConfig.server ??= {}; + viteConfig.server.port = addressInfo.port; } } } @@ -186,11 +200,11 @@ export default class VitePlugin extends PluginBase { } this.watchers = []; - for (const server of this.servers) { + for (const renderer of this.renderers) { d('cleaning http server'); - server.close(); + renderer.server.close(); } - this.servers = []; + this.renderers = []; } if (err) console.error(err.stack); // Why: This is literally what the option says to do. diff --git a/packages/plugin/vite/src/util/plugins.ts b/packages/plugin/vite/src/util/plugins.ts index fb5df3db1b..c1f4531914 100644 --- a/packages/plugin/vite/src/util/plugins.ts +++ b/packages/plugin/vite/src/util/plugins.ts @@ -1,5 +1,7 @@ import { builtinModules } from 'node:module'; +import type { VitePluginBuildConfig } from '../Config'; +import type { RendererConfigWithServer } from '../VitePlugin'; import type { Plugin } from 'vite'; /** @@ -33,3 +35,48 @@ export function externalBuiltins() { }, }; } + +/** + * Hot restart App during development for better DX. + */ +export function hotRestart( + config: VitePluginBuildConfig, + // For `VitePluginBuildConfig['restart']` callback args. + context: { renderers: RendererConfigWithServer[] } +) { + const restart = () => { + // https://github.com/electron/forge/blob/v6.1.1/packages/api/core/src/api/start.ts#L204-L211 + process.stdin.emit('data', 'rs'); + }; + // Avoid first start, it's stated by forge. + let isFirstStart: undefined | true; + + return { + name: '@electron-forge/plugin-vite:hot-restart', + closeBundle() { + if (isFirstStart == null) { + isFirstStart = true; + return; + } + if (config.restart === false) { + return; + } + if (typeof config.restart === 'function') { + // Leave it to the user to decide whether to restart. + config.restart({ + restart, + renderer: context.renderers.map((renderer) => ({ + // 🎯-②: + // Users can decide which Renderer process to refresh according to `config`. + config: renderer.config, + reload() { + renderer.server.ws.send({ type: 'full-reload' }); + }, + })), + }); + } else { + restart(); + } + }, + }; +} diff --git a/packages/plugin/vite/test/ViteConfig_spec.ts b/packages/plugin/vite/test/ViteConfig_spec.ts index 87d188342d..0e28b87354 100644 --- a/packages/plugin/vite/test/ViteConfig_spec.ts +++ b/packages/plugin/vite/test/ViteConfig_spec.ts @@ -15,20 +15,23 @@ describe('ViteConfigGenerator', () => { const generator = new ViteConfigGenerator(config, '', false); const servers: ViteDevServer[] = []; - for (const userConfig of await generator.getRendererConfig()) { + for (const { vite: viteConfig } of await generator.getRendererConfig()) { const viteDevServer = await vite.createServer({ configFile: false, - ...userConfig, + optimizeDeps: { + disabled: true, + }, + ...viteConfig, }); await viteDevServer.listen(); - viteDevServer.printUrls(); + // viteDevServer.printUrls(); servers.push(viteDevServer); // Make suee that `getDefines` in VitePlugin.ts gets the correct `server.port`. (#3198) const addressInfo = viteDevServer.httpServer!.address() as AddressInfo; - userConfig.server ??= {}; - userConfig.server.port = addressInfo.port; + viteConfig.server ??= {}; + viteConfig.server.port = addressInfo.port; } const define = await generator.getDefines(); @@ -50,15 +53,15 @@ describe('ViteConfigGenerator', () => { renderer: [], } as VitePluginConfig; const generator = new ViteConfigGenerator(config, '', true); - const buildConfig = (await generator.getBuildConfig())[0]; - expect(buildConfig).deep.equal({ + const { vite: viteConfig } = (await generator.getBuildConfig())[0]; + expect(viteConfig).deep.equal({ mode: 'production', build: { lib: { entry: 'foo.js', formats: ['cjs'], // shims - fileName: (buildConfig.build?.lib as any)?.fileName, + fileName: (viteConfig.build?.lib as any)?.fileName, }, emptyOutDir: false, outDir: path.join('.vite', 'build'), @@ -67,7 +70,7 @@ describe('ViteConfigGenerator', () => { clearScreen: false, define: {}, // shims - plugins: [buildConfig.plugins?.[0]], + plugins: viteConfig.plugins, } as UserConfig); }); @@ -77,8 +80,8 @@ describe('ViteConfigGenerator', () => { } as VitePluginConfig; const generator = new ViteConfigGenerator(config, '', false); const configs = await generator.getRendererConfig(); - for (const [index, rendererConfig] of configs.entries()) { - expect(rendererConfig).deep.equal({ + for (const [index, { vite: viteConfig }] of configs.entries()) { + expect(viteConfig).deep.equal({ mode: 'development', base: './', build: { diff --git a/packages/plugin/vite/test/fixture/lib-entry.ts b/packages/plugin/vite/test/fixture/lib-entry.ts new file mode 100644 index 0000000000..47ccd3c013 --- /dev/null +++ b/packages/plugin/vite/test/fixture/lib-entry.ts @@ -0,0 +1,3 @@ +export default function booststrap() { + console.log('App bootstrap.'); +} diff --git a/packages/plugin/vite/test/util/plugins_spec.ts b/packages/plugin/vite/test/util/plugins_spec.ts index bdf0eb226e..7efa98e958 100644 --- a/packages/plugin/vite/test/util/plugins_spec.ts +++ b/packages/plugin/vite/test/util/plugins_spec.ts @@ -1,15 +1,19 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { builtinModules } from 'module'; +import fs from 'node:fs'; +import { builtinModules } from 'node:module'; +import path from 'node:path'; import { expect } from 'chai'; -// eslint-disable-next-line node/no-extraneous-import -import { ExternalOption } from 'rollup'; -import { resolveConfig } from 'vite'; +import { build, type Plugin, resolveConfig } from 'vite'; -import { externalBuiltins } from '../../src/util/plugins'; +import { externalBuiltins, hotRestart } from '../../src/util/plugins'; -describe('plugins', () => { - it('externalBuiltins', async () => { +import type { ExternalOption, RollupWatcher } from 'rollup'; + +export type RestartType = 'auto' | 'manually' | null; + +describe('interval Vite plugins', () => { + it('vite-plugin externalBuiltins', async () => { const nativeModules = builtinModules.filter((e) => !e.startsWith('_')); const builtins: any[] = ['electron', ...nativeModules, ...nativeModules.map((m) => `node:${m}`)]; const getConfig = (external: ExternalOption) => @@ -42,4 +46,78 @@ describe('plugins', () => { const external_function2 = (await getConfig(external_function))!.build!.rollupOptions!.external; expect((external_function2 as (source: string) => boolean)('electron')).true; }); + + it('vite-plugin hotRestart', async () => { + const createBuild = (plugin: Plugin) => + // eslint-disable-next-line no-async-promise-executor + new Promise(async (resolve) => { + let isFirstStart: undefined | true; + const root = path.join(__dirname, '../fixture'); + const entryFile = path.join(root, 'lib-entry.ts'); + const watcher = (await build({ + configFile: false, + root, + build: { + lib: { + entry: 'lib-entry.ts', + formats: ['cjs'], + }, + watch: {}, + }, + plugins: [ + // `hotStart` plugin + plugin, + { + name: 'close-watcher', + async closeBundle() { + if (isFirstStart == null) { + isFirstStart = true; + + // Trigger hot restart + setTimeout(() => { + fs.writeFileSync(entryFile, fs.readFileSync(entryFile, 'utf8')); + }, 100); + } else { + watcher.close(); + resolve(watcher); + } + }, + }, + ], + logLevel: 'silent', + })) as RollupWatcher; + }); + + const autoRestart = await (async () => { + let restart: RestartType | undefined; + // If directly manipulating `process.stdin` of the Main process will cause some side-effects, + // then this should be rewritten with a Child porcess. 🚧 + process.stdin.once('data', (data: Buffer) => { + if (data.toString().trim() === 'rs') { + restart = 'auto'; + } + process.stdin.destroy(); + }); + await createBuild(hotRestart({}, { renderers: [] })); + return restart; + })(); + + const manuallyRestart = await (async () => { + let restart: RestartType | undefined; + await createBuild( + hotRestart( + { + restart() { + restart = 'manually'; + }, + }, + { renderers: [] } + ) + ); + return restart; + })(); + + expect(autoRestart).equal('auto'); + expect(manuallyRestart).equal('manually'); + }); });