mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
perf(build): skip unnecessary work and reclaim Vite caches (#29479)
* perf(build): remove unnecessary development and config work * feat(dev): reclaim inactive worktree Vite caches --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -67,6 +67,8 @@ desktop.ini
|
||||
|
||||
# Lint/cache
|
||||
.oxlintcache
|
||||
/.cache/vite/
|
||||
/.cache/vite.lock/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -3,15 +3,18 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import ts from 'typescript-api'
|
||||
import type ts from 'typescript-api'
|
||||
import type { Plugin } from 'rolldown'
|
||||
import { createWorkspaceSourceResolver } from '../scripts/workspace-source-exports.mjs'
|
||||
|
||||
function configError(diagnostic: ts.Diagnostic): Error {
|
||||
return new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
function configError(diagnostic: ts.Diagnostic, compiler: typeof ts): Error {
|
||||
return new Error(compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
}
|
||||
|
||||
function processHostInputs(root: string): {
|
||||
function processHostInputs(
|
||||
root: string,
|
||||
compiler: typeof ts
|
||||
): {
|
||||
directory: string
|
||||
files: string[]
|
||||
directories: ts.MapLike<ts.WatchDirectoryFlags>
|
||||
@@ -23,14 +26,16 @@ function processHostInputs(root: string): {
|
||||
const manifest = resolve(root, source.manifest)
|
||||
const directory = dirname(manifest)
|
||||
const config = join(directory, 'tsconfig.json')
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(config, undefined, {
|
||||
...ts.sys,
|
||||
const parsed = compiler.getParsedCommandLineOfConfigFile(config, undefined, {
|
||||
...compiler.sys,
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw configError(diagnostic)
|
||||
throw configError(diagnostic, compiler)
|
||||
}
|
||||
})
|
||||
if (!parsed || parsed.errors.length > 0) {
|
||||
throw parsed?.errors[0] ? configError(parsed.errors[0]) : new Error(`Cannot read ${config}`)
|
||||
throw parsed?.errors[0]
|
||||
? configError(parsed.errors[0], compiler)
|
||||
: new Error(`Cannot read ${config}`)
|
||||
}
|
||||
const configSource = parsed.options.configFile
|
||||
const inheritedConfigs =
|
||||
@@ -38,7 +43,7 @@ function processHostInputs(root: string): {
|
||||
? configSource.extendedSourceFiles
|
||||
: []
|
||||
// Follow explicit imports through exclusions without loading automatic type libraries.
|
||||
const sources = ts
|
||||
const sources = compiler
|
||||
.createProgram(parsed.fileNames, { ...parsed.options, noLib: true, types: [] })
|
||||
.getSourceFiles()
|
||||
.map((source) => source.fileName)
|
||||
@@ -65,11 +70,13 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
|
||||
packageDirty = true
|
||||
}
|
||||
},
|
||||
buildStart() {
|
||||
async buildStart() {
|
||||
if (!this.meta.watchMode) {
|
||||
return
|
||||
}
|
||||
const inputs = packageDirty || !cachedInputs ? processHostInputs(root) : cachedInputs
|
||||
const { default: compiler } = await import('typescript-api')
|
||||
const inputs =
|
||||
packageDirty || !cachedInputs ? processHostInputs(root, compiler) : cachedInputs
|
||||
cachedInputs = inputs
|
||||
packageFiles = new Set(inputs.files.map((file) => resolve(file)))
|
||||
for (const file of inputs.files) {
|
||||
@@ -91,16 +98,16 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
|
||||
}
|
||||
}
|
||||
for (const [directory, flags] of Object.entries(inputs.directories)) {
|
||||
const recursive = flags === ts.WatchDirectoryFlags.Recursive
|
||||
const recursive = flags === compiler.WatchDirectoryFlags.Recursive
|
||||
const previous = directoryWatchers.get(directory)
|
||||
if (previous?.recursive === recursive) {
|
||||
continue
|
||||
}
|
||||
previous?.watcher.close()
|
||||
if (!ts.sys.watchDirectory) {
|
||||
if (!compiler.sys.watchDirectory) {
|
||||
throw new Error('The process-host source directory watcher is unavailable')
|
||||
}
|
||||
const watcher = ts.sys.watchDirectory(
|
||||
const watcher = compiler.sys.watchDirectory(
|
||||
directory,
|
||||
(changedPath) => {
|
||||
if (
|
||||
@@ -110,7 +117,7 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
|
||||
!packageFiles.has(resolve(changedPath))
|
||||
) {
|
||||
try {
|
||||
const next = processHostInputs(root)
|
||||
const next = processHostInputs(root, compiler)
|
||||
const files = new Set(next.files.map((file) => resolve(file)))
|
||||
if (
|
||||
files.size === packageFiles.size &&
|
||||
@@ -128,7 +135,7 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
|
||||
}
|
||||
},
|
||||
recursive,
|
||||
{ watchDirectory: ts.WatchDirectoryKind.UseFsEvents }
|
||||
{ watchDirectory: compiler.WatchDirectoryKind.UseFsEvents }
|
||||
)
|
||||
directoryWatchers.set(directory, { recursive, watcher })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
import { retainViteCache } from '../scripts/vite-cache-activity.mjs'
|
||||
|
||||
export function createViteCacheRetentionPlugin(cacheRoot = resolve('.cache/vite')): Plugin {
|
||||
return {
|
||||
name: 'orca-vite-cache-retention',
|
||||
apply: 'serve',
|
||||
async configureServer(server) {
|
||||
const release = await retainViteCache(cacheRoot)
|
||||
const close = server.close.bind(server)
|
||||
// Keep ownership until Vite has stopped its optimizer and outstanding requests.
|
||||
server.close = async (...args) => {
|
||||
try {
|
||||
await close(...args)
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,8 @@ module.exports = {
|
||||
'!config{,/**/*}',
|
||||
// Release archives and other build staging are not runtime resources.
|
||||
'!.build{,/**/*}',
|
||||
// Worktree-local Vite dependency caches are development inputs.
|
||||
'!.cache/vite{,.lock}{,/**/*}',
|
||||
'!docs{,/**/*}',
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
|
||||
@@ -165,7 +165,7 @@ describe('electron-builder config', () => {
|
||||
|
||||
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
|
||||
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
|
||||
it('keeps cached dev Electron bundles out of app.asar', () => {
|
||||
it('keeps cached dev Electron bundles and Vite dependencies out of app.asar', () => {
|
||||
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
||||
matcher.prependPattern('**/*')
|
||||
const isPacked = matcher.createFilter()
|
||||
@@ -173,7 +173,10 @@ describe('electron-builder config', () => {
|
||||
|
||||
for (const devBundlePath of [
|
||||
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
|
||||
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
|
||||
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json',
|
||||
'.cache/vite/renderer/deps/react.js',
|
||||
'.cache/vite/web/deps/react.js',
|
||||
'.cache/vite.lock/owner'
|
||||
]) {
|
||||
expect(packs(devBundlePath)).toBe(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { resolveConfig as resolveElectronConfig } from 'electron-vite'
|
||||
import { resolveConfig } from 'vite'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('ORCA_UNMINIFIED_MAIN', '')
|
||||
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', '')
|
||||
vi.stubEnv('NODE_ENV', process.env.NODE_ENV)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function resolveMainConfig(command: 'build' | 'serve', watch = false) {
|
||||
const resolved = await resolveElectronConfig(
|
||||
{ logLevel: 'silent', ...(watch ? { build: { watch: {} } } : {}) },
|
||||
command,
|
||||
command === 'serve' ? 'development' : 'production'
|
||||
)
|
||||
if (!resolved.config?.main) {
|
||||
throw new Error('Electron Vite did not resolve the main-process configuration')
|
||||
}
|
||||
// Electron Vite runs main through Vite's build command even in development.
|
||||
return resolveConfig({ ...resolved.config.main, configFile: false }, 'build')
|
||||
}
|
||||
|
||||
describe('Electron Vite development compilation', () => {
|
||||
it('keeps the watch-only TypeScript API out of production config loading', async () => {
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`import { createRequire } from 'node:module';
|
||||
import { resolveConfig } from 'electron-vite';
|
||||
await resolveConfig({ logLevel: 'silent' }, 'build');
|
||||
const require = createRequire(import.meta.url);
|
||||
console.log(JSON.stringify({ loaded: Object.hasOwn(require.cache, require.resolve('typescript-api')) }));`
|
||||
],
|
||||
cwd: process.cwd()
|
||||
})
|
||||
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it.each([false, true])('skips main minification in development (watch=%s)', async (watch) => {
|
||||
const resolved = await resolveMainConfig('serve', watch)
|
||||
|
||||
expect(resolved.build.minify).toBe(false)
|
||||
expect(resolved.build.sourcemap).toBe('hidden')
|
||||
expect(resolved.build.reportCompressedSize).toBe(false)
|
||||
expect(Boolean(resolved.build.watch)).toBe(watch)
|
||||
})
|
||||
|
||||
it('keeps production minification and crash source maps', async () => {
|
||||
const resolved = await resolveMainConfig('build')
|
||||
|
||||
expect(resolved.build.minify).toBe('oxc')
|
||||
expect(resolved.build.sourcemap).toBe('hidden')
|
||||
expect(resolved.build.reportCompressedSize).toBe(false)
|
||||
})
|
||||
|
||||
it('honors the unminified main diagnostic override in production', async () => {
|
||||
vi.stubEnv('ORCA_UNMINIFIED_MAIN', '1')
|
||||
|
||||
const resolved = await resolveMainConfig('build')
|
||||
|
||||
expect(resolved.build.minify).toBe(false)
|
||||
expect(resolved.build.sourcemap).toBe('hidden')
|
||||
})
|
||||
|
||||
it('honors the development source-map override alongside unminified compilation', async () => {
|
||||
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', '0')
|
||||
|
||||
const development = await resolveMainConfig('serve')
|
||||
const production = await resolveMainConfig('build')
|
||||
|
||||
expect(development.build.minify).toBe(false)
|
||||
expect(development.build.sourcemap).toBe(false)
|
||||
expect(production.build.minify).toBe('oxc')
|
||||
expect(production.build.sourcemap).toBe('hidden')
|
||||
})
|
||||
|
||||
it('keeps renderer dependency caches in this worktree and skips gzip reporting', async () => {
|
||||
const { config } = await resolveElectronConfig({ logLevel: 'silent' }, 'serve')
|
||||
if (!config?.renderer) {
|
||||
throw new Error('Electron Vite did not resolve the renderer configuration')
|
||||
}
|
||||
|
||||
const resolved = await resolveConfig({ ...config.renderer, configFile: false }, 'serve')
|
||||
|
||||
expect(resolved.cacheDir).toBe(resolve('.cache/vite/renderer').replaceAll('\\', '/'))
|
||||
expect(resolved.build.reportCompressedSize).toBe(false)
|
||||
})
|
||||
|
||||
it('gives the web client its own worktree cache and skips gzip reporting', async () => {
|
||||
const resolved = await resolveConfig(
|
||||
{ configFile: resolve('vite.web.config.ts'), logLevel: 'silent' },
|
||||
'serve'
|
||||
)
|
||||
|
||||
expect(resolved.cacheDir).toBe(resolve('.cache/vite/web').replaceAll('\\', '/'))
|
||||
expect(resolved.build.reportCompressedSize).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -110,13 +110,13 @@ describe('Electron Vite output contract', () => {
|
||||
it('can skip main source maps during development without changing build output settings', () => {
|
||||
try {
|
||||
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', undefined)
|
||||
expect(configureElectronVite({ command: 'serve', mode: 'development' })).toBe(
|
||||
electronViteConfig
|
||||
)
|
||||
const defaults = configureElectronVite({ command: 'serve', mode: 'development' })
|
||||
expect(defaults.main?.build?.sourcemap).toBe('hidden')
|
||||
expect(defaults.main?.build?.minify).toBe(false)
|
||||
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', '0')
|
||||
const development = configureElectronVite({ command: 'serve', mode: 'development' })
|
||||
expect(development.main?.build?.sourcemap).toBe(false)
|
||||
expect(development.main?.build?.minify).toBe(electronViteConfig.main?.build?.minify)
|
||||
expect(development.main?.build?.minify).toBe(false)
|
||||
expect(development.main?.build?.rollupOptions).toBe(
|
||||
electronViteConfig.main?.build?.rollupOptions
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { decodeGitCQuotedPath } from '../../src/shared/git-cquoted-path.ts'
|
||||
|
||||
async function git(repoRoot, args) {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 30_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
terminationBarrier: true
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error('Could not read the local Git repository')
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
export async function localRepositoryWorktrees(repoRoot) {
|
||||
const common = (await git(repoRoot, ['rev-parse', '--git-common-dir'])).replace(
|
||||
process.platform === 'win32' ? /\r?\n$/ : /\n$/,
|
||||
''
|
||||
)
|
||||
if (!common || common.includes('\n')) {
|
||||
throw new Error('Unrecognised Git common directory')
|
||||
}
|
||||
const output = await git(repoRoot, [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'worktree',
|
||||
'list',
|
||||
'--porcelain'
|
||||
])
|
||||
const worktrees = []
|
||||
for (const block of output.split(/\r?\n\r?\n/)) {
|
||||
const lines = block.split(/\r?\n/).filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
lines.some(
|
||||
(line) =>
|
||||
!/^(worktree |HEAD |branch |bare$|detached$|locked(?: |$)|prunable(?: |$))/.test(line)
|
||||
)
|
||||
) {
|
||||
throw new Error('Ambiguous worktree listing; refusing cleanup')
|
||||
}
|
||||
const field = lines.find((line) => line.startsWith('worktree '))
|
||||
if (field && !lines.includes('bare')) {
|
||||
worktrees.push(decodeGitCQuotedPath(field.slice(9)))
|
||||
}
|
||||
}
|
||||
return { commonDirectory: await realpath(resolve(repoRoot, common)), worktrees }
|
||||
}
|
||||
@@ -344,7 +344,7 @@ describe('External process-host development rebuilds', () => {
|
||||
if (typeof buildStart !== 'function') {
|
||||
throw new Error('The process-host rebuild plugin must have a buildStart hook')
|
||||
}
|
||||
rebuildPlugin.buildStart = function (...args) {
|
||||
rebuildPlugin.buildStart = async function (...args) {
|
||||
const addWatchFile = this.addWatchFile.bind(this)
|
||||
const registration = vi.spyOn(this, 'addWatchFile').mockImplementation((file) => {
|
||||
addWatchFile(file)
|
||||
@@ -353,7 +353,7 @@ describe('External process-host development rebuilds', () => {
|
||||
}
|
||||
})
|
||||
try {
|
||||
return buildStart.apply(this, args)
|
||||
return await buildStart.apply(this, args)
|
||||
} finally {
|
||||
registration.mockRestore()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat, readFile, readdir, realpath, rm } from 'node:fs/promises'
|
||||
import { lstat, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { lock } from 'proper-lockfile'
|
||||
import { decodeGitCQuotedPath } from '../../src/shared/git-cquoted-path.ts'
|
||||
import { localRepositoryWorktrees } from './local-repository-worktrees.mjs'
|
||||
import { CHECKOUT_FORMAT } from '../../tests/e2e/cross-version-wire/release-checkout-publication.ts'
|
||||
import { withReleaseSourceFormatLease } from '../../tests/e2e/cross-version-wire/release-source-retention.ts'
|
||||
|
||||
@@ -70,58 +69,6 @@ async function logicalBytes(root) {
|
||||
return bytes
|
||||
}
|
||||
|
||||
async function git(repoRoot, args) {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 30_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
terminationBarrier: true
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error('Could not read the local Git repository')
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
async function repositoryTargets(repoRoot) {
|
||||
const common = (await git(repoRoot, ['rev-parse', '--git-common-dir'])).replace(
|
||||
process.platform === 'win32' ? /\r?\n$/ : /\n$/,
|
||||
''
|
||||
)
|
||||
if (!common || common.includes('\n')) {
|
||||
throw new Error('Unrecognised Git common directory')
|
||||
}
|
||||
const output = await git(repoRoot, [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'worktree',
|
||||
'list',
|
||||
'--porcelain'
|
||||
])
|
||||
const worktrees = []
|
||||
for (const block of output.split(/\r?\n\r?\n/)) {
|
||||
const lines = block.split(/\r?\n/).filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
lines.some(
|
||||
(line) =>
|
||||
!/^(worktree |HEAD |branch |bare$|detached$|locked(?: |$)|prunable(?: |$))/.test(line)
|
||||
)
|
||||
) {
|
||||
throw new Error('Ambiguous worktree listing; refusing cleanup')
|
||||
}
|
||||
const field = lines.find((line) => line.startsWith('worktree '))
|
||||
if (field && !lines.includes('bare')) {
|
||||
worktrees.push(decodeGitCQuotedPath(field.slice(9)))
|
||||
}
|
||||
}
|
||||
return { commonDirectory: await realpath(resolve(repoRoot, common)), worktrees }
|
||||
}
|
||||
|
||||
export async function reclaimReleaseTestCaches({
|
||||
repoRoot = process.cwd(),
|
||||
apply = false,
|
||||
@@ -139,7 +86,7 @@ export async function reclaimReleaseTestCaches({
|
||||
'Stop cross-version tests in this repository, then pass --tests-stopped with --apply'
|
||||
)
|
||||
}
|
||||
const { commonDirectory, worktrees } = targets ?? (await repositoryTargets(repoRoot))
|
||||
const { commonDirectory, worktrees } = targets ?? (await localRepositoryWorktrees(repoRoot))
|
||||
const cutoff = nowMs - minAgeDays * DAY_MS
|
||||
const result = { entries: 0, logicalBytes: 0, protected: 0 }
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { localRepositoryWorktrees } from './local-repository-worktrees.mjs'
|
||||
import { isProcessAlive } from './remote-agent-session-process-cleanup.mjs'
|
||||
import { parseReclaimArguments } from './reclaim-worktree-command.mjs'
|
||||
import {
|
||||
VITE_CACHE_HOST_ID,
|
||||
VITE_CACHE_USAGE_DIRECTORY,
|
||||
withViteCacheLock
|
||||
} from './vite-cache-activity.mjs'
|
||||
|
||||
const OWNER = /^([\da-f]{64})-([1-9]\d*)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/
|
||||
|
||||
async function directory(path) {
|
||||
try {
|
||||
return (await lstat(path)).isDirectory()
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheDirectory(worktree) {
|
||||
if (!(await directory(join(worktree, '.cache')))) {
|
||||
return null
|
||||
}
|
||||
const root = join(worktree, '.cache', 'vite')
|
||||
return (await directory(root)) ? root : null
|
||||
}
|
||||
|
||||
async function unusedCache(cacheRoot, cutoff, isAlive) {
|
||||
try {
|
||||
const marker = join(cacheRoot, VITE_CACHE_USAGE_DIRECTORY)
|
||||
const info = await lstat(marker)
|
||||
if (!info.isDirectory() || info.mtimeMs > cutoff) {
|
||||
return false
|
||||
}
|
||||
for (const name of await readdir(marker)) {
|
||||
const match = OWNER.exec(name)
|
||||
if (!match || match[1] !== VITE_CACHE_HOST_ID) {
|
||||
return false
|
||||
}
|
||||
const pid = Number(match[2])
|
||||
const info = await lstat(join(marker, name))
|
||||
if (!Number.isSafeInteger(pid) || !info.isFile() || info.size !== 0 || isAlive(pid)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function reclaimViteCaches({
|
||||
repoRoot = process.cwd(),
|
||||
apply = false,
|
||||
excludeWorktree,
|
||||
minAgeDays = 30,
|
||||
nowMs = Date.now(),
|
||||
worktrees,
|
||||
isAlive = isProcessAlive,
|
||||
report = console.log
|
||||
} = {}) {
|
||||
if (!Number.isFinite(minAgeDays) || minAgeDays < 0) {
|
||||
throw new Error('Vite cache age must be a non-negative number')
|
||||
}
|
||||
const excluded = excludeWorktree ? await realpath(excludeWorktree) : null
|
||||
const roots = worktrees ?? (await localRepositoryWorktrees(repoRoot)).worktrees
|
||||
const result = { entries: 0, protected: 0 }
|
||||
for (const worktree of new Set(roots)) {
|
||||
if (!(await directory(worktree))) {
|
||||
continue
|
||||
}
|
||||
const canonical = await realpath(worktree)
|
||||
if (canonical === excluded) {
|
||||
continue
|
||||
}
|
||||
const cacheRoot = await cacheDirectory(canonical)
|
||||
if (!cacheRoot) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await withViteCacheLock(
|
||||
cacheRoot,
|
||||
async (verify) => {
|
||||
if (
|
||||
(await cacheDirectory(canonical)) !== cacheRoot ||
|
||||
!(await unusedCache(cacheRoot, nowMs - minAgeDays * 86_400_000, isAlive))
|
||||
) {
|
||||
result.protected += 1
|
||||
return
|
||||
}
|
||||
verify()
|
||||
if (apply) {
|
||||
await rm(cacheRoot, { recursive: true, force: true })
|
||||
}
|
||||
result.entries += 1
|
||||
report(`${apply ? 'removed' : 'would remove'} Vite cache ${JSON.stringify(cacheRoot)}`)
|
||||
},
|
||||
0
|
||||
)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ELOCKED') {
|
||||
throw error
|
||||
}
|
||||
result.protected += 1
|
||||
}
|
||||
}
|
||||
report(
|
||||
`${apply ? 'Removed' : 'Would remove'} ${result.entries} Vite cache(s); retained ${result.protected} recent, active, or unverifiable cache(s).`
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const options = parseReclaimArguments(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log('Usage: pnpm reclaim-space [--apply] [--repo DIRECTORY]')
|
||||
} else {
|
||||
reclaimViteCaches(options).catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
utimes,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { createServer } from 'vite'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { createViteCacheRetentionPlugin } from '../build-plugins/vite-cache-retention'
|
||||
import { reclaimViteCaches } from './reclaim-vite-caches.mjs'
|
||||
import {
|
||||
retainViteCache,
|
||||
VITE_CACHE_USAGE_DIRECTORY,
|
||||
withViteCacheLock
|
||||
} from './vite-cache-activity.mjs'
|
||||
|
||||
const roots = []
|
||||
const nowMs = Date.now()
|
||||
const dayMs = 86_400_000
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
for (const root of roots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function age(cacheRoot, days = 31) {
|
||||
const time = new Date(nowMs - days * dayMs)
|
||||
await utimes(join(cacheRoot, VITE_CACHE_USAGE_DIRECTORY), time, time)
|
||||
}
|
||||
|
||||
async function fixture(days = 31) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-vite-cache-'))
|
||||
roots.push(root)
|
||||
const worktree = join(root, 'worktree')
|
||||
const cacheRoot = join(worktree, '.cache', 'vite')
|
||||
const release = await retainViteCache(cacheRoot)
|
||||
await release()
|
||||
const files = ['renderer', 'web'].map((target) => join(cacheRoot, target, 'deps', 'react.js'))
|
||||
for (const file of files) {
|
||||
await mkdir(resolve(file, '..'), { recursive: true })
|
||||
await writeFile(file, 'export const cached = true')
|
||||
}
|
||||
await age(cacheRoot, days)
|
||||
return {
|
||||
root,
|
||||
worktree,
|
||||
cacheRoot,
|
||||
files,
|
||||
worktrees: [worktree, worktree],
|
||||
nowMs,
|
||||
report: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
it('previews and removes old owned caches for both build targets', async () => {
|
||||
const options = await fixture()
|
||||
expect(await reclaimViteCaches(options)).toMatchObject({ entries: 1 })
|
||||
for (const file of options.files) {
|
||||
expect(await readFile(file, 'utf8')).toContain('cached')
|
||||
}
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 1 })
|
||||
for (const file of options.files) {
|
||||
await expect(readFile(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a recently used cache even when its dependency outputs are old', async () => {
|
||||
const options = await fixture(1)
|
||||
for (const file of options.files) {
|
||||
await utimes(file, new Date(0), new Date(0))
|
||||
}
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
})
|
||||
|
||||
it('retains every live owner and refreshes last use when a server closes', async () => {
|
||||
const options = await fixture()
|
||||
const first = await retainViteCache(options.cacheRoot)
|
||||
const second = await retainViteCache(options.cacheRoot)
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
await first()
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
await second()
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 1 })
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'reclaims exited owners only on this host (foreign: %s)',
|
||||
async (foreign) => {
|
||||
const options = await fixture()
|
||||
const moduleUrl = pathToFileURL(resolve(import.meta.dirname, 'vite-cache-activity.mjs')).href
|
||||
const child = await runProcess({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`import { retainViteCache } from ${JSON.stringify(moduleUrl)}; await retainViteCache(${JSON.stringify(options.cacheRoot)});`
|
||||
],
|
||||
env: foreign
|
||||
? { ...process.env, WSL_DISTRO_NAME: `${process.env.WSL_DISTRO_NAME ?? ''}-other` }
|
||||
: process.env,
|
||||
timeoutMs: 10_000,
|
||||
terminationBarrier: true
|
||||
})
|
||||
expect(child.code, child.stderr).toBe(0)
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({
|
||||
entries: foreign ? 0 : 1
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['foreign-host', 'corrupt', 'permission-denied'])(
|
||||
'retains unverifiable ownership: %s',
|
||||
async (kind) => {
|
||||
const options = await fixture()
|
||||
const release = await retainViteCache(options.cacheRoot)
|
||||
const owners = join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY)
|
||||
const [name] = await readdir(owners)
|
||||
const owner = join(owners, name)
|
||||
if (kind === 'foreign-host') {
|
||||
await rename(owner, join(owners, name.replace(/^[\da-f]{64}/, '0'.repeat(64))))
|
||||
} else if (kind === 'corrupt') {
|
||||
await writeFile(owner, '{')
|
||||
} else {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('denied'), { code: 'EPERM' })
|
||||
})
|
||||
}
|
||||
try {
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
await release()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('retains untracked caches and the worktree being launched', async () => {
|
||||
const options = await fixture()
|
||||
expect(
|
||||
await reclaimViteCaches({ ...options, apply: true, excludeWorktree: options.worktree })
|
||||
).toMatchObject({ entries: 0 })
|
||||
await rm(join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY), { recursive: true })
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
})
|
||||
|
||||
it.each(['.cache', 'vite'])('does not follow a %s directory link during cleanup', async (part) => {
|
||||
const options = await fixture()
|
||||
const source = part === '.cache' ? join(options.worktree, '.cache') : options.cacheRoot
|
||||
const target = join(options.root, 'outside')
|
||||
await rename(source, target)
|
||||
await symlink(target, source, 'junction')
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
expect(await readdir(target)).not.toEqual([])
|
||||
})
|
||||
|
||||
it('does not sweep while cache initialization holds the maintenance lock', async () => {
|
||||
const options = await fixture()
|
||||
await withViteCacheLock(options.cacheRoot, async () => {
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({
|
||||
entries: 0,
|
||||
protected: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks a real Vite server until shutdown has completed', async () => {
|
||||
const options = await fixture()
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
root: options.worktree,
|
||||
cacheDir: join(options.cacheRoot, 'renderer'),
|
||||
logLevel: 'silent',
|
||||
server: { middlewareMode: true, watch: null, hmr: false },
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
plugins: [createViteCacheRetentionPlugin(options.cacheRoot)]
|
||||
})
|
||||
try {
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
expect(await readdir(join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY))).toEqual([])
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
|
||||
await age(options.cacheRoot)
|
||||
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 1 })
|
||||
})
|
||||
@@ -53,7 +53,7 @@ export async function runReclaimCommand(
|
||||
'Usage: pnpm reclaim-space [--apply] [--repo DIRECTORY] [--tests-stopped] [--release-cache-min-age-days 30]\n'
|
||||
)
|
||||
output.write(
|
||||
'Shares Electron installs and removes idle dev bundles across local Git worktrees.\n'
|
||||
'Shares Electron installs and removes idle dev bundles and Vite caches unused for 30 days across local Git worktrees.\n'
|
||||
)
|
||||
output.write('Without --apply, previews cleanup and offers [y/N] in an interactive terminal.\n')
|
||||
output.write(
|
||||
|
||||
@@ -43,7 +43,11 @@ export async function reclaimWorktreeSpace({
|
||||
let cleanupFailed = false
|
||||
let cleanupError
|
||||
try {
|
||||
const scripts = ['reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs']
|
||||
const scripts = [
|
||||
'reclaim-electron-dists.mjs',
|
||||
'reclaim-dev-electron-bundles.mjs',
|
||||
'reclaim-vite-caches.mjs'
|
||||
]
|
||||
if (!automatic && testsStopped) {
|
||||
scripts.unshift('reclaim-release-test-caches.mjs')
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { reclaimWorktreeSpace } from './reclaim-worktree-space.mjs'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { retainViteCache, VITE_CACHE_USAGE_DIRECTORY } from './vite-cache-activity.mjs'
|
||||
|
||||
const roots = []
|
||||
afterEach(async () => {
|
||||
@@ -18,17 +19,17 @@ async function fixture() {
|
||||
return { commonDirectory: root, repoRoot: root, execute }
|
||||
}
|
||||
|
||||
it('previews historical test caches and both Electron cleanup operations', async () => {
|
||||
it('previews historical test caches, Electron cleanup, and Vite caches', async () => {
|
||||
const options = { ...(await fixture()), testsStopped: true }
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
expect(options.execute).toHaveBeenCalledTimes(4)
|
||||
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply')
|
||||
})
|
||||
|
||||
it('forwards apply and repository selection to all cleanup operations', async () => {
|
||||
const options = { ...(await fixture()), apply: true, testsStopped: true }
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
expect(options.execute).toHaveBeenCalledTimes(4)
|
||||
for (const [spec] of options.execute.mock.calls) {
|
||||
expect(spec.args).toContain('--apply')
|
||||
expect(spec.args).toContain(options.repoRoot)
|
||||
@@ -42,10 +43,10 @@ it('forwards apply and repository selection to all cleanup operations', async ()
|
||||
}
|
||||
})
|
||||
|
||||
it('applies Electron cleanup while retaining unacknowledged test caches', async () => {
|
||||
it('applies Electron and Vite cleanup while retaining unacknowledged test caches', async () => {
|
||||
const options = { ...(await fixture()), apply: true }
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(2)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain(
|
||||
'reclaim-release-test-caches'
|
||||
)
|
||||
@@ -110,6 +111,13 @@ it('runs the CLI outside the repository and retains historical caches until ackn
|
||||
const marker = join(cache, 'checkout-stamp.json')
|
||||
await writeFile(marker, JSON.stringify({ commit, format: 4 }))
|
||||
await utimes(cache, new Date(0), new Date(0))
|
||||
const viteCache = join(options.repoRoot, '.cache', 'vite')
|
||||
const releaseCache = await retainViteCache(viteCache)
|
||||
await releaseCache()
|
||||
await mkdir(join(viteCache, 'renderer', 'deps'), { recursive: true })
|
||||
const dependency = join(viteCache, 'renderer', 'deps', 'react.js')
|
||||
await writeFile(dependency, 'export const cached = true')
|
||||
await utimes(join(viteCache, VITE_CACHE_USAGE_DIRECTORY), new Date(0), new Date(0))
|
||||
const run = (flags = []) =>
|
||||
runProcess({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
@@ -128,10 +136,13 @@ it('runs the CLI outside the repository and retains historical caches until ackn
|
||||
expect(preview.code, preview.stderr).toBe(0)
|
||||
expect(preview.stdout).toContain('Would remove 1 cache(s)')
|
||||
expect(await readFile(marker, 'utf8')).toContain(commit)
|
||||
expect(await readFile(dependency, 'utf8')).toContain('cached')
|
||||
const safe = await run(['--apply'])
|
||||
expect(safe.code, safe.stderr).toBe(0)
|
||||
expect(safe.stdout).toContain('Historical test caches retained')
|
||||
expect(await readFile(marker, 'utf8')).toContain(commit)
|
||||
expect(safe.stdout).toContain('Removed 1 Vite cache(s)')
|
||||
await expect(readFile(dependency)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
const applied = await run(['--apply', '--tests-stopped'])
|
||||
expect(applied.code, applied.stderr).toBe(0)
|
||||
expect(applied.stdout).toContain('Removed 1 cache(s)')
|
||||
@@ -161,7 +172,7 @@ it('rejects competing sweeps and releases the lock after a failed operation', as
|
||||
await rejected
|
||||
options.execute.mockResolvedValue({ code: 0, timedOut: false })
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(4)
|
||||
expect(options.execute).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => {
|
||||
@@ -188,5 +199,5 @@ it('fails if cleanup succeeds but the repository lock cannot be released', async
|
||||
await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe(
|
||||
failure
|
||||
)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
expect(options.execute).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { hostname } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { lock } from 'proper-lockfile'
|
||||
|
||||
export const VITE_CACHE_USAGE_DIRECTORY = '.orca-vite-cache-v1'
|
||||
// Windows and WSL can share a hostname while using separate PID namespaces.
|
||||
export const VITE_CACHE_HOST_ID = createHash('sha256')
|
||||
.update(JSON.stringify([process.platform, hostname(), process.env.WSL_DISTRO_NAME ?? '']))
|
||||
.digest('hex')
|
||||
|
||||
export async function withViteCacheLock(cacheRoot, action, retries = 100) {
|
||||
await mkdir(dirname(cacheRoot), { recursive: true })
|
||||
let compromised
|
||||
const release = await lock(cacheRoot, {
|
||||
realpath: false,
|
||||
stale: 60_000,
|
||||
retries: { retries, minTimeout: 50, maxTimeout: 250 },
|
||||
onCompromised: (error) => {
|
||||
compromised = error
|
||||
}
|
||||
})
|
||||
const verify = () => {
|
||||
if (compromised) {
|
||||
throw compromised
|
||||
}
|
||||
}
|
||||
try {
|
||||
verify()
|
||||
const result = await action(verify)
|
||||
verify()
|
||||
return result
|
||||
} finally {
|
||||
if (!compromised) {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function retainViteCache(cacheRoot) {
|
||||
const usage = join(cacheRoot, VITE_CACHE_USAGE_DIRECTORY)
|
||||
const owner = join(usage, `${VITE_CACHE_HOST_ID}-${process.pid}-${randomUUID()}`)
|
||||
await withViteCacheLock(cacheRoot, async (verify) => {
|
||||
await mkdir(dirname(owner), { recursive: true })
|
||||
verify()
|
||||
await writeFile(owner, '', { flag: 'wx' })
|
||||
await utimes(usage, new Date(), new Date())
|
||||
})
|
||||
return () =>
|
||||
withViteCacheLock(cacheRoot, async (verify) => {
|
||||
verify()
|
||||
await rm(owner, { force: true })
|
||||
await utimes(usage, new Date(), new Date())
|
||||
})
|
||||
}
|
||||
@@ -18,9 +18,10 @@ pnpm reclaim-space --tests-stopped
|
||||
|
||||
Answer `Y` to apply that preview, or use `pnpm reclaim-space --apply --tests-stopped` to apply directly.
|
||||
|
||||
The command reuses the historical test cache, Electron distribution, and dev bundle cleanup scripts.
|
||||
The command reuses the historical test cache, Electron distribution, dev bundle, and Vite cache cleanup scripts.
|
||||
It shares complete Electron distributions through the repository cache and removes idle
|
||||
`out/electron-dev` bundles, which development rebuilds on demand. With `--tests-stopped`, it also
|
||||
`out/electron-dev` bundles, which development rebuilds on demand. It also removes tracked
|
||||
`.cache/vite` dependency caches unused for 30 days. With `--tests-stopped`, it also
|
||||
removes stamped historical test checkouts and shared seeds older than 30 days; tests regenerate them when needed. It leaves source files,
|
||||
dependency installs, worktree registrations, and other build output alone. Reported sizes are
|
||||
logical file data; APFS clones and hardlinks mean actual disk savings can be smaller.
|
||||
@@ -33,6 +34,11 @@ checkout is unused. Without that flag, historical caches are excluded from both
|
||||
Cache cleanup skips symlinks, malformed stamps, future formats, and publication/copy locks.
|
||||
See [release source sharing](./release-source-sharing.md) for the cache and locking details.
|
||||
|
||||
Vite dev servers record cache use at startup and shutdown. Cleanup retains recent caches,
|
||||
caches owned by a live process, and caches whose ownership cannot be verified, including caches
|
||||
created before tracking was added. A maintenance lock prevents cleanup racing with server startup.
|
||||
Desktop and web caches are reclaimed together; the next dev launch regenerates them as needed.
|
||||
|
||||
Use `--repo DIRECTORY` to choose another local repository. Git's worktree list determines scope,
|
||||
including registered worktrees outside Orca's workspace directory. Folder workspaces without Git
|
||||
have no repository-wide sweep. These commands run on the local host: they do not inspect or delete
|
||||
@@ -45,9 +51,9 @@ without waiting for an Electron upgrade. The manual command migrates older insta
|
||||
Read-only Electron package directories are retained and reported as skipped. Cache publication
|
||||
or copying failures are also reported as skipped rather than silently omitted.
|
||||
|
||||
`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide Electron cleanup worker
|
||||
`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide Electron and Vite cleanup worker
|
||||
in the background after the dev process starts. It never prompts or waits before launching the app.
|
||||
The worktree being launched is excluded from both Electron sharing and dev bundle removal; active
|
||||
The worktree being launched is excluded from Electron sharing, dev bundle removal, and Vite cleanup; active
|
||||
sibling apps and recent unfinished builds remain protected by the existing bundle checks.
|
||||
Background sweep output is suppressed; worker start or exit failures are reported without stopping dev. Set `ORCA_SKIP_DEV_STORAGE_RECLAIM=1` to opt out.
|
||||
The worker finishes independently if the dev app exits, and each cleanup operation has a ten-minute limit.
|
||||
|
||||
+20
-3
@@ -8,6 +8,7 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
|
||||
import { createProcessHostDevRebuildPlugin } from './config/build-plugins/process-host-dev-rebuild'
|
||||
import { createViteCacheRetentionPlugin } from './config/build-plugins/vite-cache-retention'
|
||||
import { MAIN_BUILD_ENTRY_POINTS } from './config/build-plugins/main-build-entry-points'
|
||||
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
|
||||
import { resolveBuildSourceCommit } from './config/scripts/build-source-commit.mjs'
|
||||
@@ -294,6 +295,8 @@ export const electronViteConfig: UserConfig = {
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
// Shared node_modules installs must not share dependency optimization state across worktrees.
|
||||
cacheDir: resolve('.cache/vite/renderer'),
|
||||
define: {
|
||||
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL,
|
||||
ORCA_LAYOUT_OWNER: ORCA_LAYOUT_OWNER_LITERAL
|
||||
@@ -305,12 +308,18 @@ export const electronViteConfig: UserConfig = {
|
||||
'@': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react(), tailwindcss(), createPdfjsViewerAssetsPlugin()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
createPdfjsViewerAssetsPlugin(),
|
||||
createViteCacheRetentionPlugin()
|
||||
],
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
manifest: true,
|
||||
reportCompressedSize: false,
|
||||
modulePreload: { polyfill: true },
|
||||
minify: 'oxc',
|
||||
target: 'es2020',
|
||||
@@ -334,14 +343,22 @@ export const electronViteConfig: UserConfig = {
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
if (command !== 'serve' || process.env.ORCA_DEV_MAIN_SOURCEMAPS !== '0') {
|
||||
if (command !== 'serve') {
|
||||
return electronViteConfig
|
||||
}
|
||||
// Electron Vite also uses Vite's build command for the development main process.
|
||||
return {
|
||||
...electronViteConfig,
|
||||
main: {
|
||||
...electronViteConfig.main,
|
||||
build: { ...electronViteConfig.main?.build, sourcemap: false }
|
||||
build: {
|
||||
...electronViteConfig.main?.build,
|
||||
minify: false,
|
||||
sourcemap:
|
||||
process.env.ORCA_DEV_MAIN_SOURCEMAPS === '0'
|
||||
? false
|
||||
: electronViteConfig.main?.build?.sourcemap
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+9
-1
@@ -4,14 +4,21 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
|
||||
import { createViteCacheRetentionPlugin } from './config/build-plugins/vite-cache-retention'
|
||||
import { resolveBuildSourceCommit } from './config/scripts/build-source-commit.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
root: resolve('src/renderer'),
|
||||
cacheDir: resolve('.cache/vite/web'),
|
||||
// Why: pairing URLs may live under a reverse-proxy path prefix like
|
||||
// /orca/web-index.html, so built assets must resolve relative to the page.
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss(), createPdfjsViewerAssetsPlugin()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
createPdfjsViewerAssetsPlugin(),
|
||||
createViteCacheRetentionPlugin()
|
||||
],
|
||||
define: {
|
||||
ORCA_FEATURE_WALL_ENABLED: 'true',
|
||||
ORCA_BUILD_COMMIT: JSON.stringify(resolveBuildSourceCommit())
|
||||
@@ -26,6 +33,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: resolve('out/web'),
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
input: resolve('src/renderer/web-index.html')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user