diff --git a/packages/vite/src/node/cli.ts b/packages/vite/src/node/cli.ts index e73feee62d0955..8863fd3cd42167 100644 --- a/packages/vite/src/node/cli.ts +++ b/packages/vite/src/node/cli.ts @@ -1,6 +1,7 @@ import path from 'node:path' import fs from 'node:fs' import { performance } from 'node:perf_hooks' +import type { Session } from 'node:inspector' import { cac } from 'cac' import colors from 'picocolors' import type { BuildOptions } from './build' @@ -8,6 +9,7 @@ import type { ServerOptions } from './server' import type { LogLevel } from './logger' import { createLogger } from './logger' import { VERSION } from './constants' +import { bindShortcuts } from './shortcuts' import { resolveConfig } from '.' const cli = cac('vite') @@ -30,25 +32,34 @@ interface GlobalCLIOptions { force?: boolean } -export const stopProfiler = (log: (message: string) => void): void => { - // @ts-ignore - const profileSession = global.__vite_profile_session - if (profileSession) { - profileSession.post('Profiler.stop', (err: any, { profile }: any) => { +// @ts-ignore +let profileSession: Session | undefined = global.__vite_profile_session +let profileCount = 0 + +export const stopProfiler = ( + log: (message: string) => void, +): void | Promise => { + if (!profileSession) return + return new Promise((res, rej) => { + profileSession!.post('Profiler.stop', (err: any, { profile }: any) => { // Write profile to disk, upload, etc. if (!err) { - const outPath = path.resolve('./vite-profile.cpuprofile') + const outPath = path.resolve( + `./vite-profile-${profileCount++}.cpuprofile`, + ) fs.writeFileSync(outPath, JSON.stringify(profile)) log( colors.yellow( `CPU profile written to ${colors.white(colors.dim(outPath))}`, ), ) + profileSession = undefined + res() } else { - throw err + rej(err) } }) - } + }) } const filterDuplicateOptions = (options: T) => { @@ -148,7 +159,34 @@ cli ) server.printUrls() - stopProfiler((message) => server.config.logger.info(` ${message}`)) + bindShortcuts(server, { + print: true, + customShortcuts: [ + profileSession && { + key: 'p', + description: 'start/stop the profiler', + async action(server) { + if (profileSession) { + await stopProfiler(server.config.logger.info) + } else { + const inspector = await import('node:inspector').then( + (r) => r.default, + ) + await new Promise((res) => { + profileSession = new inspector.Session() + profileSession.connect() + profileSession.post('Profiler.enable', () => { + profileSession!.post('Profiler.start', () => { + server.config.logger.info('Profiler started') + res() + }) + }) + }) + } + }, + }, + ], + }) } catch (e) { const logger = createLogger(options.logLevel) logger.error(colors.red(`error when starting dev server:\n${e.stack}`), { diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 795c9ea5246042..4c26087d9ce2b8 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -41,6 +41,8 @@ import { initDepsOptimizer, initDevSsrDepsOptimizer, } from '../optimizer' +import { bindShortcuts } from '../shortcuts' +import type { BindShortcutsOptions } from '../shortcuts' import { CLIENT_DIR } from '../constants' import type { Logger } from '../logger' import { printServerUrls } from '../logger' @@ -300,6 +302,13 @@ export interface ViteDevServer { * @internal */ _fsDenyGlob: Matcher + /** + * @internal + * Actually BindShortcutsOptions | undefined but api-extractor checks for + * export before trimming internal types :( + * And I don't want to complexity prePatchTypes for that + */ + _shortcutsOptions: any | undefined } export interface ResolvedServerUrls { @@ -452,6 +461,7 @@ export async function createServer( _forceOptimizeOnRestart: false, _pendingRequests: new Map(), _fsDenyGlob: picomatch(config.server.fs.deny, { matchBase: true }), + _shortcutsOptions: undefined, } server.transformIndexHtml = createDevHtmlTransformFn(server) @@ -771,6 +781,7 @@ async function restartServer(server: ViteDevServer) { // @ts-ignore global.__vite_start_time = performance.now() const { port: prevPort, host: prevHost } = server.config.server + const shortcutsOptions: BindShortcutsOptions = server._shortcutsOptions await server.close() @@ -819,6 +830,11 @@ async function restartServer(server: ViteDevServer) { logger.info('server restarted.', { timestamp: true }) } + if (shortcutsOptions) { + shortcutsOptions.print = false + bindShortcuts(newServer, shortcutsOptions) + } + // new server (the current server) can restart now newServer._restartPromise = null } diff --git a/packages/vite/src/node/shortcuts.ts b/packages/vite/src/node/shortcuts.ts new file mode 100644 index 00000000000000..3d06d83fd93fed --- /dev/null +++ b/packages/vite/src/node/shortcuts.ts @@ -0,0 +1,112 @@ +import colors from 'picocolors' +import type { ViteDevServer } from './server' +import { openBrowser } from './server/openBrowser' +import { isDefined } from './utils' + +export type BindShortcutsOptions = { + /** + * Print a one line hint to the terminal. + */ + print?: boolean + customShortcuts?: (CLIShortcut | undefined | null)[] +} + +export type CLIShortcut = { + key: string + description: string + action(server: ViteDevServer): void | Promise +} + +export function bindShortcuts( + server: ViteDevServer, + opts: BindShortcutsOptions, +): void { + if (!server.httpServer) return + server._shortcutsOptions = opts + + if (opts.print) { + server.config.logger.info( + colors.dim(colors.green(' ➜')) + + colors.dim(' press ') + + colors.bold('h') + + colors.dim(' to show help'), + ) + } + + const shortcuts = (opts.customShortcuts ?? []) + .filter(isDefined) + .concat(BASE_SHORTCUTS) + + let actionRunning = false + + const onInput = async (input: string) => { + // ctrl+c or ctrl+d + if (input === '\x03' || input === '\x04') { + process.emit('SIGTERM') + return + } + + if (actionRunning) return + + if (input === 'h') { + server.config.logger.info( + shortcuts + .map( + (shortcut) => + colors.dim(' press ') + + colors.bold(shortcut.key) + + colors.dim(` to ${shortcut.description}`), + ) + .join('\n'), + ) + } + + const shortcut = shortcuts.find((shortcut) => shortcut.key === input) + if (!shortcut) return + + actionRunning = true + await shortcut.action(server) + actionRunning = false + } + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + + process.stdin.on('data', onInput).setEncoding('utf8').resume() + + server.httpServer.on('close', () => { + process.stdin.off('data', onInput).pause() + }) +} + +const BASE_SHORTCUTS: CLIShortcut[] = [ + { + key: 'r', + description: 'restart the server', + async action(server) { + await server.restart() + }, + }, + { + key: 'o', + description: 'open in browser', + action(server) { + const url = server.resolvedUrls?.local[0] + + if (!url) { + server.config.logger.warn('No URL available to open in browser') + return + } + + openBrowser(url, true, server.config.logger) + }, + }, + { + key: 'q', + description: 'quit', + async action(server) { + await server.close().finally(() => process.exit()) + }, + }, +]