Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(plugin-vite): hot-restart 🔥 #3203

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/plugin/vite/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
renderer: {
renderers: {

Should this be renderers since it's a list of all renderer processes?

Copy link
Member Author

@caoxiemeihao caoxiemeihao Sep 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this API design? 🤔
It looks little simper.

export interface VitePluginBuildConfig {
  restart?: (rs: (options?: { reload: 'main_window' }) => void) => void;
}

Usage

// Restart App
restart(rs) {
   rs();
};

// Reload Renderer process
restart(rs) {
   rs({ reload: 'main_window' });
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @erickzhao means that because this is an array, it would be better to use plural for words.

config: VitePluginRendererConfig;
reload: () => void;
}[];
}) => void);
}

export interface VitePluginRendererConfig {
Expand Down
47 changes: 34 additions & 13 deletions packages/plugin/vite/src/ViteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -14,10 +14,20 @@ const d = debug('electron-forge:plugin:vite:viteconfig');
*/
export type LoadResult = Awaited<ReturnType<typeof loadConfigFromFile>>;

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<UserConfig>[];
private rendererConfigCache!: Promise<RendererConfig>[];

constructor(private readonly pluginConfig: VitePluginConfig, private readonly projectDir: string, private readonly isProd: boolean) {
this.baseDir = path.join(projectDir, '.vite');
Expand All @@ -43,20 +53,21 @@ export default class ViteConfigGenerator {
async getDefines(): Promise<Record<string, string>> {
const defines: Record<string, any> = {};
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<UserConfig[]> {
async getBuildConfig(watch = false): Promise<BuildConfig[]> {
if (!Array.isArray(this.pluginConfig.build)) {
throw new Error('"config.build" must be an Array');
}
Expand All @@ -65,8 +76,9 @@ export default class ViteConfigGenerator {
const plugins = [externalBuiltins()];
const configs = this.pluginConfig.build
.filter(({ entry, config }) => entry || config)
.map<Promise<UserConfig>>(async ({ entry, config }) => {
const defaultConfig: UserConfig = {
.map<Promise<BuildConfig>>(async (buildConfig) => {
const { entry, config } = buildConfig;
let viteConfig: UserConfig = {
// Ensure that each build config loads the .env file correctly.
mode: this.mode,
build: {
Expand All @@ -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<UserConfig[]> {
async getRendererConfig(): Promise<RendererConfig[]> {
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.
Expand All @@ -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);
Expand Down
48 changes: 31 additions & 17 deletions packages/plugin/vite/src/VitePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

// 🎯-②:
erickzhao marked this conversation as resolved.
Show resolved Hide resolved
// 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<VitePluginConfig> {
private static alreadyStarted = false;

Expand All @@ -31,7 +40,7 @@ export default class VitePlugin extends PluginBase<VitePluginConfig> {

private watchers: RollupWatcher[] = [];

private servers: vite.ViteDevServer[] = [];
public renderers: RendererConfigWithServer[] = [];

init = (dir: string): void => {
this.setDirectories(dir);
Expand Down Expand Up @@ -105,26 +114,27 @@ export default class VitePlugin extends PluginBase<VitePluginConfig> {

// Main process, Preload scripts and Worker process, etc.
build = async (watch = false): Promise<void> => {
// 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<void>((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)] : []),
erickzhao marked this conversation as resolved.
Show resolved Hide resolved
...(viteConfig.plugins ?? []),
],
})
.then((result) => {
Expand All @@ -144,34 +154,38 @@ export default class VitePlugin extends PluginBase<VitePluginConfig> {

// Renderer process
buildRenderer = async (): Promise<void> => {
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<void> => {
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;
}
}
}
Expand All @@ -186,11 +200,11 @@ export default class VitePlugin extends PluginBase<VitePluginConfig> {
}
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.
Expand Down
47 changes: 47 additions & 0 deletions packages/plugin/vite/src/util/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { builtinModules } from 'node:module';

import type { VitePluginBuildConfig } from '../Config';
import type { RendererConfigWithServer } from '../VitePlugin';
import type { Plugin } from 'vite';

/**
Expand Down Expand Up @@ -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 = () => {
erickzhao marked this conversation as resolved.
Show resolved Hide resolved
// https://github.com/electron/forge/blob/v6.1.1/packages/api/core/src/api/start.ts#L204-L211
process.stdin.emit('data', 'rs');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MarshallOfSound pointed out to me that using stdin here is a bit of a hack here and there are edge cases where process.stdin could be null.

Ideally, we should instead expose additional start logic from the @electron-forge/core API to expose this functionality so that plugins can hook into it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay! This does seem a little dangerous. 🚨

};
// Avoid first start, it's stated by forge.
let isFirstStart: undefined | true;

return <Plugin>{
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();
}
},
};
}
25 changes: 14 additions & 11 deletions packages/plugin/vite/test/ViteConfig_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is this commented out intentionally?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! When I run test on my local machine, some verbose log print, so commented it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we remove this before we ship it to users then?

Copy link
Member Author

@caoxiemeihao caoxiemeihao Sep 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logs removed here are only for the CI test environment and will not affect the production environment.
Do we need to keep this log in the test environment?

Copy link
Member

@BlackHole1 BlackHole1 Oct 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to keep this log in the test environment?

No. If others need it, they can add it themselves.

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();
Expand All @@ -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'),
Expand All @@ -67,7 +70,7 @@ describe('ViteConfigGenerator', () => {
clearScreen: false,
define: {},
// shims
plugins: [buildConfig.plugins?.[0]],
plugins: viteConfig.plugins,
} as UserConfig);
});

Expand All @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin/vite/test/fixture/lib-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function booststrap() {
console.log('App bootstrap.');
}
Loading