feat(cap-vite): press R to re-run capacitor cli
This commit is contained in:
@@ -21,6 +21,7 @@ pnpm -F @proj-airi/stage-pocket run dev:ios -- --target <DEVICE_ID_OR_SIMULATOR_
|
||||
- Arguments after `--` are forwarded to `cap run`.
|
||||
- If `CAPACITOR_DEVICE_ID` is set and `cap run` args do not contain `--target`, `cap-vite` injects `--target <CAPACITOR_DEVICE_ID>` 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
}
|
||||
|
||||
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<typeof vi.fn>
|
||||
setEncoding: ReturnType<typeof vi.fn>
|
||||
setRawMode: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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<typeof createMockServer>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<void>,
|
||||
) {
|
||||
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<void> | 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user