refactor(cap-vite): use plugin instead of createServer

This commit is contained in:
LemonNeko
2026-03-10 22:31:28 +08:00
parent eab0fbd83c
commit 756ddd31e4
13 changed files with 764 additions and 321 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"dev:web": "vite",
"dev:ios": "cap-vite ios"
"dev:ios": "cap-vite -- ios"
},
"dependencies": {
"@capacitor/core": "catalog:",
+28 -14
View File
@@ -5,25 +5,32 @@ CLI for [Capacitor](https://capacitorjs.com/) live-reload development using Vite
## Usage
```bash
pnpm cap-vite ios --target <DEVICE_ID_OR_SIMULATOR_NAME>
pnpm cap-vite android --target <DEVICE_ID_OR_SIMULATOR_NAME>
pnpm cap-vite ios --target <DEVICE_ID_OR_SIMULATOR_NAME> -- --scheme AIRI
pnpm cap-vite android --target <DEVICE_ID_OR_SIMULATOR_NAME> -- --flavor release
# Or
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm cap-vite ios
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm cap-vite android
cap-vite [vite args...] -- <ios|android> [cap run args...]
```
- Arguments after `--` are forwarded to `cap run`, example: `pnpm cap-vite ios --target <DEVICE_ID_OR_SIMULATOR_NAME> -- --scheme AIRI` will run `cap run ios --target <DEVICE_ID_OR_SIMULATOR_NAME> --scheme AIRI`.
Examples:
```bash
pnpm exec cap-vite -- ios --target <DEVICE_ID_OR_SIMULATOR_NAME>
pnpm exec cap-vite -- --host 0.0.0.0 --port 5173 -- android --target <DEVICE_ID_OR_SIMULATOR_NAME> --flavor release
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm exec cap-vite -- ios
pnpm -F @proj-airi/stage-pocket run dev:ios -- --target <DEVICE_ID_OR_SIMULATOR_NAME>
```
- Arguments before `--` are forwarded to `vite`.
- 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.
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`.
## Capacitor Configuration
You need to set `server.url` in `capacitor.config.ts` to the env variable `CAPACITOR_DEV_SERVER_URL`.
You need to set `server.url` in `capacitor.config.ts` to the env variable `CAPACITOR_DEV_SERVER_URL`, then the cli will handle rest for you.
```ts
const serverURL = env.CAPACITOR_DEV_SERVER_URL
const isCleartext = serverURL?.startsWith('http://') ?? false
const config: CapacitorConfig = {
appId: 'com.example.app',
@@ -32,7 +39,7 @@ const config: CapacitorConfig = {
server: serverURL
? {
url: serverURL,
cleartext: false,
cleartext: isCleartext,
}
: undefined,
}
@@ -40,8 +47,15 @@ const config: CapacitorConfig = {
export default config
```
## What It Does
## Why we need this?
- Starts the project's own Vite config through the Vite API.
- Executes the local `cap` binary via `tinyexec`.
- Watches native files under `ios/` or `android/` and re-runs `cap run` after a small debounce.
- 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.
- 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.
- `cap-vite` only splits the two argument groups and passes the `cap run` arguments into the injected plugin through environment variables.
+6 -2
View File
@@ -3,15 +3,19 @@
import process from 'node:process'
import { runCapVite } from '..'
import { parseCapViteCliArgs } from '../cli'
import { getCapViteCliHelpText, parseCapViteCliArgs } from '../cli'
async function main() {
const parsed = parseCapViteCliArgs(process.argv.slice(2))
if (!parsed) {
process.stdout.write(`${getCapViteCliHelpText()}\n`)
return
}
await runCapVite(parsed.platform, parsed.target, { capArgs: parsed.capArgs })
const result = await runCapVite(parsed.viteArgs, parsed.capArgs)
if (typeof result.exitCode === 'number') {
process.exitCode = result.exitCode
}
}
void main().catch((error) => {
+23 -23
View File
@@ -3,39 +3,39 @@ import { describe, expect, it } from 'vitest'
import { parseCapViteCliArgs } from './cli'
describe('parseCapViteCliArgs', () => {
it('parses target and cap args after double dashes', () => {
expect(parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--', '--flavor', 'release'])).toEqual({
capArgs: ['--flavor', 'release'],
platform: 'ios',
target: 'iPhone 16 Pro',
it('splits vite args from cap run args', () => {
expect(parseCapViteCliArgs(['--host', '0.0.0.0', '--port', '5173', '--', 'ios', '--target', 'iPhone 16 Pro'])).toEqual({
capArgs: ['ios', '--target', 'iPhone 16 Pro'],
viteArgs: ['--host', '0.0.0.0', '--port', '5173'],
})
})
it('falls back to CAPACITOR_DEVICE_ID when --target is omitted', () => {
expect(parseCapViteCliArgs(['android'], { CAPACITOR_DEVICE_ID: 'emulator-5554' })).toEqual({
capArgs: [],
platform: 'android',
target: 'emulator-5554',
it('returns null for help output', () => {
expect(parseCapViteCliArgs(['--help'])).toBeNull()
})
it('keeps cap run args untouched after double dashes', () => {
expect(parseCapViteCliArgs(['--mode', 'release', '--', 'android', '--target', 'emulator-5554', '--flavor', 'release'])).toEqual({
capArgs: ['android', '--target', 'emulator-5554', '--flavor', 'release'],
viteArgs: ['--mode', 'release'],
})
})
it('keeps cap args untouched even when they look like wrapper flags', () => {
expect(parseCapViteCliArgs(['android', '--target', 'emulator-5554', '--', '--mode', 'release'])).toEqual({
capArgs: ['--mode', 'release'],
platform: 'android',
target: 'emulator-5554',
})
it('rejects invocations without the cap run separator', () => {
expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro'])).toThrow(
'cap-vite [vite args...] -- <ios|android> [cap run args...]',
)
})
it('rejects unknown wrapper options before double dashes', () => {
expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--mode', 'mobile'])).toThrow(/Unknown option `--mode`/)
})
it('allows only one positional argument', () => {
expect(() => parseCapViteCliArgs(['ios', 'iPhone 16 Pro'])).toThrow('cap-vite <ios|android> [--target <DEVICE_ID_OR_SIMULATOR_NAME>] [-- <cap args...>]')
it('rejects invocations without a platform after the separator', () => {
expect(() => parseCapViteCliArgs(['--host', '0.0.0.0', '--'])).toThrow(
'cap-vite [vite args...] -- <ios|android> [cap run args...]',
)
})
it('rejects unsupported platforms', () => {
expect(() => parseCapViteCliArgs(['web', '--target', 'chrome'])).toThrow('cap-vite <ios|android> [--target <DEVICE_ID_OR_SIMULATOR_NAME>] [-- <cap args...>]')
expect(() => parseCapViteCliArgs(['--host', '0.0.0.0', '--', 'web', '--target', 'chrome'])).toThrow(
'cap-vite [vite args...] -- <ios|android> [cap run args...]',
)
})
})
+33 -41
View File
@@ -1,63 +1,55 @@
import type { CapacitorPlatform } from '.'
const usage = 'cap-vite [vite args...] -- <ios|android> [cap run args...]'
import process from 'node:process'
import { cac } from 'cac'
const helpText = [
'Run a Vite dev server and forward a second argument group to `cap run`.',
'',
'Usage:',
` ${usage}`,
'',
'Examples:',
' cap-vite -- ios --target "iPhone 16 Pro"',
' cap-vite --host 0.0.0.0 --port 5173 -- android --target emulator-5554 --flavor release',
'',
'Notes:',
' Arguments before `--` are forwarded to Vite.',
' Arguments after `--` are forwarded to `cap run`.',
].join('\n')
export interface ParsedCapViteCliArgs {
capArgs: string[]
platform: CapacitorPlatform
target: string
viteArgs: string[]
}
const usage = 'cap-vite <ios|android> [--target <DEVICE_ID_OR_SIMULATOR_NAME>] [-- <cap args...>]'
function createCapViteCli() {
const cli = cac('cap-vite')
cli.help()
cli
.command('<platform>', 'Run Capacitor with a Vite dev server')
.usage(usage)
.option('--target <target>', 'Set the Capacitor device target')
.example('cap-vite ios --target "iPhone 16 Pro" -- --scheme AIRI')
.example('CAPACITOR_DEVICE_ID=emulator-5554 cap-vite android -- --flavor release')
return cli
export function getCapViteCliHelpText(): string {
return helpText
}
export function parseCapViteCliArgs(
argv: string[],
env: NodeJS.ProcessEnv = process.env,
): ParsedCapViteCliArgs | null {
const cli = createCapViteCli()
const parsed = cli.parse(['node', 'cap-vite', ...argv], { run: false })
export function getCapViteCliUsage(): string {
return usage
}
if (cli.options.help) {
export function parseCapViteCliArgs(argv: string[]): ParsedCapViteCliArgs | null {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
return null
}
cli.matchedCommand?.checkUnknownOptions()
cli.matchedCommand?.checkOptionValue()
cli.matchedCommand?.checkRequiredArgs()
if (parsed.args.length > 1) {
const separatorIndex = argv.indexOf('--')
if (separatorIndex === -1) {
throw new Error(usage)
}
const platform = parsed.args[0]
const capArgs = argv.slice(separatorIndex + 1)
if (capArgs.length === 0) {
throw new Error(usage)
}
const platform = capArgs[0]
if (platform !== 'android' && platform !== 'ios') {
throw new Error(usage)
}
const target = typeof parsed.options.target === 'string' ? parsed.options.target : env.CAPACITOR_DEVICE_ID
if (!target) {
throw new Error(usage)
}
return {
capArgs: Array.isArray(parsed.options['--']) ? parsed.options['--'] : [],
platform,
target,
capArgs,
viteArgs: argv.slice(0, separatorIndex),
}
}
+55 -54
View File
@@ -1,9 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import { runCapVite } from './index'
import { describe, expect, it, vi } from 'vitest'
const { createServer, x } = vi.hoisted(() => ({
createServer: vi.fn(),
const { x } = vi.hoisted(() => ({
x: vi.fn(),
}))
@@ -11,68 +10,70 @@ vi.mock('tinyexec', () => ({
x,
}))
vi.mock('vite', () => ({
createServer,
}))
describe('prepareCapViteLaunch', () => {
it('captures the user config while forwarding the remaining vite args', async () => {
const { prepareCapViteLaunch } = await import('./index')
expect(prepareCapViteLaunch(['--host', '0.0.0.0', '--config', 'vite.mobile.ts', '--configLoader', 'runner'])).toEqual({
baseConfigFile: resolve(process.cwd(), 'vite.mobile.ts'),
configLoader: 'runner',
projectRoot: process.cwd(),
viteArgs: ['--host', '0.0.0.0', '--configLoader', 'runner'],
wrapperConfigFile: expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/),
})
})
it('uses the leading positional vite root as the wrapper root', async () => {
const { prepareCapViteLaunch } = await import('./index')
expect(prepareCapViteLaunch(['apps/stage-pocket', '--host', '0.0.0.0'])).toEqual({
baseConfigFile: undefined,
configLoader: undefined,
projectRoot: resolve(process.cwd(), 'apps/stage-pocket'),
viteArgs: ['apps/stage-pocket', '--host', '0.0.0.0'],
wrapperConfigFile: expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/),
})
})
it('throws when --config is missing its value', async () => {
const { prepareCapViteLaunch } = await import('./index')
expect(() => prepareCapViteLaunch(['--config'])).toThrow('Missing value for `--config`.')
})
})
describe('runCapVite', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('forwards cap args into cap run', async () => {
const processOnce = vi.spyOn(process, 'once').mockImplementation(() => process)
createServer.mockResolvedValue({
close: vi.fn().mockResolvedValue(undefined),
config: {
logger: {
info: vi.fn(),
},
},
listen: vi.fn().mockResolvedValue(undefined),
printUrls: vi.fn(),
resolvedUrls: {
local: ['http://127.0.0.1:5173/'],
},
watcher: {
add: vi.fn(),
off: vi.fn(),
on: vi.fn(),
unwatch: vi.fn().mockResolvedValue(undefined),
},
it('launches vite with the wrapper config and cap-vite env vars', async () => {
const { runCapVite } = await import('./index')
x.mockResolvedValue({
exitCode: 0,
stderr: '',
stdout: '',
})
x.mockReturnValue({
kill: vi.fn(),
})
await runCapVite(
['--host', '0.0.0.0', '--config', 'vite.mobile.ts', '--configLoader=runner'],
['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'],
)
await runCapVite('android', 'emulator-5554', {
capArgs: ['--flavor', 'release'],
})
expect(createServer).toHaveBeenCalledWith({
clearScreen: false,
root: process.cwd(),
})
expect(x).toHaveBeenCalledWith('cap', ['run', 'android', '--target', 'emulator-5554', '--flavor', 'release'], {
expect(x).toHaveBeenCalledWith('vite', [
'--config',
expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/),
'--host',
'0.0.0.0',
'--configLoader=runner',
], {
nodeOptions: {
cwd: process.cwd(),
env: {
CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/',
CAP_VITE_BASE_CONFIG: resolve(process.cwd(), 'vite.mobile.ts'),
CAP_VITE_CAP_ARGS_JSON: JSON.stringify(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']),
CAP_VITE_CONFIG_LOADER: 'runner',
CAP_VITE_ROOT: process.cwd(),
},
stdio: 'inherit',
},
persist: true,
throwOnError: false,
})
expect(processOnce).toHaveBeenCalledWith('SIGINT', expect.any(Function))
expect(processOnce).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
})
})
+141 -186
View File
@@ -1,221 +1,176 @@
import type { Result } from 'tinyexec'
import type { ViteDevServer } from 'vite'
import type { Output } from 'tinyexec'
import process from 'node:process'
import { basename, extname, relative, resolve, sep } from 'node:path'
import { extname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { x } from 'tinyexec'
import { createServer } from 'vite'
export type CapacitorPlatform = 'android' | 'ios'
import { parseCapacitorPlatform } from './native'
export type { CapacitorPlatform } from './native'
export interface RunCapViteOptions {
capArgs?: string[]
cwd?: string
debounceMs?: number
}
const nativeExtensionsByPlatform: Record<CapacitorPlatform, Set<string>> = {
ios: new Set([
'.entitlements',
'.h',
'.hpp',
'.m',
'.mm',
'.pbxproj',
'.plist',
'.storyboard',
'.strings',
'.swift',
'.xcodeproj',
'.xcconfig',
'.xcscheme',
'.xib',
]),
android: new Set([
'.gradle',
'.java',
'.json',
'.kts',
'.kt',
'.properties',
'.xml',
]),
interface PreparedViteLaunch {
baseConfigFile?: string
configLoader?: 'bundle' | 'native' | 'runner'
projectRoot: string
viteArgs: string[]
wrapperConfigFile: string
}
const nativeNamesByPlatform: Record<CapacitorPlatform, Set<string>> = {
ios: new Set([
'Podfile',
'Podfile.lock',
'project.pbxproj',
]),
android: new Set([
'AndroidManifest.xml',
'build.gradle',
'build.gradle.kts',
'gradle.properties',
'settings.gradle',
'settings.gradle.kts',
]),
interface ParsedViteArg {
baseConfigFile?: string
configLoader?: 'bundle' | 'native' | 'runner'
consumedArgs: number
forwardedArgs: string[]
}
const ignoredNames = new Set([
'capacitor.config.json',
])
const ignoredPathSegments = new Set([
'.gradle',
'DerivedData',
'Pods',
'build',
'xcuserdata',
])
const ignoredPathPrefixesByPlatform: Record<CapacitorPlatform, string[][]> = {
ios: [
['App', 'CapApp-SPM'],
],
android: [],
function resolveWrapperConfigFile(): string {
const currentModulePath = fileURLToPath(import.meta.url)
const wrapperExtension = extname(currentModulePath) === '.ts' ? '.ts' : '.mjs'
return fileURLToPath(new URL(`./vite-wrapper-config${wrapperExtension}`, import.meta.url))
}
function pickServerUrl(server: ViteDevServer): URL {
const url = server.resolvedUrls?.network?.[0] ?? server.resolvedUrls?.local?.[0]
if (!url) {
throw new Error('Vite did not expose a reachable dev server URL.')
function parseViteConfigLoader(value: string | undefined): 'bundle' | 'native' | 'runner' | undefined {
if (value === 'bundle' || value === 'native' || value === 'runner') {
return value
}
const resolved = new URL(url)
return resolved
return undefined
}
function shouldRestartForNativeChange(file: string, platform: CapacitorPlatform, cwd: string): boolean {
const absoluteFile = resolve(cwd, file)
const platformRoot = resolve(cwd, platform)
if (!absoluteFile.startsWith(`${platformRoot}${sep}`) && absoluteFile !== platformRoot) {
return false
}
const fileName = basename(absoluteFile)
if (ignoredNames.has(fileName)) {
return false
}
const segments = absoluteFile.split(sep)
if (segments.some(segment => ignoredPathSegments.has(segment))) {
return false
}
const relativeFile = relative(platformRoot, absoluteFile)
const relativeSegments = relativeFile.split(sep).filter(Boolean)
if (ignoredPathPrefixesByPlatform[platform].some(prefix =>
prefix.every((segment, index) => relativeSegments[index] === segment),
)) {
// NOTICE: Capacitor regenerates ios/App/CapApp-SPM/Package.swift during `cap run`.
// Treating that generated tree as a native source change causes an infinite restart loop.
return false
}
if (nativeNamesByPlatform[platform].has(fileName)) {
return true
}
return nativeExtensionsByPlatform[platform].has(extname(fileName).toLowerCase())
function resolveConfigPath(cwd: string, value: string): string {
return resolve(cwd, value)
}
async function stopCapProcess(current: Result | undefined) {
if (!current) {
return
function readRequiredOptionValue(viteArgs: string[], index: number, optionName: string): string {
const value = viteArgs[index + 1]
if (!value) {
throw new Error(`Missing value for \`${optionName}\`.`)
}
current.kill('SIGINT')
try {
await current
}
catch {
// tinyexec rejects on interrupted exits when the child was stopped for a restart.
}
return value
}
function startCapProcess(cwd: string, platform: CapacitorPlatform, target: string, url: URL, capArgs: string[]) {
console.info('\n----------------------\n')
console.info('Running cap run', platform, '--target', target, ...capArgs)
return x('cap', ['run', platform, '--target', target, ...capArgs], { persist: true, throwOnError: false, nodeOptions: { cwd, stdio: 'inherit', env: { CAPACITOR_DEV_SERVER_URL: url.toString() } } })
function parseConfigArg(viteArgs: string[], index: number, cwd: string): ParsedViteArg | null {
const arg = viteArgs[index]
// NOTICE: Vite only accepts one `--config` entrypoint. cap-vite consumes that slot
// for its wrapper config, then loads the user config from inside the wrapper.
if (arg === '--config' || arg === '-c') {
return {
baseConfigFile: resolveConfigPath(cwd, readRequiredOptionValue(viteArgs, index, '--config')),
consumedArgs: 2,
forwardedArgs: [],
}
}
if (arg.startsWith('--config=')) {
return {
baseConfigFile: resolveConfigPath(cwd, arg.slice('--config='.length)),
consumedArgs: 1,
forwardedArgs: [],
}
}
return null
}
function parseConfigLoaderArg(viteArgs: string[], index: number): ParsedViteArg | null {
const arg = viteArgs[index]
if (arg === '--configLoader') {
const value = readRequiredOptionValue(viteArgs, index, '--configLoader')
return {
configLoader: parseViteConfigLoader(value),
consumedArgs: 2,
forwardedArgs: [arg, value],
}
}
if (arg.startsWith('--configLoader=')) {
return {
configLoader: parseViteConfigLoader(arg.slice('--configLoader='.length)),
consumedArgs: 1,
forwardedArgs: [arg],
}
}
return null
}
function parseViteArg(viteArgs: string[], index: number, cwd: string): ParsedViteArg {
return parseConfigArg(viteArgs, index, cwd)
?? parseConfigLoaderArg(viteArgs, index)
?? {
consumedArgs: 1,
forwardedArgs: [viteArgs[index]],
}
}
function resolveProjectRoot(viteArgs: string[], cwd: string): string {
const firstArg = viteArgs[0]
return firstArg && !firstArg.startsWith('-')
? resolve(cwd, firstArg)
: cwd
}
export function prepareCapViteLaunch(viteArgs: string[], cwd: string = process.cwd()): PreparedViteLaunch {
const resolvedCwd = resolve(cwd)
const projectRoot = resolveProjectRoot(viteArgs, resolvedCwd)
let baseConfigFile: string | undefined
let configLoader: 'bundle' | 'native' | 'runner' | undefined
const forwardedViteArgs: string[] = []
for (let index = 0; index < viteArgs.length;) {
const parsedArg = parseViteArg(viteArgs, index, resolvedCwd)
baseConfigFile = parsedArg.baseConfigFile ?? baseConfigFile
configLoader = parsedArg.configLoader ?? configLoader
forwardedViteArgs.push(...parsedArg.forwardedArgs)
index += parsedArg.consumedArgs
}
return {
baseConfigFile,
configLoader,
projectRoot,
viteArgs: forwardedViteArgs,
wrapperConfigFile: resolveWrapperConfigFile(),
}
}
export async function runCapVite(
platform: CapacitorPlatform,
target: string,
viteArgs: string[],
capArgs: string[],
options: RunCapViteOptions = {},
): Promise<void> {
const capArgs = options.capArgs ?? []
): Promise<Output> {
if (!parseCapacitorPlatform(capArgs[0])) {
throw new Error('The first `cap run` argument must be `ios` or `android`.')
}
const cwd = resolve(options.cwd ?? process.cwd())
const debounceMs = options.debounceMs ?? 300
const server = await createServer({
clearScreen: false,
root: cwd,
})
const prepared = prepareCapViteLaunch(viteArgs, cwd)
await server.listen()
server.printUrls()
const url = pickServerUrl(server)
const logger = server.config.logger
let currentCapProcess: Result | undefined = startCapProcess(cwd, platform, target, url, capArgs)
let restartTimer: NodeJS.Timeout | undefined
let shuttingDown = false
async function restartCapProcess(reason: string) {
if (shuttingDown) {
return
}
logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`)
const previous = currentCapProcess
currentCapProcess = undefined
await stopCapProcess(previous)
currentCapProcess = startCapProcess(cwd, platform, target, url, capArgs)
}
const onWatcherEvent = (_event: string, file: string) => {
if (!shouldRestartForNativeChange(file, platform, cwd)) {
return
}
clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
void restartCapProcess(`native file changed: ${resolve(cwd, file)}`)
}, debounceMs)
}
const shutdown = async (exitCode: number) => {
if (shuttingDown) {
return
}
shuttingDown = true
clearTimeout(restartTimer)
server.watcher.off('all', onWatcherEvent)
await server.watcher.unwatch(platform)
await server.close()
await stopCapProcess(currentCapProcess)
process.exit(exitCode)
}
server.watcher.add(platform)
server.watcher.on('all', onWatcherEvent)
process.once('SIGINT', () => {
void shutdown(0)
})
process.once('SIGTERM', () => {
void shutdown(0)
return await x('vite', ['--config', prepared.wrapperConfigFile, ...prepared.viteArgs], {
throwOnError: false,
nodeOptions: {
cwd,
env: {
CAP_VITE_BASE_CONFIG: prepared.baseConfigFile ?? '',
CAP_VITE_CAP_ARGS_JSON: JSON.stringify(capArgs),
CAP_VITE_CONFIG_LOADER: prepared.configLoader ?? '',
CAP_VITE_ROOT: prepared.projectRoot,
},
stdio: 'inherit',
},
})
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { hasCapacitorTargetArg, parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native'
describe('parseCapacitorPlatform', () => {
it('accepts supported platforms', () => {
expect(parseCapacitorPlatform('ios')).toBe('ios')
expect(parseCapacitorPlatform('android')).toBe('android')
})
it('rejects unsupported platforms', () => {
expect(parseCapacitorPlatform('web')).toBeNull()
expect(parseCapacitorPlatform(undefined)).toBeNull()
})
})
describe('pickServerUrl', () => {
it('prefers network urls over local urls', () => {
expect(pickServerUrl({
resolvedUrls: {
local: ['http://127.0.0.1:5173/'],
network: ['http://192.168.1.10:5173/'],
},
} as any).toString()).toBe('http://192.168.1.10:5173/')
})
it('falls back to local urls when no network url exists', () => {
expect(pickServerUrl({
resolvedUrls: {
local: ['http://127.0.0.1:5173/'],
},
} as any).toString()).toBe('http://127.0.0.1:5173/')
})
it('throws when vite did not expose any reachable url', () => {
expect(() => pickServerUrl({
resolvedUrls: {
local: [],
network: [],
},
} as any)).toThrow('Vite did not expose a reachable dev server URL.')
})
})
describe('resolveCapRunArgs', () => {
it('keeps an explicit --target argument untouched', () => {
expect(resolveCapRunArgs(
['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'],
{ CAPACITOR_DEVICE_ID: 'ignored-device' },
)).toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'])
})
it('injects --target from CAPACITOR_DEVICE_ID when it is missing', () => {
expect(resolveCapRunArgs(
['android', '--flavor', 'release'],
{ CAPACITOR_DEVICE_ID: 'emulator-5554' },
)).toEqual(['android', '--target', 'emulator-5554', '--flavor', 'release'])
})
it('supports the --target=value form when checking existing args', () => {
expect(hasCapacitorTargetArg(['android', '--target=emulator-5554'])).toBe(true)
expect(resolveCapRunArgs(
['android', '--target=emulator-5554', '--flavor', 'release'],
{ CAPACITOR_DEVICE_ID: 'ignored-device' },
)).toEqual(['android', '--target=emulator-5554', '--flavor', 'release'])
})
})
describe('shouldRestartForNativeChange', () => {
it('restarts for native source files inside the selected platform directory', () => {
expect(shouldRestartForNativeChange('/repo/app/ios/App/AppDelegate.swift', 'ios', '/repo/app')).toBe(true)
expect(shouldRestartForNativeChange('/repo/app/android/app/src/main/AndroidManifest.xml', 'android', '/repo/app')).toBe(true)
})
it('ignores web-side files and generated native output', () => {
expect(shouldRestartForNativeChange('/repo/app/src/main.ts', 'ios', '/repo/app')).toBe(false)
expect(shouldRestartForNativeChange('/repo/app/ios/App/CapApp-SPM/Package.swift', 'ios', '/repo/app')).toBe(false)
expect(shouldRestartForNativeChange('/repo/app/android/build/generated/file.kt', 'android', '/repo/app')).toBe(false)
})
it('ignores capacitor config json updates', () => {
expect(shouldRestartForNativeChange('/repo/app/android/capacitor.config.json', 'android', '/repo/app')).toBe(false)
})
})
+140
View File
@@ -0,0 +1,140 @@
import type { ViteDevServer } from 'vite'
import process from 'node:process'
import { basename, extname, relative, resolve, sep } from 'node:path'
export type CapacitorPlatform = 'android' | 'ios'
const nativeExtensionsByPlatform: Record<CapacitorPlatform, Set<string>> = {
ios: new Set([
'.entitlements',
'.h',
'.hpp',
'.m',
'.mm',
'.pbxproj',
'.plist',
'.storyboard',
'.strings',
'.swift',
'.xcodeproj',
'.xcconfig',
'.xcscheme',
'.xib',
]),
android: new Set([
'.gradle',
'.java',
'.json',
'.kts',
'.kt',
'.properties',
'.xml',
]),
}
const nativeNamesByPlatform: Record<CapacitorPlatform, Set<string>> = {
ios: new Set([
'Podfile',
'Podfile.lock',
'project.pbxproj',
]),
android: new Set([
'AndroidManifest.xml',
'build.gradle',
'build.gradle.kts',
'gradle.properties',
'settings.gradle',
'settings.gradle.kts',
]),
}
const ignoredNames = new Set([
'capacitor.config.json',
])
const ignoredPathSegments = new Set([
'.gradle',
'DerivedData',
'Pods',
'build',
'xcuserdata',
])
const ignoredPathPrefixesByPlatform: Record<CapacitorPlatform, string[][]> = {
ios: [
['App', 'CapApp-SPM'],
],
android: [],
}
export function parseCapacitorPlatform(value: string | undefined): CapacitorPlatform | null {
return value === 'android' || value === 'ios' ? value : null
}
export function hasCapacitorTargetArg(capArgs: string[]): boolean {
return capArgs.some((arg, index) => arg === '--target' || (index > 0 && arg.startsWith('--target=')))
}
export function resolveCapRunArgs(capArgs: string[], env: NodeJS.ProcessEnv = process.env): string[] {
if (capArgs.length === 0 || hasCapacitorTargetArg(capArgs)) {
return capArgs
}
const target = env.CAPACITOR_DEVICE_ID
if (!target) {
return capArgs
}
const [platform, ...rest] = capArgs
return [platform, '--target', target, ...rest]
}
export function pickServerUrl(server: Pick<ViteDevServer, 'resolvedUrls'>): URL {
const url = server.resolvedUrls?.network?.[0] ?? server.resolvedUrls?.local?.[0]
if (!url) {
throw new Error('Vite did not expose a reachable dev server URL.')
}
return new URL(url)
}
export function shouldRestartForNativeChange(file: string, platform: CapacitorPlatform, cwd: string): boolean {
const absoluteFile = resolve(cwd, file)
const platformRoot = resolve(cwd, platform)
if (!absoluteFile.startsWith(`${platformRoot}${sep}`) && absoluteFile !== platformRoot) {
return false
}
const fileName = basename(absoluteFile)
if (ignoredNames.has(fileName)) {
return false
}
const segments = absoluteFile.split(sep)
if (segments.some(segment => ignoredPathSegments.has(segment))) {
return false
}
const relativeFile = relative(platformRoot, absoluteFile)
const relativeSegments = relativeFile.split(sep).filter(Boolean)
if (ignoredPathPrefixesByPlatform[platform].some(prefix =>
prefix.every((segment, index) => relativeSegments[index] === segment),
)) {
// NOTICE: Capacitor regenerates ios/App/CapApp-SPM/Package.swift during `cap run`.
// Treating that generated tree as a native source change causes an infinite restart loop.
return false
}
if (nativeNamesByPlatform[platform].has(fileName)) {
return true
}
return nativeExtensionsByPlatform[platform].has(extname(fileName).toLowerCase())
}
+124
View File
@@ -0,0 +1,124 @@
import type { Result } from 'tinyexec'
import type { Plugin } from 'vite'
import process from 'node:process'
import { resolve } from 'node:path'
import { x } from 'tinyexec'
import { parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native'
export interface CapVitePluginOptions {
capArgs: string[]
}
async function stopCapProcess(current: Result | undefined) {
if (!current) {
return
}
current.kill('SIGINT')
try {
await current
}
catch {
// tinyexec rejects when a process is stopped during a restart.
}
}
function startCapProcess(cwd: string, capArgs: string[], url: URL) {
console.info('\n----------------------\n')
console.info('Running cap run', ...capArgs)
return x('cap', ['run', ...capArgs], {
throwOnError: false,
nodeOptions: {
cwd,
env: {
CAPACITOR_DEV_SERVER_URL: url.toString(),
},
stdio: 'inherit',
},
})
}
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`.')
}
return {
apply: 'serve',
name: 'cap-vite:run-capacitor',
configureServer(server) {
const cwd = resolve(server.config.root)
const platformRoot = resolve(cwd, platform)
const debounceMs = 300
const logger = server.config.logger
let currentCapProcess: Result | undefined
let shuttingDown = false
let restartTimer: NodeJS.Timeout | undefined
const start = () => {
const url = pickServerUrl(server)
currentCapProcess = startCapProcess(cwd, resolvedCapArgs, url)
}
const restartCapProcess = async (reason: string) => {
if (shuttingDown) {
return
}
logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`)
const previous = currentCapProcess
currentCapProcess = undefined
await stopCapProcess(previous)
start()
}
const onWatcherEvent = (_event, file) => {
if (!shouldRestartForNativeChange(file, platform, cwd)) {
return
}
clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
restartCapProcess(`native file changed: ${resolve(cwd, file)}`)
}, debounceMs)
}
const shutdown = async () => {
if (shuttingDown) {
return
}
shuttingDown = true
clearTimeout(restartTimer)
server.watcher.off('all', onWatcherEvent)
await server.watcher.unwatch(platformRoot)
await stopCapProcess(currentCapProcess)
}
server.watcher.add(platformRoot)
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()
}
})
server.httpServer?.once('close', shutdown)
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
},
}
}
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { capVitePlugin, defineConfig, loadConfigFromFile, mergeConfig } = vi.hoisted(() => ({
capVitePlugin: vi.fn(),
defineConfig: vi.fn((config: unknown) => config),
loadConfigFromFile: vi.fn(),
mergeConfig: vi.fn(),
}))
vi.mock('vite', () => ({
defineConfig,
loadConfigFromFile,
mergeConfig,
}))
vi.mock('./vite-plugin', () => ({
capVitePlugin,
}))
describe('vite-wrapper-config', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
process.env.CAP_VITE_BASE_CONFIG = '/repo/app/vite.config.ts'
process.env.CAP_VITE_CAP_ARGS_JSON = JSON.stringify(['ios', '--target', 'iPhone 16 Pro'])
process.env.CAP_VITE_CONFIG_LOADER = 'runner'
process.env.CAP_VITE_ROOT = '/repo/app'
})
it('loads the user config and merges the injected plugin', async () => {
loadConfigFromFile.mockResolvedValue({
config: {
server: {
port: 5173,
},
},
dependencies: [],
path: '/repo/app/vite.config.ts',
})
mergeConfig.mockImplementation((defaults: Record<string, unknown>, overrides: Record<string, unknown>) => ({
...defaults,
...overrides,
}))
capVitePlugin.mockReturnValue({ name: 'cap-vite:run-capacitor' })
const module = await import('./vite-wrapper-config')
const config = await module.default({
command: 'serve',
mode: 'development',
isPreview: false,
})
expect(defineConfig).toHaveBeenCalledTimes(1)
expect(loadConfigFromFile).toHaveBeenCalledWith(
{
command: 'serve',
isPreview: false,
mode: 'development',
},
'/repo/app/vite.config.ts',
'/repo/app',
undefined,
undefined,
'runner',
)
expect(capVitePlugin).toHaveBeenCalledWith({
capArgs: ['ios', '--target', 'iPhone 16 Pro'],
})
expect(config).toEqual({
plugins: [{ name: 'cap-vite:run-capacitor' }],
server: {
port: 5173,
},
})
})
})
@@ -0,0 +1,51 @@
import process from 'node:process'
import { defineConfig, loadConfigFromFile, mergeConfig } from 'vite'
import { capVitePlugin } from './vite-plugin'
function parseCapArgs(): string[] {
const value = process.env.CAP_VITE_CAP_ARGS_JSON
if (!value) {
return []
}
const parsed = JSON.parse(value)
if (!Array.isArray(parsed) || parsed.some(arg => typeof arg !== 'string')) {
throw new Error('CAP_VITE_CAP_ARGS_JSON must be a JSON string array.')
}
return parsed
}
function parseConfigLoader(): 'bundle' | 'native' | 'runner' | undefined {
const value = process.env.CAP_VITE_CONFIG_LOADER
if (value === 'bundle' || value === 'native' || value === 'runner') {
return value
}
return undefined
}
export default defineConfig(async (env) => {
const root = process.env.CAP_VITE_ROOT ?? process.cwd()
const baseConfigFile = process.env.CAP_VITE_BASE_CONFIG || undefined
const configLoader = parseConfigLoader()
const loaded = await loadConfigFromFile(
env,
baseConfigFile,
root,
undefined,
undefined,
configLoader,
)
return mergeConfig(loaded?.config ?? {}, {
plugins: [
capVitePlugin({
capArgs: parseCapArgs(),
}),
],
})
})
+2
View File
@@ -4,6 +4,8 @@ export default defineConfig({
entry: {
'index': 'src/index.ts',
'bin/run': 'src/bin/run.ts',
'vite-plugin': 'src/vite-plugin.ts',
'vite-wrapper-config': 'src/vite-wrapper-config.ts',
},
target: 'node18',
outDir: 'dist',