diff --git a/packages/cap-vite/README.md b/packages/cap-vite/README.md index 2014c0d54..99d31b808 100644 --- a/packages/cap-vite/README.md +++ b/packages/cap-vite/README.md @@ -21,6 +21,7 @@ pnpm -F @proj-airi/stage-pocket run dev:ios -- --target ` automatically. - `cap-vite` always launches the Vite dev server. Do not pass `vite dev` or `vite serve` as extra args. +- After the dev server starts, press `R` in the terminal to re-run `cap run` without restarting Vite. You can see the list of available devices and simulators by running `pnpm exec cap run ios --list` or `pnpm exec cap run android --list`. @@ -51,11 +52,12 @@ export default config - No need to care what `server.url` should be, it will be automatically set to the correct value. - Rerun native app when native code changes, you won't forget to start it. +- Rerun `cap run` on demand from the same terminal when you need a clean native relaunch. - No need to open two terminals to run the project, you can run it with one command. ## Architecture Notes - Vite arguments are left to the real Vite CLI instead of being reimplemented inside `cap-vite`. - `cap-vite` injects a wrapper config so it can append its own Vite plugin without editing the user's existing `vite.config.*`. -- The injected plugin reads `server.resolvedUrls`, starts `cap run`, and restarts it when files under the native platform directory change. +- The injected plugin reads `server.resolvedUrls`, starts `cap run`, and restarts it when files under the native platform directory change or when you press `R` in the terminal. - `cap-vite` only splits the two argument groups and passes the `cap run` arguments into the injected plugin through environment variables. diff --git a/packages/cap-vite/src/cli.ts b/packages/cap-vite/src/cli.ts index ec89703b8..ea92d4c56 100644 --- a/packages/cap-vite/src/cli.ts +++ b/packages/cap-vite/src/cli.ts @@ -13,6 +13,7 @@ const helpText = [ 'Notes:', ' Arguments before `--` are forwarded to Vite.', ' Arguments after `--` are forwarded to `cap run`.', + ' After the dev server starts, press `R` to re-run `cap run`.', ].join('\n') export interface ParsedCapViteCliArgs { diff --git a/packages/cap-vite/src/vite-plugin.test.ts b/packages/cap-vite/src/vite-plugin.test.ts new file mode 100644 index 000000000..0eeb386a5 --- /dev/null +++ b/packages/cap-vite/src/vite-plugin.test.ts @@ -0,0 +1,197 @@ +import type { Plugin } from 'vite' + +import process from 'node:process' + +import { EventEmitter } from 'node:events' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { emitKeypressEvents, x } = vi.hoisted(() => ({ + emitKeypressEvents: vi.fn(), + x: vi.fn(), +})) + +vi.mock('node:readline', () => ({ + emitKeypressEvents, +})) + +vi.mock('tinyexec', () => ({ + x, +})) + +type MockResult = Promise<{ exitCode: number, stderr: string, stdout: string }> & { + kill: ReturnType +} + +class MockWatcher extends EventEmitter { + add = vi.fn() + unwatch = vi.fn(async () => {}) +} + +class MockHttpServer extends EventEmitter {} + +function createMockResult(): MockResult { + const output = { + exitCode: 0, + stderr: '', + stdout: '', + } + + return Object.assign(Promise.resolve(output), { + kill: vi.fn(() => true), + }) +} + +function createMockServer() { + const watcher = new MockWatcher() + const httpServer = new MockHttpServer() + + return { + config: { + logger: { + error: vi.fn(), + info: vi.fn(), + }, + root: '/repo/app', + }, + httpServer, + resolvedUrls: { + local: ['http://127.0.0.1:5173/'], + }, + watcher, + } +} + +function createMockStdin() { + const stdin = new EventEmitter() as EventEmitter & { + isRaw: boolean + isTTY: boolean + resume: ReturnType + setEncoding: ReturnType + setRawMode: ReturnType + } + + stdin.isRaw = false + stdin.isTTY = true + stdin.resume = vi.fn() + stdin.setEncoding = vi.fn() + stdin.setRawMode = vi.fn((value: boolean) => { + stdin.isRaw = value + }) + + return stdin +} + +function configurePluginServer(plugin: Plugin, server: ReturnType) { + const configureServer = plugin.configureServer + if (!configureServer) { + throw new Error('cap-vite plugin is missing configureServer().') + } + + const handler = typeof configureServer === 'function' + ? configureServer + : configureServer.handler + + handler.call({} as any, server as any) +} + +const originalStdin = Object.getOwnPropertyDescriptor(process, 'stdin') + +describe('capVitePlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + afterEach(() => { + if (originalStdin) { + Object.defineProperty(process, 'stdin', originalStdin) + } + }) + + it('restarts cap run when the terminal shortcut receives r', async () => { + const firstRun = createMockResult() + const secondRun = createMockResult() + x.mockReturnValueOnce(firstRun).mockReturnValueOnce(secondRun) + + const stdin = createMockStdin() + Object.defineProperty(process, 'stdin', { + configurable: true, + value: stdin, + }) + + const { capVitePlugin } = await import('./vite-plugin') + const server = createMockServer() + + configurePluginServer(capVitePlugin({ + capArgs: ['ios', '--scheme', 'AIRI'], + }), server) + + server.httpServer.emit('listening') + + expect(emitKeypressEvents).toHaveBeenCalledWith(stdin) + expect(stdin.setRawMode).toHaveBeenCalledWith(true) + expect(x).toHaveBeenNthCalledWith(1, 'cap', ['run', 'ios', '--scheme', 'AIRI'], { + nodeOptions: { + cwd: '/repo/app', + env: { + CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/', + }, + stdio: ['ignore', 'inherit', 'inherit'], + }, + throwOnError: false, + }) + + stdin.emit('keypress', 'r', { ctrl: false, name: 'r' }) + + await vi.waitFor(() => { + expect(firstRun.kill).toHaveBeenCalledWith('SIGINT') + expect(server.config.logger.info).toHaveBeenCalledWith('[cap-vite] manual restart requested. Re-running cap run ios.') + expect(x).toHaveBeenNthCalledWith(2, 'cap', ['run', 'ios', '--scheme', 'AIRI'], { + nodeOptions: { + cwd: '/repo/app', + env: { + CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/', + }, + stdio: ['ignore', 'inherit', 'inherit'], + }, + throwOnError: false, + }) + }) + + server.httpServer.emit('close') + + await vi.waitFor(() => { + expect(secondRun.kill).toHaveBeenCalledWith('SIGINT') + }) + }) + + it('restores terminal state before forwarding ctrl+c to vite', async () => { + const run = createMockResult() + x.mockReturnValue(run) + + const stdin = createMockStdin() + Object.defineProperty(process, 'stdin', { + configurable: true, + value: stdin, + }) + + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + const { capVitePlugin } = await import('./vite-plugin') + const server = createMockServer() + + configurePluginServer(capVitePlugin({ + capArgs: ['android'], + }), server) + + server.httpServer.emit('listening') + stdin.emit('keypress', '\u0003', { ctrl: true, name: 'c' }) + + await vi.waitFor(() => { + expect(run.kill).toHaveBeenCalledWith('SIGINT') + expect(stdin.setRawMode).toHaveBeenNthCalledWith(1, true) + expect(stdin.setRawMode).toHaveBeenNthCalledWith(2, false) + expect(kill).toHaveBeenCalledWith(process.pid, 'SIGINT') + }) + }) +}) diff --git a/packages/cap-vite/src/vite-plugin.ts b/packages/cap-vite/src/vite-plugin.ts index 07e560577..923c2a972 100644 --- a/packages/cap-vite/src/vite-plugin.ts +++ b/packages/cap-vite/src/vite-plugin.ts @@ -1,10 +1,14 @@ import type { Result } from 'tinyexec' -import type { Plugin } from 'vite' +import type { Logger, Plugin } from 'vite' + +import type { CapacitorPlatform } from './native' import process from 'node:process' import { resolve } from 'node:path' +import * as readline from 'node:readline' + import { x } from 'tinyexec' import { parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native' @@ -31,6 +35,7 @@ async function stopCapProcess(current: Result | undefined) { function startCapProcess(cwd: string, capArgs: string[], url: URL) { console.info('\n----------------------\n') console.info('Running cap run', ...capArgs) + console.info('[cap-vite] Press R to restart cap run. Press Ctrl+C to exit.') return x('cap', ['run', ...capArgs], { throwOnError: false, @@ -39,67 +44,168 @@ function startCapProcess(cwd: string, capArgs: string[], url: URL) { env: { CAPACITOR_DEV_SERVER_URL: url.toString(), }, - stdio: 'inherit', + // NOTICE: cap-vite owns the terminal shortcuts, so cap run should not + // consume stdin while still mirroring its stdout/stderr to the console. + stdio: ['ignore', 'inherit', 'inherit'], }, }) } +function bindCapViteShortcuts( + logger: Logger, + onRestart: () => void, + onShutdown: () => Promise, +) { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') { + return () => {} + } + + process.stdin.resume() + process.stdin.setEncoding('utf8') + readline.emitKeypressEvents(process.stdin) + + const shouldRestoreRawMode = !process.stdin.isRaw + if (shouldRestoreRawMode) { + process.stdin.setRawMode(true) + } + + async function shutdownFromShortcut() { + try { + await onShutdown() + } + finally { + if (shouldRestoreRawMode) { + process.stdin.setRawMode(false) + } + + process.kill(process.pid, 'SIGINT') + } + } + + const onKeyPress = (input: string, key: readline.Key) => { + if (key.ctrl && key.name === 'c') { + void shutdownFromShortcut() + return + } + + const keyName = key.name?.toLowerCase() ?? input.toLowerCase() + if (!key.ctrl && !key.meta && keyName === 'r') { + onRestart() + } + } + + process.stdin.on('keypress', onKeyPress) + logger.info('[cap-vite] Terminal shortcuts enabled: R restarts cap run.') + + return () => { + process.stdin.off('keypress', onKeyPress) + + if (shouldRestoreRawMode) { + process.stdin.setRawMode(false) + } + } +} + export function capVitePlugin(options: CapVitePluginOptions): Plugin { const resolvedCapArgs = resolveCapRunArgs(options.capArgs) const platform = parseCapacitorPlatform(resolvedCapArgs[0]) if (!platform) { throw new Error('The first `cap run` argument must be `ios` or `android`.') } + const resolvedPlatform: CapacitorPlatform = platform return { apply: 'serve', name: 'cap-vite:run-capacitor', configureServer(server) { const cwd = resolve(server.config.root) - const platformRoot = resolve(cwd, platform) + const platformRoot = resolve(cwd, resolvedPlatform) const debounceMs = 300 const logger = server.config.logger let currentCapProcess: Result | undefined + let restartTask: Promise | undefined + let queuedRestartReason: string | undefined + let disposeShortcut: (() => void) | undefined let shuttingDown = false let restartTimer: NodeJS.Timeout | undefined - const start = () => { + function launchCapProcess() { const url = pickServerUrl(server) currentCapProcess = startCapProcess(cwd, resolvedCapArgs, url) } - const restartCapProcess = async (reason: string) => { + function requestRestart(reason: string) { if (shuttingDown) { return } - logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`) - const previous = currentCapProcess - currentCapProcess = undefined - await stopCapProcess(previous) - start() + queuedRestartReason = reason + if (!restartTask) { + restartTask = flushPendingRestarts() + } } - const onWatcherEvent = (_event, file) => { - if (!shouldRestartForNativeChange(file, platform, cwd)) { + async function flushPendingRestarts() { + try { + while (queuedRestartReason) { + const activeReason = queuedRestartReason + queuedRestartReason = undefined + + if (shuttingDown) { + return + } + + logger.info(`[cap-vite] ${activeReason}. Re-running cap run ${resolvedPlatform}.`) + const previous = currentCapProcess + currentCapProcess = undefined + await stopCapProcess(previous) + + if (shuttingDown) { + return + } + + launchCapProcess() + } + } + catch (error) { + logger.error(`[cap-vite] ${error instanceof Error ? error.message : String(error)}`) + await shutdown() + } + finally { + restartTask = undefined + } + } + + function onWatcherEvent(_event, file) { + if (!shouldRestartForNativeChange(file, resolvedPlatform, cwd)) { return } clearTimeout(restartTimer) restartTimer = setTimeout(() => { - restartCapProcess(`native file changed: ${resolve(cwd, file)}`) + requestRestart(`native file changed: ${resolve(cwd, file)}`) }, debounceMs) } - const shutdown = async () => { + function handleShutdownRequest() { + void shutdown() + } + + async function shutdown() { if (shuttingDown) { return } shuttingDown = true clearTimeout(restartTimer) + queuedRestartReason = undefined + const disposeBoundShortcut = disposeShortcut + disposeShortcut = undefined + disposeBoundShortcut?.() server.watcher.off('all', onWatcherEvent) + process.off('SIGINT', handleShutdownRequest) + process.off('SIGTERM', handleShutdownRequest) await server.watcher.unwatch(platformRoot) await stopCapProcess(currentCapProcess) } @@ -108,17 +214,12 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin { server.watcher.on('all', onWatcherEvent) server.httpServer?.once('listening', () => { - try { - start() - } - catch (error) { - logger.error(`[cap-vite] ${error instanceof Error ? error.message : String(error)}`) - shutdown() - } + launchCapProcess() + disposeShortcut = bindCapViteShortcuts(logger, () => requestRestart('manual restart requested'), shutdown) }) - server.httpServer?.once('close', shutdown) - process.once('SIGINT', shutdown) - process.once('SIGTERM', shutdown) + server.httpServer?.once('close', handleShutdownRequest) + process.once('SIGINT', handleShutdownRequest) + process.once('SIGTERM', handleShutdownRequest) }, } }