Compare commits
No commits in common. "b1fc698b9ab52ec09b9e5a9a1ccba7da8e92d53c" and "303d2dfc720e5a201335a64db63160b693a8e738" have entirely different histories.
b1fc698b9a
...
303d2dfc72
336
CLAUDE.md
336
CLAUDE.md
|
|
@ -223,339 +223,3 @@ 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
374
PID.md
|
|
@ -1,374 +0,0 @@
|
||||||
# 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,7 +3,6 @@ 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,8 +45,6 @@ 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,8 +319,6 @@ 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,8 +217,6 @@ 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,8 +248,6 @@ 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,6 +3,5 @@ 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, ToolScript, theme } from '@because/toes/tools'
|
import { baseStyles, initScript, 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,8 +190,6 @@ 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',
|
||||||
}))
|
}))
|
||||||
|
|
@ -211,7 +209,7 @@ app.get('/', async c => {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ToolScript />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>TODO</Title>
|
<Title>TODO</Title>
|
||||||
|
|
@ -244,7 +242,7 @@ app.get('/', async c => {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ToolScript />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<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, ToolScript, theme } from '@because/toes/tools'
|
import { baseStyles, initScript, 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>
|
||||||
<ToolScript />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>Versions</Title>
|
<Title>Versions</Title>
|
||||||
|
|
@ -121,8 +121,6 @@ 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.5",
|
"version": "0.0.4",
|
||||||
"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,7 +15,6 @@
|
||||||
"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,5 +12,4 @@ 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'
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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,7 +24,6 @@ import {
|
||||||
stashListApp,
|
stashListApp,
|
||||||
stashPopApp,
|
stashPopApp,
|
||||||
startApp,
|
startApp,
|
||||||
statsApp,
|
|
||||||
statusApp,
|
statusApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
syncApp,
|
||||||
|
|
@ -110,12 +109,6 @@ 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,8 +16,10 @@ const router = Hype.router()
|
||||||
|
|
||||||
// BackendApp -> SharedApp
|
// BackendApp -> SharedApp
|
||||||
function convert(app: BackendApp): SharedApp {
|
function convert(app: BackendApp): SharedApp {
|
||||||
const { proc, logs, ...rest } = app
|
const clone = { ...app }
|
||||||
return { ...rest, pid: proc?.pid }
|
delete clone.proc
|
||||||
|
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}/ok`, {
|
const response = await fetch(`http://localhost:${port}/`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,35 +22,6 @@ 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,7 +23,6 @@ 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,6 +3,5 @@ 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,6 +4,5 @@ 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