Compare commits
No commits in common. "769b564d207e95775b44bff708ac9d6ccfaae9e2" and "32e52a030f9202a39c8bdf31c8baec9dc4e14cce" have entirely different histories.
769b564d20
...
32e52a030f
|
|
@ -40,7 +40,7 @@ async function get<T>(url: string): Promise<T | undefined> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
|
async function getManifest(appName: string): Promise<{ exists: boolean; manifest?: Manifest } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
|
|
@ -276,7 +276,7 @@ async function getApp(name: string) {
|
||||||
console.log(color.green(`✓ Downloaded ${name}`))
|
console.log(color.green(`✓ Downloaded ${name}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppPackage(): { name?: string, scripts?: { toes?: string } } | null {
|
function getAppPackage(): { name?: string; scripts?: { toes?: string } } | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
|
return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -579,7 +579,7 @@ async function syncApp() {
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data: ')) continue
|
if (!line.startsWith('data: ')) continue
|
||||||
const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string }
|
const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete'; path: string; hash?: string }
|
||||||
|
|
||||||
if (event.type === 'change') {
|
if (event.type === 'change') {
|
||||||
// Skip if we already have this version (handles echo from our own changes)
|
// Skip if we already have this version (handles echo from our own changes)
|
||||||
|
|
@ -621,37 +621,6 @@ async function prompt(message: string): Promise<string> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameApp(arg: string | undefined, newName: string) {
|
|
||||||
const name = resolveAppName(arg)
|
|
||||||
if (!name) return
|
|
||||||
|
|
||||||
const result = await getManifest(name)
|
|
||||||
if (result === null) return
|
|
||||||
if (!result.exists) {
|
|
||||||
console.error(`App not found on server: ${name}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = `sudo rename ${name} ${newName}`
|
|
||||||
console.log(`This will rename ${color.bold(name)} to ${color.bold(newName)}.`)
|
|
||||||
const answer = await prompt(`Type "${expected}" to confirm: `)
|
|
||||||
|
|
||||||
if (answer !== expected) {
|
|
||||||
console.log('Aborted.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await post<{ ok: boolean, error?: string, name?: string }>(`/api/apps/${name}/rename`, { name: newName })
|
|
||||||
if (!response) return
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(color.red(`Error: ${response.error}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(color.green(`✓ Renamed ${name} to ${response.name}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rmApp(arg?: string) {
|
async function rmApp(arg?: string) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
@ -783,11 +752,4 @@ program
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(rmApp)
|
.action(rmApp)
|
||||||
|
|
||||||
program
|
|
||||||
.command('rename')
|
|
||||||
.description('Rename an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.argument('<new-name>', 'new app name')
|
|
||||||
.action(renameApp)
|
|
||||||
|
|
||||||
program.parse()
|
program.parse()
|
||||||
|
|
@ -623,7 +623,7 @@ async function doRenameApp(input: HTMLInputElement) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
let data: { ok?: boolean, error?: string, name?: string }
|
let data: { ok?: boolean; error?: string; name?: string }
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text)
|
data = JSON.parse(text)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { App as SharedApp, AppState } from '@types'
|
import type { App as SharedApp, AppState, LogLine } from '@types'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
||||||
|
|
@ -7,37 +7,17 @@ import { join } from 'path'
|
||||||
export type { AppState } from '@types'
|
export type { AppState } from '@types'
|
||||||
|
|
||||||
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
||||||
|
|
||||||
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
|
||||||
const HEALTH_CHECK_INTERVAL = 30000
|
|
||||||
const HEALTH_CHECK_TIMEOUT = 5000
|
|
||||||
const MAX_LOGS = 100
|
const MAX_LOGS = 100
|
||||||
const MAX_PORT = 3100
|
|
||||||
const MIN_PORT = 3001
|
|
||||||
const RESTART_DELAYS = [1000, 2000, 4000, 8000, 16000, 32000]
|
|
||||||
const SHUTDOWN_TIMEOUT = 10000
|
|
||||||
const STABLE_RUN_TIME = 60000
|
|
||||||
const STARTUP_TIMEOUT = 30000
|
|
||||||
|
|
||||||
const _appPorts = new Map<string, number>()
|
|
||||||
const _apps = new Map<string, App>()
|
const _apps = new Map<string, App>()
|
||||||
const _availablePorts: number[] = []
|
|
||||||
const _listeners = new Set<() => void>()
|
const _listeners = new Set<() => void>()
|
||||||
|
|
||||||
let _shuttingDown = false
|
let NEXT_PORT = 3001
|
||||||
|
|
||||||
export type App = SharedApp & {
|
export type App = SharedApp & {
|
||||||
consecutiveHealthFailures?: number
|
|
||||||
healthCheckTimer?: Timer
|
|
||||||
lastRestartTime?: number
|
|
||||||
manuallyStopped?: boolean
|
|
||||||
proc?: Subprocess
|
proc?: Subprocess
|
||||||
restartAttempts?: number
|
|
||||||
shutdownTimer?: Timer
|
|
||||||
startupTimer?: Timer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadResult = { pkg: any, error?: string }
|
type LoadResult = { pkg: any; error?: string }
|
||||||
|
|
||||||
export const allApps = (): App[] =>
|
export const allApps = (): App[] =>
|
||||||
Array.from(_apps.values())
|
Array.from(_apps.values())
|
||||||
|
|
@ -53,8 +33,6 @@ export const runningApps = (): App[] =>
|
||||||
allApps().filter(a => a.state === 'running')
|
allApps().filter(a => a.state === 'running')
|
||||||
|
|
||||||
export function initApps() {
|
export function initApps() {
|
||||||
initPortPool()
|
|
||||||
setupShutdownHandlers()
|
|
||||||
discoverApps()
|
discoverApps()
|
||||||
runApps()
|
runApps()
|
||||||
watchAppsDir()
|
watchAppsDir()
|
||||||
|
|
@ -65,26 +43,25 @@ export function onChange(cb: () => void) {
|
||||||
return () => _listeners.delete(cb)
|
return () => _listeners.delete(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startApp(dir: string) {
|
||||||
|
const app = _apps.get(dir)
|
||||||
|
if (!app || app.state !== 'stopped') return
|
||||||
|
if (!isApp(dir)) return
|
||||||
|
runApp(dir, getPort())
|
||||||
|
}
|
||||||
|
|
||||||
export function removeApp(dir: string) {
|
export function removeApp(dir: string) {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app) return
|
if (!app) return
|
||||||
|
|
||||||
// Clear all timers
|
|
||||||
clearTimers(app)
|
|
||||||
|
|
||||||
if (app.state === 'running')
|
if (app.state === 'running')
|
||||||
app.proc?.kill()
|
app.proc?.kill()
|
||||||
|
|
||||||
// Release port if assigned
|
|
||||||
if (app.port) {
|
|
||||||
releasePort(app.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
_apps.delete(dir)
|
_apps.delete(dir)
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameApp(oldName: string, newName: string): { ok: boolean, error?: string } {
|
export function renameApp(oldName: string, newName: string): { ok: boolean; error?: string } {
|
||||||
const app = _apps.get(oldName)
|
const app = _apps.get(oldName)
|
||||||
if (!app) return { ok: false, error: 'App not found' }
|
if (!app) return { ok: false, error: 'App not found' }
|
||||||
|
|
||||||
|
|
@ -100,10 +77,8 @@ export function renameApp(oldName: string, newName: string): { ok: boolean, erro
|
||||||
// Stop the app if running
|
// Stop the app if running
|
||||||
const wasRunning = app.state === 'running'
|
const wasRunning = app.state === 'running'
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
clearTimers(app)
|
|
||||||
app.proc?.kill()
|
app.proc?.kill()
|
||||||
app.proc = undefined
|
app.proc = undefined
|
||||||
if (app.port) releasePort(app.port)
|
|
||||||
app.port = undefined
|
app.port = undefined
|
||||||
app.started = undefined
|
app.started = undefined
|
||||||
}
|
}
|
||||||
|
|
@ -114,19 +89,10 @@ export function renameApp(oldName: string, newName: string): { ok: boolean, erro
|
||||||
return { ok: false, error: `Failed to rename directory: ${e instanceof Error ? e.message : String(e)}` }
|
return { ok: false, error: `Failed to rename directory: ${e instanceof Error ? e.message : String(e)}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer port mapping to new name
|
|
||||||
const oldPort = _appPorts.get(oldName)
|
|
||||||
if (oldPort !== undefined) {
|
|
||||||
_appPorts.delete(oldName)
|
|
||||||
_appPorts.set(newName, oldPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the internal registry
|
// Update the internal registry
|
||||||
_apps.delete(oldName)
|
_apps.delete(oldName)
|
||||||
app.name = newName
|
app.name = newName
|
||||||
app.state = 'stopped'
|
app.state = 'stopped'
|
||||||
app.manuallyStopped = false
|
|
||||||
app.restartAttempts = 0
|
|
||||||
_apps.set(newName, app)
|
_apps.set(newName, app)
|
||||||
|
|
||||||
update()
|
update()
|
||||||
|
|
@ -139,34 +105,13 @@ export function renameApp(oldName: string, newName: string): { ok: boolean, erro
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startApp(dir: string) {
|
|
||||||
const app = _apps.get(dir)
|
|
||||||
if (!app || app.state !== 'stopped') return
|
|
||||||
if (!isApp(dir)) return
|
|
||||||
|
|
||||||
// Clear manually stopped flag when explicitly starting
|
|
||||||
app.manuallyStopped = false
|
|
||||||
runApp(dir, getPort(dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopApp(dir: string) {
|
export function stopApp(dir: string) {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app || app.state !== 'running') return
|
if (!app || app.state !== 'running') return
|
||||||
|
|
||||||
info(app, 'Stopping...')
|
info(app, 'Stopping...')
|
||||||
app.state = 'stopping'
|
app.state = 'stopping'
|
||||||
app.manuallyStopped = true
|
|
||||||
update()
|
update()
|
||||||
|
|
||||||
// Clear health check timer
|
|
||||||
if (app.healthCheckTimer) {
|
|
||||||
clearInterval(app.healthCheckTimer)
|
|
||||||
app.healthCheckTimer = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start shutdown timeout - escalate to SIGKILL if needed
|
|
||||||
startShutdownTimeout(app)
|
|
||||||
|
|
||||||
app.proc?.kill()
|
app.proc?.kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,20 +124,7 @@ export function updateAppIcon(dir: string, icon: string) {
|
||||||
saveApp(dir, pkg)
|
saveApp(dir, pkg)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearTimers = (app: App) => {
|
const getPort = () => NEXT_PORT++
|
||||||
if (app.startupTimer) {
|
|
||||||
clearTimeout(app.startupTimer)
|
|
||||||
app.startupTimer = undefined
|
|
||||||
}
|
|
||||||
if (app.shutdownTimer) {
|
|
||||||
clearTimeout(app.shutdownTimer)
|
|
||||||
app.shutdownTimer = undefined
|
|
||||||
}
|
|
||||||
if (app.healthCheckTimer) {
|
|
||||||
clearInterval(app.healthCheckTimer)
|
|
||||||
app.healthCheckTimer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = (app: App, ...msg: string[]) => {
|
const info = (app: App, ...msg: string[]) => {
|
||||||
console.log('🐾', `[${app.name}]`, ...msg)
|
console.log('🐾', `[${app.name}]`, ...msg)
|
||||||
|
|
@ -220,111 +152,6 @@ function discoverApps() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPort(appName?: string): number {
|
|
||||||
// Try to return the same port this app used before
|
|
||||||
if (appName) {
|
|
||||||
const previousPort = _appPorts.get(appName)
|
|
||||||
if (previousPort !== undefined) {
|
|
||||||
// Check if it's still in the available pool
|
|
||||||
const idx = _availablePorts.indexOf(previousPort)
|
|
||||||
if (idx !== -1) {
|
|
||||||
_availablePorts.splice(idx, 1)
|
|
||||||
return previousPort
|
|
||||||
}
|
|
||||||
// Port is in use by another app, fall through to get new one
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next available port
|
|
||||||
const port = _availablePorts.shift()
|
|
||||||
if (port === undefined) {
|
|
||||||
// Pool exhausted - this shouldn't happen with 100 ports
|
|
||||||
throw new Error('No available ports')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remember this port for the app
|
|
||||||
if (appName) {
|
|
||||||
_appPorts.set(appName, port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gracefulShutdown(signal: string) {
|
|
||||||
if (_shuttingDown) return
|
|
||||||
_shuttingDown = true
|
|
||||||
|
|
||||||
console.log(`\n🐾 Received ${signal}, shutting down gracefully...`)
|
|
||||||
|
|
||||||
const running = runningApps()
|
|
||||||
if (running.length === 0) {
|
|
||||||
console.log('🐾 No apps running, exiting.')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🐾 Stopping ${running.length} app(s)...`)
|
|
||||||
|
|
||||||
// Stop all running apps
|
|
||||||
for (const app of running) {
|
|
||||||
app.manuallyStopped = true
|
|
||||||
if (app.healthCheckTimer) {
|
|
||||||
clearInterval(app.healthCheckTimer)
|
|
||||||
app.healthCheckTimer = undefined
|
|
||||||
}
|
|
||||||
app.proc?.kill()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all apps to exit with timeout
|
|
||||||
const shutdownStart = Date.now()
|
|
||||||
const checkInterval = setInterval(() => {
|
|
||||||
const stillRunning = runningApps()
|
|
||||||
if (stillRunning.length === 0) {
|
|
||||||
clearInterval(checkInterval)
|
|
||||||
console.log('🐾 All apps stopped, exiting.')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for timeout
|
|
||||||
if (Date.now() - shutdownStart > SHUTDOWN_TIMEOUT) {
|
|
||||||
clearInterval(checkInterval)
|
|
||||||
console.log(`🐾 Shutdown timeout, forcing ${stillRunning.length} app(s) to stop...`)
|
|
||||||
for (const app of stillRunning) {
|
|
||||||
if (app.proc) {
|
|
||||||
app.proc.kill(9) // SIGKILL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Give a moment for SIGKILL to take effect
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('🐾 Forced shutdown complete, exiting.')
|
|
||||||
process.exit(1)
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHealthCheckFailure(app: App) {
|
|
||||||
app.consecutiveHealthFailures = (app.consecutiveHealthFailures ?? 0) + 1
|
|
||||||
info(app, `Health check failed (${app.consecutiveHealthFailures}/${HEALTH_CHECK_FAILURES_BEFORE_RESTART})`)
|
|
||||||
|
|
||||||
if (app.consecutiveHealthFailures >= HEALTH_CHECK_FAILURES_BEFORE_RESTART) {
|
|
||||||
info(app, 'Too many health check failures, restarting...')
|
|
||||||
// Clear health check timer before killing
|
|
||||||
if (app.healthCheckTimer) {
|
|
||||||
clearInterval(app.healthCheckTimer)
|
|
||||||
app.healthCheckTimer = undefined
|
|
||||||
}
|
|
||||||
// Don't set manuallyStopped - we want auto-restart to kick in
|
|
||||||
app.proc?.kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPortPool() {
|
|
||||||
_availablePorts.length = 0
|
|
||||||
for (let port = MIN_PORT; port <= MAX_PORT; port++) {
|
|
||||||
_availablePorts.push(port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDir(path: string): boolean {
|
function isDir(path: string): boolean {
|
||||||
try {
|
try {
|
||||||
return statSync(path).isDirectory()
|
return statSync(path).isDirectory()
|
||||||
|
|
@ -354,23 +181,8 @@ function loadApp(dir: string): LoadResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeResetBackoff(app: App) {
|
|
||||||
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {
|
|
||||||
app.restartAttempts = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function releasePort(port: number) {
|
|
||||||
// Return port to pool if not already there
|
|
||||||
if (!_availablePorts.includes(port)) {
|
|
||||||
_availablePorts.push(port)
|
|
||||||
// Keep sorted for predictable allocation
|
|
||||||
_availablePorts.sort((a, b) => a - b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runApp(dir: string, port: number) {
|
async function runApp(dir: string, port: number) {
|
||||||
const { error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
if (error) return
|
if (error) return
|
||||||
|
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
|
|
@ -380,17 +192,8 @@ async function runApp(dir: string, port: number) {
|
||||||
app.state = 'starting'
|
app.state = 'starting'
|
||||||
app.port = port
|
app.port = port
|
||||||
app.logs = []
|
app.logs = []
|
||||||
app.consecutiveHealthFailures = 0
|
|
||||||
update()
|
update()
|
||||||
|
|
||||||
// Start startup timeout
|
|
||||||
app.startupTimer = setTimeout(() => {
|
|
||||||
if (app.state === 'starting') {
|
|
||||||
info(app, 'Startup timeout, killing process...')
|
|
||||||
app.proc?.kill()
|
|
||||||
}
|
|
||||||
}, STARTUP_TIMEOUT)
|
|
||||||
|
|
||||||
const cwd = join(APPS_DIR, dir)
|
const cwd = join(APPS_DIR, dir)
|
||||||
|
|
||||||
const needsInstall = !existsSync(join(cwd, 'node_modules'))
|
const needsInstall = !existsSync(join(cwd, 'node_modules'))
|
||||||
|
|
@ -407,20 +210,12 @@ async function runApp(dir: string, port: number) {
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear startup timer and set state to running
|
// Set state to running
|
||||||
if (app.startupTimer) {
|
|
||||||
clearTimeout(app.startupTimer)
|
|
||||||
app.startupTimer = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
app.state = 'running'
|
app.state = 'running'
|
||||||
app.proc = proc
|
app.proc = proc
|
||||||
app.started = Date.now()
|
app.started = Date.now()
|
||||||
update()
|
update()
|
||||||
|
|
||||||
// Start health checks
|
|
||||||
startHealthChecks(app, port)
|
|
||||||
|
|
||||||
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
||||||
if (!stream) return
|
if (!stream) return
|
||||||
const reader = stream.getReader()
|
const reader = stream.getReader()
|
||||||
|
|
@ -443,33 +238,17 @@ async function runApp(dir: string, port: number) {
|
||||||
|
|
||||||
// Handle process exit
|
// Handle process exit
|
||||||
proc.exited.then(code => {
|
proc.exited.then(code => {
|
||||||
// Clear all timers
|
|
||||||
clearTimers(app)
|
|
||||||
|
|
||||||
// Check if app was stable before crashing (for backoff reset)
|
|
||||||
maybeResetBackoff(app)
|
|
||||||
|
|
||||||
if (code !== 0)
|
if (code !== 0)
|
||||||
app.logs?.push({ time: Date.now(), text: `Exited with code ${code}` })
|
app.logs?.push({ time: Date.now(), text: `Exited with code ${code}` })
|
||||||
else
|
else
|
||||||
app.logs?.push({ time: Date.now(), text: 'Stopped' })
|
app.logs?.push({ time: Date.now(), text: 'Stopped' })
|
||||||
|
|
||||||
// Release port back to pool
|
|
||||||
if (app.port) {
|
|
||||||
releasePort(app.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset to stopped state (or invalid if no longer valid)
|
// Reset to stopped state (or invalid if no longer valid)
|
||||||
app.state = isApp(dir) ? 'stopped' : 'invalid'
|
app.state = isApp(dir) ? 'stopped' : 'invalid'
|
||||||
app.proc = undefined
|
app.proc = undefined
|
||||||
app.port = undefined
|
app.port = undefined
|
||||||
app.started = undefined
|
app.started = undefined
|
||||||
update()
|
update()
|
||||||
|
|
||||||
// Schedule restart if appropriate
|
|
||||||
if (shouldAutoRestart(app, code)) {
|
|
||||||
scheduleRestart(app, dir)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,92 +257,8 @@ function saveApp(dir: string, pkg: any) {
|
||||||
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRestart(app: App, dir: string) {
|
|
||||||
const attempts = app.restartAttempts ?? 0
|
|
||||||
const delayIndex = Math.min(attempts, RESTART_DELAYS.length - 1)
|
|
||||||
const delay = RESTART_DELAYS[delayIndex]!
|
|
||||||
|
|
||||||
app.restartAttempts = attempts + 1
|
|
||||||
app.lastRestartTime = Date.now()
|
|
||||||
|
|
||||||
info(app, `Scheduling restart in ${delay / 1000}s (attempt ${app.restartAttempts})...`)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// Double-check conditions before restarting
|
|
||||||
if (_shuttingDown) return
|
|
||||||
if (app.manuallyStopped) return
|
|
||||||
if (app.state !== 'stopped') return
|
|
||||||
if (!isApp(dir)) return
|
|
||||||
|
|
||||||
info(app, 'Restarting...')
|
|
||||||
runApp(dir, getPort(dir))
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupShutdownHandlers() {
|
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAutoRestart(app: App, exitCode: number | null): boolean {
|
|
||||||
// Don't restart during host shutdown
|
|
||||||
if (_shuttingDown) return false
|
|
||||||
|
|
||||||
// Don't restart if manually stopped
|
|
||||||
if (app.manuallyStopped) return false
|
|
||||||
|
|
||||||
// Don't restart if app became invalid
|
|
||||||
if (app.state === 'invalid') return false
|
|
||||||
|
|
||||||
// Only restart on non-zero exit codes (crashes)
|
|
||||||
if (exitCode === 0) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHealthChecks(app: App, port: number) {
|
|
||||||
app.healthCheckTimer = setInterval(async () => {
|
|
||||||
if (app.state !== 'running') {
|
|
||||||
if (app.healthCheckTimer) {
|
|
||||||
clearInterval(app.healthCheckTimer)
|
|
||||||
app.healthCheckTimer = undefined
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
|
|
||||||
|
|
||||||
const response = await fetch(`http://localhost:${port}/`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
clearTimeout(timeout)
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Reset consecutive failures on success
|
|
||||||
app.consecutiveHealthFailures = 0
|
|
||||||
} else {
|
|
||||||
handleHealthCheckFailure(app)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
handleHealthCheckFailure(app)
|
|
||||||
}
|
|
||||||
}, HEALTH_CHECK_INTERVAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startShutdownTimeout(app: App) {
|
|
||||||
app.shutdownTimer = setTimeout(() => {
|
|
||||||
if (app.proc && (app.state === 'stopping' || app.state === 'running')) {
|
|
||||||
info(app, 'Shutdown timeout, sending SIGKILL...')
|
|
||||||
app.proc.kill(9) // SIGKILL
|
|
||||||
}
|
|
||||||
}, SHUTDOWN_TIMEOUT)
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAppsDir() {
|
function watchAppsDir() {
|
||||||
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
watch(APPS_DIR, { recursive: true }, (event, filename) => {
|
||||||
if (!filename) return
|
if (!filename) return
|
||||||
|
|
||||||
// Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp")
|
// Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp")
|
||||||
|
|
@ -580,7 +275,7 @@ function watchAppsDir() {
|
||||||
_apps.set(dir, { name: dir, state, icon, error })
|
_apps.set(dir, { name: dir, state, icon, error })
|
||||||
update()
|
update()
|
||||||
if (!error) {
|
if (!error) {
|
||||||
runApp(dir, getPort(dir))
|
runApp(dir, getPort())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -589,8 +284,6 @@ function watchAppsDir() {
|
||||||
|
|
||||||
// check if app was deleted
|
// check if app was deleted
|
||||||
if (!isDir(join(APPS_DIR, dir))) {
|
if (!isDir(join(APPS_DIR, dir))) {
|
||||||
clearTimers(app)
|
|
||||||
if (app.port) releasePort(app.port)
|
|
||||||
_apps.delete(dir)
|
_apps.delete(dir)
|
||||||
update()
|
update()
|
||||||
return
|
return
|
||||||
|
|
@ -608,13 +301,12 @@ function watchAppsDir() {
|
||||||
// App became valid - start it if stopped
|
// App became valid - start it if stopped
|
||||||
if (!error && app.state === 'invalid') {
|
if (!error && app.state === 'invalid') {
|
||||||
app.state = 'stopped'
|
app.state = 'stopped'
|
||||||
runApp(dir, getPort(dir))
|
runApp(dir, getPort())
|
||||||
}
|
}
|
||||||
|
|
||||||
// App became invalid - stop it if running
|
// App became invalid - stop it if running
|
||||||
if (error && app.state === 'running') {
|
if (error && app.state === 'running') {
|
||||||
app.state = 'invalid'
|
app.state = 'invalid'
|
||||||
clearTimers(app)
|
|
||||||
app.proc?.kill()
|
app.proc?.kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user