Compare commits
8 Commits
303d2dfc72
...
b1fc698b9a
| Author | SHA1 | Date | |
|---|---|---|---|
| b1fc698b9a | |||
| a3f36a0c98 | |||
| b2d7c72fee | |||
| 2ef00c9d53 | |||
| 067fd12e94 | |||
| 0d572b4b5d | |||
| 913f8f34d7 | |||
| 11caa8fe19 |
336
CLAUDE.md
336
CLAUDE.md
|
|
@ -223,3 +223,339 @@ function start(app: App): void {
|
||||||
console.log(`Starting ${app.config.name}`)
|
console.log(`Starting ${app.config.name}`)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Guide to Writing Tools
|
||||||
|
|
||||||
|
This guide explains how to write a Toes tool.
|
||||||
|
|
||||||
|
A tool is just an app that is displayed in a tab and an iframe on each app's page. When the iframe is loaded, it's passed ?app=<app-name>.
|
||||||
|
|
||||||
|
### Minimal Tool Structure
|
||||||
|
|
||||||
|
A tool needs four files at minimum:
|
||||||
|
|
||||||
|
**.npmrc**
|
||||||
|
```
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
```
|
||||||
|
|
||||||
|
**tsconfig.json**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": ["src/server/*"],
|
||||||
|
"#*": ["src/client/*"],
|
||||||
|
"@*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-tool",
|
||||||
|
"private": true,
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"tool": true, // can also be set to a string to control the tab's label
|
||||||
|
"icon": "🔧"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**index.tsx**
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
// Define styled components using Forge
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Layout wrapper (every tool needs this pattern)
|
||||||
|
function Layout({ title, children }: { title: string; children: Child }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve styles (required)
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Main route
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="My Tool">
|
||||||
|
<p>No app selected</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="My Tool">
|
||||||
|
<h1>Tool for {appName}</h1>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Imports
|
||||||
|
|
||||||
|
Every tool imports from three packages:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// HTTP helpers
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
// Structured CSS
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
// Tool helpers
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Tools have access to these environment variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `APPS_DIR` | Path to the apps directory (e.g., `/home/toes/apps`) |
|
||||||
|
| `TOES_URL` | Base URL of the Toes server (e.g., `http://192.168.1.100:3000`) |
|
||||||
|
| `PORT` | Port assigned to this tool |
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
Tools receive `?app=<name>` when an app is selected. Add custom params as needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const appName = c.req.query('app') // Always present when app selected
|
||||||
|
const version = c.req.query('version') // Custom param (code tool)
|
||||||
|
const file = c.req.query('file') // Custom param (code tool)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling with Forge
|
||||||
|
|
||||||
|
Use `define()` to create styled components and `theme()` for design tokens:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button', // HTML element (optional, defaults to 'div')
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': { opacity: 0.9 },
|
||||||
|
':disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const List = define('List', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
selectors: {
|
||||||
|
'& > li:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Common theme values:
|
||||||
|
- `colors-text`, `colors-textMuted`, `colors-bg`, `colors-bgHover`, `colors-bgElement`, `colors-bgSubtle`
|
||||||
|
- `colors-border`, `colors-link`, `colors-primary`, `colors-error`
|
||||||
|
- `colors-statusRunning`, `colors-statusStopped`
|
||||||
|
- `fonts-sans`, `fonts-mono`
|
||||||
|
- `radius-md`
|
||||||
|
|
||||||
|
### ToolScript
|
||||||
|
|
||||||
|
Always include `<ToolScript />` in your layout's body. It handles:
|
||||||
|
1. Theme detection (light/dark mode based on system preference)
|
||||||
|
2. Iframe height communication with parent dashboard
|
||||||
|
|
||||||
|
### Accessing App Files
|
||||||
|
|
||||||
|
When accessing files in an app, always use the `current` symlink:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readdir } from 'fs/promises'
|
||||||
|
|
||||||
|
const appPath = join(APPS_DIR, appName, 'current') // ✓ Correct
|
||||||
|
// NOT: join(APPS_DIR, appName) // ✗ Wrong
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linking Between Tools
|
||||||
|
|
||||||
|
Use `TOES_URL` to create links to other tools:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
// Link from versions tool to code tool
|
||||||
|
<a href={`${TOES_URL}/tool/code?app=${appName}&version=${version}`}>
|
||||||
|
View Code
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Complexity Levels
|
||||||
|
|
||||||
|
**Simple (single file)** - `versions` tool
|
||||||
|
- Everything in `index.tsx`
|
||||||
|
- Good for straightforward display tools
|
||||||
|
|
||||||
|
**Medium (re-export)** - `code` tool
|
||||||
|
- `index.tsx` re-exports: `export { default } from './src/server'`
|
||||||
|
- Actual implementation in `src/server/index.tsx`
|
||||||
|
- Good when you want cleaner organization
|
||||||
|
|
||||||
|
**Complex (multiple modules)** - `cron` tool
|
||||||
|
- Main server in `index.tsx`
|
||||||
|
- Business logic in `lib/` directory
|
||||||
|
- Good for tools with significant logic (discovery, scheduling, state management)
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
|
||||||
|
**Error states:**
|
||||||
|
```tsx
|
||||||
|
const ErrorBox = define('ErrorBox', {
|
||||||
|
color: theme('colors-error'),
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="My Tool">
|
||||||
|
<ErrorBox>Please specify an app with ?app=<name></ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty states:**
|
||||||
|
```tsx
|
||||||
|
const EmptyState = define('EmptyState', {
|
||||||
|
padding: '40px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState>No items found</EmptyState>
|
||||||
|
) : (
|
||||||
|
<List>...</List>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form handling:**
|
||||||
|
```tsx
|
||||||
|
app.get('/new', async c => {
|
||||||
|
return c.html(<Layout><form method="post" action="/new">...</form></Layout>)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/new', async c => {
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const name = body.name as string
|
||||||
|
// Process form...
|
||||||
|
return c.redirect('/')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**File watching (for live updates):**
|
||||||
|
```tsx
|
||||||
|
import { watch } from 'fs'
|
||||||
|
|
||||||
|
let debounceTimer: Timer | null = null
|
||||||
|
|
||||||
|
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
||||||
|
if (!filename?.includes('/target/')) return
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(handleChange, 100)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist for New Tools
|
||||||
|
|
||||||
|
1. [ ] `.npmrc` contains `registry=https://npm.nose.space`
|
||||||
|
2. [ ] `tsconfig.json` matches the exact config shown above (do not improvise)
|
||||||
|
3. [ ] `package.json` has `toes.tool: true` and `toes.icon`
|
||||||
|
4. [ ] `scripts.toes` uses `bun run --watch index.tsx`
|
||||||
|
5. [ ] Dependencies include `@because/forge`, `@because/hype`, `@because/toes`
|
||||||
|
6. [ ] Import `baseStyles`, `ToolScript`, `theme` from `@because/toes/tools`
|
||||||
|
7. [ ] Layout body includes `<ToolScript />`
|
||||||
|
8. [ ] Styles served at `/styles.css` with `baseStyles + stylesToCSS()`
|
||||||
|
9. [ ] Main route handles missing `?app` parameter gracefully
|
||||||
|
10. [ ] Uses `APPS_DIR/<app>/current` for file paths
|
||||||
|
11. [ ] Exports `app.defaults` as default export
|
||||||
|
|
|
||||||
374
PID.md
Normal file
374
PID.md
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
# PID File Tracking for Robust Process Management
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
When the Toes host process crashes unexpectedly (OOM, SIGKILL, power loss, kernel panic), child app processes continue running as orphans. On restart, Toes has no knowledge of these processes:
|
||||||
|
|
||||||
|
- **Port conflicts**: Orphans hold ports, new instances fail to bind
|
||||||
|
- **Resource waste**: Zombie processes consume memory/CPU
|
||||||
|
- **State confusion**: App appears "stopped" but is actually running
|
||||||
|
- **Data corruption**: Multiple instances may write to same files
|
||||||
|
|
||||||
|
Currently, Toes only handles graceful shutdown (SIGTERM/SIGINT). There's no recovery mechanism for ungraceful termination.
|
||||||
|
|
||||||
|
## Proposed Solution: PID File Tracking
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
Store PID files in `TOES_DIR/pids/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
${TOES_DIR}/pids/
|
||||||
|
clock.pid # Contains: 12345
|
||||||
|
todo.pid # Contains: 12389
|
||||||
|
weather.pid # Contains: 12402
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ App Start │────▶│ Write PID │────▶│ Running │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────┐ │
|
||||||
|
│ Delete PID │◀──────────┘
|
||||||
|
└─────────────┘ App Exit
|
||||||
|
```
|
||||||
|
|
||||||
|
On host startup:
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Host Init │────▶│ Scan PIDs │────▶│Kill Orphans │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│Clean Stale │
|
||||||
|
│ PID Files │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
#### 1. PID Directory Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const PIDS_DIR = join(TOES_DIR, 'pids')
|
||||||
|
|
||||||
|
function ensurePidsDir() {
|
||||||
|
if (!existsSync(PIDS_DIR)) {
|
||||||
|
mkdirSync(PIDS_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Write PID on Start
|
||||||
|
|
||||||
|
In `runApp()`, after spawning:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const proc = Bun.spawn(['bun', 'run', 'toes'], { ... })
|
||||||
|
app.proc = proc
|
||||||
|
|
||||||
|
// Write PID file
|
||||||
|
const pidFile = join(PIDS_DIR, `${dir}.pid`)
|
||||||
|
writeFileSync(pidFile, String(proc.pid))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Delete PID on Exit
|
||||||
|
|
||||||
|
In the `proc.exited.then()` handler:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
proc.exited.then(code => {
|
||||||
|
// Remove PID file
|
||||||
|
const pidFile = join(PIDS_DIR, `${dir}.pid`)
|
||||||
|
if (existsSync(pidFile)) {
|
||||||
|
unlinkSync(pidFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing cleanup
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Orphan Cleanup on Startup
|
||||||
|
|
||||||
|
New function called during `initApps()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function cleanupOrphanProcesses() {
|
||||||
|
ensurePidsDir()
|
||||||
|
|
||||||
|
for (const file of readdirSync(PIDS_DIR)) {
|
||||||
|
if (!file.endsWith('.pid')) continue
|
||||||
|
|
||||||
|
const appName = file.replace('.pid', '')
|
||||||
|
const pidFile = join(PIDS_DIR, file)
|
||||||
|
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10)
|
||||||
|
|
||||||
|
if (isNaN(pid)) {
|
||||||
|
// Invalid PID file, remove it
|
||||||
|
unlinkSync(pidFile)
|
||||||
|
hostLog(`Removed invalid PID file: ${file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProcessRunning(pid)) {
|
||||||
|
// Orphan found - kill it
|
||||||
|
hostLog(`Found orphan process for ${appName} (PID ${pid}), terminating...`)
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM')
|
||||||
|
|
||||||
|
// Give it 5 seconds, then SIGKILL
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isProcessRunning(pid)) {
|
||||||
|
hostLog(`Orphan ${appName} (PID ${pid}) didn't terminate, sending SIGKILL`)
|
||||||
|
process.kill(pid, 'SIGKILL')
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
} catch (e) {
|
||||||
|
// Process may have exited between check and kill
|
||||||
|
hostLog(`Failed to kill orphan ${appName}: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale PID file
|
||||||
|
unlinkSync(pidFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
// Sending signal 0 checks if process exists without killing it
|
||||||
|
process.kill(pid, 0)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Integration Point
|
||||||
|
|
||||||
|
Update `initApps()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function initApps() {
|
||||||
|
initPortPool()
|
||||||
|
setupShutdownHandlers()
|
||||||
|
cleanupOrphanProcesses() // <-- Add here, before discovery
|
||||||
|
rotateLogs()
|
||||||
|
createAppSymlinks()
|
||||||
|
discoverApps()
|
||||||
|
runApps()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| PID reused by OS | Check if process command matches expected pattern before killing |
|
||||||
|
| PID file corrupted | Delete invalid files, log warning |
|
||||||
|
| Multiple Toes instances | Use file locking or instance ID in PID path |
|
||||||
|
| App renamed while running | Old PID file orphaned; cleanup handles it |
|
||||||
|
| Permission denied on kill | Log error, continue with other orphans |
|
||||||
|
|
||||||
|
### Enhanced: Validate Process Identity
|
||||||
|
|
||||||
|
To avoid killing an unrelated process that reused the PID:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function isOurProcess(pid: number, appName: string): boolean {
|
||||||
|
try {
|
||||||
|
// On macOS/Linux, check /proc or use ps
|
||||||
|
const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'args='])
|
||||||
|
const cmd = new TextDecoder().decode(result.stdout).trim()
|
||||||
|
|
||||||
|
// Check if it looks like a Toes app process
|
||||||
|
return cmd.includes('bun') && cmd.includes('toes')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Recommendations
|
||||||
|
|
||||||
|
### 1. Store Port in PID File
|
||||||
|
|
||||||
|
Extend PID files to include port for faster recovery:
|
||||||
|
|
||||||
|
```
|
||||||
|
# clock.pid
|
||||||
|
12345
|
||||||
|
3001
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use JSON:
|
||||||
|
```json
|
||||||
|
{"pid": 12345, "port": 3001, "started": 1706900000000}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows Toes to reclaim the exact port on restart, avoiding port shuffling.
|
||||||
|
|
||||||
|
### 2. Circuit Breaker for Crash Loops
|
||||||
|
|
||||||
|
Add crash tracking to prevent infinite restart loops:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CrashRecord {
|
||||||
|
timestamp: number
|
||||||
|
exitCode: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in TOES_DIR/crashes/<app>.json
|
||||||
|
const CRASH_WINDOW = 3600000 // 1 hour
|
||||||
|
const MAX_CRASHES = 10
|
||||||
|
|
||||||
|
function recordCrash(appName: string, exitCode: number) {
|
||||||
|
const file = join(TOES_DIR, 'crashes', `${appName}.json`)
|
||||||
|
const crashes: CrashRecord[] = existsSync(file)
|
||||||
|
? JSON.parse(readFileSync(file, 'utf-8'))
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Add new crash
|
||||||
|
crashes.push({ timestamp: Date.now(), exitCode })
|
||||||
|
|
||||||
|
// Prune old crashes
|
||||||
|
const cutoff = Date.now() - CRASH_WINDOW
|
||||||
|
const recent = crashes.filter(c => c.timestamp > cutoff)
|
||||||
|
|
||||||
|
writeFileSync(file, JSON.stringify(recent))
|
||||||
|
|
||||||
|
return recent.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCircuitBreak(appName: string): boolean {
|
||||||
|
const file = join(TOES_DIR, 'crashes', `${appName}.json`)
|
||||||
|
if (!existsSync(file)) return false
|
||||||
|
|
||||||
|
const crashes: CrashRecord[] = JSON.parse(readFileSync(file, 'utf-8'))
|
||||||
|
const cutoff = Date.now() - CRASH_WINDOW
|
||||||
|
const recent = crashes.filter(c => c.timestamp > cutoff)
|
||||||
|
|
||||||
|
return recent.length >= MAX_CRASHES
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Track Restart Timer for Cancellation
|
||||||
|
|
||||||
|
Store scheduled restart timers on the app object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type App = SharedApp & {
|
||||||
|
// ... existing fields
|
||||||
|
restartTimer?: Timer // <-- Add this
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `scheduleRestart()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function scheduleRestart(app: App, dir: string) {
|
||||||
|
// Cancel any existing scheduled restart
|
||||||
|
if (app.restartTimer) {
|
||||||
|
clearTimeout(app.restartTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing delay calculation ...
|
||||||
|
|
||||||
|
app.restartTimer = setTimeout(() => {
|
||||||
|
app.restartTimer = undefined
|
||||||
|
// ... existing restart logic
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `clearTimers()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const clearTimers = (app: App) => {
|
||||||
|
// ... existing timer cleanup ...
|
||||||
|
|
||||||
|
if (app.restartTimer) {
|
||||||
|
clearTimeout(app.restartTimer)
|
||||||
|
app.restartTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Exit Code Classification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function classifyExit(code: number | null): 'restart' | 'invalid' | 'stop' {
|
||||||
|
if (code === null) return 'restart' // Killed by signal
|
||||||
|
if (code === 0) return 'stop' // Clean exit
|
||||||
|
if (code === 2) return 'invalid' // Bad arguments/config
|
||||||
|
if (code >= 128) {
|
||||||
|
// Killed by signal (128 + signal number)
|
||||||
|
const signal = code - 128
|
||||||
|
if (signal === 9) return 'restart' // SIGKILL (OOM?)
|
||||||
|
if (signal === 15) return 'stop' // SIGTERM (intentional)
|
||||||
|
}
|
||||||
|
return 'restart' // Default: try again
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Install Timeout
|
||||||
|
|
||||||
|
Wrap `bun install` with a timeout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function installWithTimeout(cwd: string, timeout = 60000): Promise<boolean> {
|
||||||
|
const install = Bun.spawn(['bun', 'install'], {
|
||||||
|
cwd,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe'
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
install.kill()
|
||||||
|
reject(new Error('Install timeout'))
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([install.exited, timeoutPromise])
|
||||||
|
return install.exitCode === 0
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
| Change | Effort | Impact | Priority |
|
||||||
|
|--------|--------|--------|----------|
|
||||||
|
| PID file tracking | Medium | High | **1** |
|
||||||
|
| Orphan cleanup on startup | Medium | High | **1** |
|
||||||
|
| Track restart timer | Low | Medium | **2** |
|
||||||
|
| Install timeout | Low | Medium | **2** |
|
||||||
|
| Circuit breaker | Medium | Medium | **3** |
|
||||||
|
| Exit code classification | Low | Low | **4** |
|
||||||
|
| Process identity validation | Medium | Low | **5** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Host crashes while apps running → orphans cleaned on restart
|
||||||
|
- [ ] App crashes → PID file removed, restart scheduled
|
||||||
|
- [ ] App stopped manually → PID file removed, no restart
|
||||||
|
- [ ] Stale PID file (process gone) → file cleaned up
|
||||||
|
- [ ] PID reused by unrelated process → not killed (with identity check)
|
||||||
|
- [ ] Multiple rapid restarts → circuit breaker triggers
|
||||||
|
- [ ] Rename app while running → handled gracefully
|
||||||
|
- [ ] `bun install` hangs → times out, app marked failed
|
||||||
|
|
@ -3,6 +3,7 @@ import { Hype } from '@because/hype'
|
||||||
const app = new Hype
|
const app = new Hype
|
||||||
|
|
||||||
app.get('/', c => c.html(<h1>Hi there!</h1>))
|
app.get('/', c => c.html(<h1>Hi there!</h1>))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
const apps = () => {
|
const apps = () => {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
app.get('/', c => c.html(
|
app.get('/', c => c.html(
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,8 @@ function Layout({ title, children, highlight, editable }: LayoutProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,8 @@ function statusColor(job: CronJob): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
2
apps/env/20260130-000000/index.tsx
vendored
2
apps/env/20260130-000000/index.tsx
vendored
|
|
@ -248,6 +248,8 @@ function appEnvPath(appName: string): string {
|
||||||
return join(ENV_DIR, `${appName}.env`)
|
return join(ENV_DIR, `${appName}.env`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@ import { Hype } from '@because/hype'
|
||||||
const app = new Hype
|
const app = new Hype
|
||||||
|
|
||||||
app.get('/', c => c.html(<h1>My Profile!!!</h1>))
|
app.get('/', c => c.html(<h1>My Profile!!!</h1>))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
|
|
@ -190,6 +190,8 @@ function serializeTodo(todo: ParsedTodo): string {
|
||||||
return lines.join('\n') + '\n'
|
return lines.join('\n') + '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
@ -209,7 +211,7 @@ app.get('/', async c => {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<ToolScript />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>TODO</Title>
|
<Title>TODO</Title>
|
||||||
|
|
@ -242,7 +244,7 @@ app.get('/', async c => {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<ToolScript />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
import { readdir, readlink, stat } from 'fs/promises'
|
import { readdir, readlink, stat } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
@ -109,7 +109,7 @@ function Layout({ title, subtitle, children }: LayoutProps) {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<ToolScript />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>Versions</Title>
|
<Title>Versions</Title>
|
||||||
|
|
@ -121,6 +121,8 @@ function Layout({ title, subtitle, children }: LayoutProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/toes",
|
"name": "@because/toes",
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"toes": "src/cli/index.ts"
|
"toes": "src/cli/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"check": "bunx tsc --noEmit",
|
||||||
"build": "./scripts/build.sh",
|
"build": "./scripts/build.sh",
|
||||||
"cli:build": "bun run scripts/build.ts",
|
"cli:build": "bun run scripts/build.ts",
|
||||||
"cli:build:all": "bun run scripts/build.ts --all",
|
"cli:build:all": "bun run scripts/build.ts --all",
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ export {
|
||||||
startApp,
|
startApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
} from './manage'
|
} from './manage'
|
||||||
|
export { statsApp } from './stats'
|
||||||
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
||||||
|
|
|
||||||
103
src/cli/commands/stats.ts
Normal file
103
src/cli/commands/stats.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import color from 'kleur'
|
||||||
|
import { get } from '../http'
|
||||||
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
|
interface AppStats {
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
port?: number
|
||||||
|
pid?: number
|
||||||
|
cpu?: number
|
||||||
|
memory?: number
|
||||||
|
rss?: number
|
||||||
|
tool?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRss(kb?: number): string {
|
||||||
|
if (kb === undefined) return '-'
|
||||||
|
if (kb < 1024) return `${kb} KB`
|
||||||
|
if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`
|
||||||
|
return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value?: number): string {
|
||||||
|
if (value === undefined) return '-'
|
||||||
|
return `${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(str: string, len: number, right = false): string {
|
||||||
|
if (right) return str.padStart(len)
|
||||||
|
return str.padEnd(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function statsApp(arg?: string) {
|
||||||
|
// If arg is provided, show stats for that app only
|
||||||
|
if (arg) {
|
||||||
|
const name = resolveAppName(arg)
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
const stats: AppStats | undefined = await get(`/api/tools/stats/api/stats/${name}`)
|
||||||
|
if (!stats) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${color.bold(stats.name)} ${stats.tool ? color.gray('[tool]') : ''}`)
|
||||||
|
console.log(` State: ${stats.state}`)
|
||||||
|
if (stats.pid) console.log(` PID: ${stats.pid}`)
|
||||||
|
if (stats.port) console.log(` Port: ${stats.port}`)
|
||||||
|
if (stats.cpu !== undefined) console.log(` CPU: ${formatPercent(stats.cpu)}`)
|
||||||
|
if (stats.memory !== undefined) console.log(` Memory: ${formatPercent(stats.memory)}`)
|
||||||
|
if (stats.rss !== undefined) console.log(` RSS: ${formatRss(stats.rss)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show stats for all apps
|
||||||
|
const stats: AppStats[] | undefined = await get('/api/tools/stats/api/stats')
|
||||||
|
if (!stats || stats.length === 0) {
|
||||||
|
console.log('No apps found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: running first, then by name
|
||||||
|
stats.sort((a, b) => {
|
||||||
|
if (a.state === 'running' && b.state !== 'running') return -1
|
||||||
|
if (a.state !== 'running' && b.state === 'running') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate column widths
|
||||||
|
const nameWidth = Math.max(4, ...stats.map(s => s.name.length + (s.tool ? 7 : 0)))
|
||||||
|
const stateWidth = Math.max(5, ...stats.map(s => s.state.length))
|
||||||
|
|
||||||
|
// Header
|
||||||
|
console.log(
|
||||||
|
color.gray(
|
||||||
|
`${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
for (const s of stats) {
|
||||||
|
const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name
|
||||||
|
const stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
|
||||||
|
const state = stateColor(s.state)
|
||||||
|
|
||||||
|
const pid = s.pid ? String(s.pid) : '-'
|
||||||
|
const cpu = formatPercent(s.cpu)
|
||||||
|
const mem = formatPercent(s.memory)
|
||||||
|
const rss = formatRss(s.rss)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const running = stats.filter(s => s.state === 'running')
|
||||||
|
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
|
||||||
|
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} total`))
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
stashListApp,
|
stashListApp,
|
||||||
stashPopApp,
|
stashPopApp,
|
||||||
startApp,
|
startApp,
|
||||||
|
statsApp,
|
||||||
statusApp,
|
statusApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
syncApp,
|
||||||
|
|
@ -109,6 +110,12 @@ program
|
||||||
.option('-g, --grep <pattern>', 'filter logs by pattern')
|
.option('-g, --grep <pattern>', 'filter logs by pattern')
|
||||||
.action(logApp)
|
.action(logApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('stats')
|
||||||
|
.description('Show CPU and memory stats for apps')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(statsApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('open')
|
.command('open')
|
||||||
.description('Open an app in browser')
|
.description('Open an app in browser')
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,8 @@ const router = Hype.router()
|
||||||
|
|
||||||
// BackendApp -> SharedApp
|
// BackendApp -> SharedApp
|
||||||
function convert(app: BackendApp): SharedApp {
|
function convert(app: BackendApp): SharedApp {
|
||||||
const clone = { ...app }
|
const { proc, logs, ...rest } = app
|
||||||
delete clone.proc
|
return { ...rest, pid: proc?.pid }
|
||||||
delete clone.logs
|
|
||||||
return clone
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE endpoint for real-time app state updates
|
// SSE endpoint for real-time app state updates
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@ function startHealthChecks(app: App, port: number) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
|
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
|
||||||
|
|
||||||
const response = await fetch(`http://localhost:${port}/`, {
|
const response = await fetch(`http://localhost:${port}/ok`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,35 @@ app.get('/tool/:tool', c => {
|
||||||
return c.redirect(url)
|
return c.redirect(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Tool API proxy: /api/tools/:tool/* -> proxy to tool port
|
||||||
|
app.all('/api/tools/:tool/:path{.+}', async c => {
|
||||||
|
const toolName = c.req.param('tool')
|
||||||
|
const tool = allApps().find(a => a.tool && a.name === toolName)
|
||||||
|
if (!tool || tool.state !== 'running' || !tool.port) {
|
||||||
|
return c.json({ error: `Tool "${toolName}" not found or not running` }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subPath = '/' + c.req.param('path')
|
||||||
|
|
||||||
|
// Build target URL
|
||||||
|
const params = new URLSearchParams(c.req.query()).toString()
|
||||||
|
const targetUrl = params
|
||||||
|
? `http://localhost:${tool.port}${subPath}?${params}`
|
||||||
|
: `http://localhost:${tool.port}${subPath}`
|
||||||
|
|
||||||
|
// Proxy the request
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: c.req.method,
|
||||||
|
headers: c.req.raw.headers,
|
||||||
|
body: c.req.method !== 'GET' && c.req.method !== 'HEAD' ? c.req.raw.body : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
initApps()
|
initApps()
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export type App = {
|
||||||
state: AppState
|
state: AppState
|
||||||
icon: string
|
icon: string
|
||||||
error?: string
|
error?: string
|
||||||
|
pid?: number
|
||||||
port?: number
|
port?: number
|
||||||
started?: number
|
started?: number
|
||||||
logs?: LogLine[]
|
logs?: LogLine[]
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@ import { Hype } from '@because/hype'
|
||||||
const app = new Hype()
|
const app = new Hype()
|
||||||
|
|
||||||
app.get('/', c => c.text('$$APP_NAME$$'))
|
app.get('/', c => c.text('$$APP_NAME$$'))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,6 @@ const app = new Hype({ layout: false })
|
||||||
|
|
||||||
// custom routes go here
|
// custom routes go here
|
||||||
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user