Compare commits
No commits in common. "d76d5ed50db9a74969933c1273e9a81d8b78a267" and "048b0af34e0f55a05b2417c4820949f397e6fae2" have entirely different histories.
d76d5ed50d
...
048b0af34e
|
|
@ -7,7 +7,7 @@ Personal web server framework that auto-discovers and runs multiple web apps on
|
||||||
1. Host server scans `/apps` directory for valid apps
|
1. Host server scans `/apps` directory for valid apps
|
||||||
2. Valid app = has `package.json` with `scripts.toes` entry
|
2. Valid app = has `package.json` with `scripts.toes` entry
|
||||||
3. Each app spawned as child process with unique port (3001+)
|
3. Each app spawned as child process with unique port (3001+)
|
||||||
4. Dashboard UI shows all apps with current status, logs, and links
|
4. Dashboard UI shows all running apps with links
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle
|
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle
|
||||||
|
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
|
||||||
import { define, Styles } from 'forge'
|
|
||||||
import type { App, AppState } from '../shared/types'
|
|
||||||
|
|
||||||
// UI state (survives re-renders)
|
|
||||||
let selectedApp: string | null = null
|
|
||||||
|
|
||||||
// Server state (from SSE)
|
|
||||||
let apps: App[] = []
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
const Layout = define('Layout', {
|
|
||||||
display: 'flex',
|
|
||||||
height: '100vh',
|
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
||||||
background: '#0a0a0a',
|
|
||||||
color: '#e5e5e5',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Sidebar = define('Sidebar', {
|
|
||||||
width: 220,
|
|
||||||
borderRight: '1px solid #333',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexShrink: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const Logo = define('Logo', {
|
|
||||||
padding: '20px 16px',
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
borderBottom: '1px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const SectionLabel = define('SectionLabel', {
|
|
||||||
padding: '16px 16px 8px',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#666',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
})
|
|
||||||
|
|
||||||
const AppList = define('AppList', {
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
})
|
|
||||||
|
|
||||||
const AppItem = define('AppItem', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '8px 16px',
|
|
||||||
color: '#999',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontSize: 14,
|
|
||||||
cursor: 'pointer',
|
|
||||||
selectors: {
|
|
||||||
'&:hover': { background: '#1a1a1a', color: '#e5e5e5' },
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
selected: { background: '#1f1f1f', color: '#fff', fontWeight: 500 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StatusDot = define('StatusDot', {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
flexShrink: 0,
|
|
||||||
variants: {
|
|
||||||
state: {
|
|
||||||
invalid: { background: '#ef4444' },
|
|
||||||
stopped: { background: '#666' },
|
|
||||||
starting: { background: '#eab308' },
|
|
||||||
running: { background: '#22c55e' },
|
|
||||||
stopping: { background: '#eab308' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const SidebarFooter = define('SidebarFooter', {
|
|
||||||
padding: 16,
|
|
||||||
borderTop: '1px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const NewAppButton = define('NewAppButton', {
|
|
||||||
display: 'block',
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: '#1f1f1f',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: '#999',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontSize: 14,
|
|
||||||
textAlign: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
selectors: {
|
|
||||||
'&:hover': { background: '#2a2a2a', color: '#e5e5e5' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Main pane
|
|
||||||
const Main = define('Main', {
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
})
|
|
||||||
|
|
||||||
const MainHeader = define('MainHeader', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '16px 24px',
|
|
||||||
borderBottom: '1px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const MainTitle = define('MainTitle', {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
margin: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const HeaderActions = define('HeaderActions', {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
})
|
|
||||||
|
|
||||||
const MainContent = define('MainContent', {
|
|
||||||
flex: 1,
|
|
||||||
padding: 24,
|
|
||||||
overflow: 'auto',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Section = define('Section', {
|
|
||||||
marginBottom: 32,
|
|
||||||
})
|
|
||||||
|
|
||||||
const SectionTitle = define('SectionTitle', {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#666',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
marginBottom: 12,
|
|
||||||
paddingBottom: 8,
|
|
||||||
borderBottom: '1px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const InfoRow = define('InfoRow', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
})
|
|
||||||
|
|
||||||
const InfoLabel = define('InfoLabel', {
|
|
||||||
color: '#666',
|
|
||||||
width: 80,
|
|
||||||
flexShrink: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const InfoValue = define('InfoValue', {
|
|
||||||
color: '#e5e5e5',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
})
|
|
||||||
|
|
||||||
const Link = define('Link', {
|
|
||||||
base: 'a',
|
|
||||||
color: '#22d3ee',
|
|
||||||
textDecoration: 'none',
|
|
||||||
selectors: {
|
|
||||||
'&:hover': { textDecoration: 'underline' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Button = define('Button', {
|
|
||||||
base: 'button',
|
|
||||||
padding: '6px 12px',
|
|
||||||
background: '#1f1f1f',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: '#e5e5e5',
|
|
||||||
fontSize: 13,
|
|
||||||
cursor: 'pointer',
|
|
||||||
selectors: {
|
|
||||||
'&:hover': { background: '#2a2a2a' },
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
danger: { borderColor: '#7f1d1d', color: '#fca5a5' },
|
|
||||||
primary: { background: '#1d4ed8', borderColor: '#1d4ed8' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ActionBar = define('ActionBar', {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 24,
|
|
||||||
paddingTop: 24,
|
|
||||||
borderTop: '1px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const EmptyState = define('EmptyState', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
color: '#666',
|
|
||||||
fontSize: 14,
|
|
||||||
})
|
|
||||||
|
|
||||||
const LogsContainer = define('LogsContainer', {
|
|
||||||
background: '#111',
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: 12,
|
|
||||||
fontFamily: 'ui-monospace, monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#888',
|
|
||||||
maxHeight: 200,
|
|
||||||
overflow: 'auto',
|
|
||||||
})
|
|
||||||
|
|
||||||
const LogLine = define('LogLine', {
|
|
||||||
marginBottom: 4,
|
|
||||||
selectors: {
|
|
||||||
'&:last-child': { marginBottom: 0 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const LogTime = define('LogTime', {
|
|
||||||
color: '#555',
|
|
||||||
marginRight: 12,
|
|
||||||
})
|
|
||||||
|
|
||||||
const stateLabels: Record<AppState, string> = {
|
|
||||||
invalid: 'Invalid',
|
|
||||||
stopped: 'Stopped',
|
|
||||||
starting: 'Starting',
|
|
||||||
running: 'Running',
|
|
||||||
stopping: 'Stopping',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions - call API then let SSE update the state
|
|
||||||
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
|
||||||
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
|
||||||
const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
|
||||||
|
|
||||||
const selectApp = (name: string) => {
|
|
||||||
selectedApp = name
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppDetail = ({ app }: { app: App }) => (
|
|
||||||
<>
|
|
||||||
<MainHeader>
|
|
||||||
<MainTitle>{app.name}</MainTitle>
|
|
||||||
<HeaderActions>
|
|
||||||
<Button>Settings</Button>
|
|
||||||
<Button variant="danger">Delete</Button>
|
|
||||||
</HeaderActions>
|
|
||||||
</MainHeader>
|
|
||||||
<MainContent>
|
|
||||||
<Section>
|
|
||||||
<SectionTitle>Status</SectionTitle>
|
|
||||||
<InfoRow>
|
|
||||||
<InfoLabel>State</InfoLabel>
|
|
||||||
<InfoValue>
|
|
||||||
<StatusDot state={app.state} />
|
|
||||||
{stateLabels[app.state]}
|
|
||||||
{app.port ? ` on :${app.port}` : ''}
|
|
||||||
</InfoValue>
|
|
||||||
</InfoRow>
|
|
||||||
{app.state === 'running' && app.port && (
|
|
||||||
<InfoRow>
|
|
||||||
<InfoLabel>URL</InfoLabel>
|
|
||||||
<InfoValue>
|
|
||||||
<Link href={`http://localhost:${app.port}`} target="_blank">
|
|
||||||
http://localhost:{app.port}
|
|
||||||
</Link>
|
|
||||||
</InfoValue>
|
|
||||||
</InfoRow>
|
|
||||||
)}
|
|
||||||
{app.started && (
|
|
||||||
<InfoRow>
|
|
||||||
<InfoLabel>Started</InfoLabel>
|
|
||||||
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
|
|
||||||
</InfoRow>
|
|
||||||
)}
|
|
||||||
{app.state === 'invalid' && (
|
|
||||||
<InfoRow>
|
|
||||||
<InfoLabel>Error</InfoLabel>
|
|
||||||
<InfoValue style={{ color: '#f87171' }}>
|
|
||||||
Missing or invalid package.json
|
|
||||||
</InfoValue>
|
|
||||||
</InfoRow>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section>
|
|
||||||
<SectionTitle>Logs</SectionTitle>
|
|
||||||
<LogsContainer>
|
|
||||||
{app.logs?.length ? (
|
|
||||||
app.logs.map((line, i) => (
|
|
||||||
<LogLine key={i}>
|
|
||||||
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
|
|
||||||
<span>{line.text}</span>
|
|
||||||
</LogLine>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<LogLine>
|
|
||||||
<LogTime>--:--:--</LogTime>
|
|
||||||
<span style={{ color: '#666' }}>No logs yet</span>
|
|
||||||
</LogLine>
|
|
||||||
)}
|
|
||||||
</LogsContainer>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<ActionBar>
|
|
||||||
{app.state === 'stopped' && (
|
|
||||||
<Button variant="primary" onClick={() => startApp(app.name)}>
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{app.state === 'running' && (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => restartApp(app.name)}>Restart</Button>
|
|
||||||
<Button variant="danger" onClick={() => stopApp(app.name)}>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(app.state === 'starting' || app.state === 'stopping') && (
|
|
||||||
<Button disabled>{stateLabels[app.state]}...</Button>
|
|
||||||
)}
|
|
||||||
</ActionBar>
|
|
||||||
</MainContent>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
|
||||||
const selected = apps.find(a => a.name === selectedApp)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Styles />
|
|
||||||
<Sidebar>
|
|
||||||
<Logo>🐾 Toes</Logo>
|
|
||||||
<SectionLabel>Apps</SectionLabel>
|
|
||||||
<AppList>
|
|
||||||
{apps.map(app => (
|
|
||||||
<AppItem
|
|
||||||
key={app.name}
|
|
||||||
onClick={() => selectApp(app.name)}
|
|
||||||
selected={app.name === selectedApp ? true : undefined}
|
|
||||||
>
|
|
||||||
<StatusDot state={app.state} />
|
|
||||||
{app.name}
|
|
||||||
</AppItem>
|
|
||||||
))}
|
|
||||||
</AppList>
|
|
||||||
<SidebarFooter>
|
|
||||||
<NewAppButton>+ New App</NewAppButton>
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
<Main>
|
|
||||||
{selected ? (
|
|
||||||
<AppDetail app={selected} />
|
|
||||||
) : (
|
|
||||||
<EmptyState>Select an app to view details</EmptyState>
|
|
||||||
)}
|
|
||||||
</Main>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
renderApp(<Dashboard />, document.getElementById('app')!)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE connection
|
|
||||||
const events = new EventSource('/api/apps/stream')
|
|
||||||
events.onmessage = e => {
|
|
||||||
apps = JSON.parse(e.data)
|
|
||||||
if (!selectedApp && apps.length) selectedApp = apps[0]!.name
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,141 @@
|
||||||
import { Shell } from '../server/shell'
|
import { define, Styles } from 'forge'
|
||||||
|
import { allApps } from '../server/apps'
|
||||||
|
import type { AppState } from '../shared/types'
|
||||||
|
|
||||||
export default () => <Shell />
|
const Apps = define('Apps', {
|
||||||
|
margin: '0 auto',
|
||||||
|
width: 750,
|
||||||
|
paddingTop: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const color = '#00c0c9'
|
||||||
|
const hoverColor = 'magenta'
|
||||||
|
|
||||||
|
const Link = define({
|
||||||
|
base: 'a',
|
||||||
|
color,
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: `1px solid ${color}`,
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
color: hoverColor,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppCard = define('AppCard', {
|
||||||
|
marginBottom: 24,
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppHeader = define('AppHeader', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppName = define('AppName', {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const State = define('State', {
|
||||||
|
fontSize: 14,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
invalid: { background: '#4a1c1c', color: '#f87171' },
|
||||||
|
stopped: { background: '#3a3a3a', color: '#9ca3af' },
|
||||||
|
starting: { background: '#3b3117', color: '#fbbf24' },
|
||||||
|
running: { background: '#14532d', color: '#4ade80' },
|
||||||
|
stopping: { background: '#3b3117', color: '#fbbf24' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Info = define('Info', {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9ca3af',
|
||||||
|
margin: '4px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ActionBar = define('ActionBar', {
|
||||||
|
marginTop: 12,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Button = define({
|
||||||
|
base: 'button',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'form:has(>&)': {
|
||||||
|
display: 'inline'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root } }) {
|
||||||
|
if (!props.post)
|
||||||
|
return <Root>{props.children}</Root>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form method='post' action={props.post}>
|
||||||
|
<Root onClick="this.closest('form').submit()">{props.children}</Root>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stateLabels: Record<AppState, string> = {
|
||||||
|
invalid: 'Invalid',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
starting: 'Starting...',
|
||||||
|
running: 'Running',
|
||||||
|
stopping: 'Stopping...',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => (
|
||||||
|
<Apps>
|
||||||
|
<Styles />
|
||||||
|
<h1>🐾 Apps</h1>
|
||||||
|
{allApps().map(app => (
|
||||||
|
<AppCard>
|
||||||
|
<AppHeader>
|
||||||
|
<AppName>
|
||||||
|
{app.state === 'running' && app.port ? (
|
||||||
|
<Link href={`http://localhost:${app.port}`}>{app.name}</Link>
|
||||||
|
) : (
|
||||||
|
app.name
|
||||||
|
)}
|
||||||
|
</AppName>
|
||||||
|
<State status={app.state}>{stateLabels[app.state]}</State>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
|
{app.port ? <Info>Port: {app.port}</Info> : null}
|
||||||
|
{app.started && <Info>Started: {new Date(app.started).toLocaleString()}</Info>}
|
||||||
|
|
||||||
|
<ActionBar>
|
||||||
|
{app.state === 'stopped' && (
|
||||||
|
<Button post={`/apps/${app.name}/start`}>Start</Button>
|
||||||
|
)}
|
||||||
|
{app.state === 'running' && (
|
||||||
|
<>
|
||||||
|
<Button post={`/apps/${app.name}/stop`}>Stop</Button>
|
||||||
|
<Button post={`/apps/${app.name}/restart`}>Restart</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{app.state === 'invalid' && (
|
||||||
|
<Info>Missing or invalid package.json</Info>
|
||||||
|
)}
|
||||||
|
</ActionBar>
|
||||||
|
</AppCard>
|
||||||
|
))}
|
||||||
|
</Apps>
|
||||||
|
)
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { existsSync, readdirSync, readFileSync, watch } from 'fs'
|
import { existsSync, readdirSync, readFileSync, watch } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { App as SharedApp, AppState, LogLine } from '../shared/types'
|
import type { App as SharedApp, AppState } from '../shared/types'
|
||||||
|
|
||||||
export type { AppState } from '../shared/types'
|
export type { AppState } from '../shared/types'
|
||||||
|
|
||||||
const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
||||||
const MAX_LOGS = 100
|
|
||||||
|
|
||||||
export type App = SharedApp & {
|
export type App = SharedApp & {
|
||||||
proc?: Subprocess
|
proc?: Subprocess
|
||||||
|
|
@ -14,14 +13,6 @@ export type App = SharedApp & {
|
||||||
|
|
||||||
const _apps = new Map<string, App>()
|
const _apps = new Map<string, App>()
|
||||||
|
|
||||||
// Change notification system
|
|
||||||
const _listeners = new Set<() => void>()
|
|
||||||
export const onChange = (cb: () => void) => {
|
|
||||||
_listeners.add(cb)
|
|
||||||
return () => _listeners.delete(cb)
|
|
||||||
}
|
|
||||||
const update = () => _listeners.forEach(cb => cb())
|
|
||||||
|
|
||||||
const err = (app: string, ...msg: string[]) =>
|
const err = (app: string, ...msg: string[]) =>
|
||||||
console.error('🐾', `${app}:`, ...msg)
|
console.error('🐾', `${app}:`, ...msg)
|
||||||
|
|
||||||
|
|
@ -97,8 +88,6 @@ const runApp = async (dir: string, port: number) => {
|
||||||
// Set state to starting
|
// Set state to starting
|
||||||
app.state = 'starting'
|
app.state = 'starting'
|
||||||
app.port = port
|
app.port = port
|
||||||
app.logs = []
|
|
||||||
update()
|
|
||||||
|
|
||||||
const cwd = join(APPS_DIR, dir)
|
const cwd = join(APPS_DIR, dir)
|
||||||
|
|
||||||
|
|
@ -120,7 +109,6 @@ const runApp = async (dir: string, port: number) => {
|
||||||
app.state = 'running'
|
app.state = 'running'
|
||||||
app.proc = proc
|
app.proc = proc
|
||||||
app.started = Date.now()
|
app.started = Date.now()
|
||||||
update()
|
|
||||||
|
|
||||||
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
||||||
if (!stream) return
|
if (!stream) return
|
||||||
|
|
@ -132,9 +120,6 @@ const runApp = async (dir: string, port: number) => {
|
||||||
const text = decoder.decode(value).trimEnd()
|
const text = decoder.decode(value).trimEnd()
|
||||||
if (text) {
|
if (text) {
|
||||||
log(dir, text)
|
log(dir, text)
|
||||||
const line: LogLine = { time: Date.now(), text }
|
|
||||||
app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line]
|
|
||||||
update()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +139,6 @@ const runApp = async (dir: string, port: number) => {
|
||||||
app.proc = undefined
|
app.proc = undefined
|
||||||
app.port = undefined
|
app.port = undefined
|
||||||
app.started = undefined
|
app.started = undefined
|
||||||
update()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +165,6 @@ export const stopApp = (dir: string) => {
|
||||||
|
|
||||||
info(dir, 'Stopping...')
|
info(dir, 'Stopping...')
|
||||||
app.state = 'stopping'
|
app.state = 'stopping'
|
||||||
update()
|
|
||||||
app.proc?.kill()
|
app.proc?.kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +179,6 @@ const watchAppsDir = () => {
|
||||||
if (!_apps.has(dir)) {
|
if (!_apps.has(dir)) {
|
||||||
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
|
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
|
||||||
_apps.set(dir, { name: dir, state })
|
_apps.set(dir, { name: dir, state })
|
||||||
update()
|
|
||||||
if (state === 'stopped') {
|
if (state === 'stopped') {
|
||||||
runApp(dir, getPort())
|
runApp(dir, getPort())
|
||||||
}
|
}
|
||||||
|
|
@ -225,11 +207,9 @@ const watchAppsDir = () => {
|
||||||
// Update state if already stopped/invalid
|
// Update state if already stopped/invalid
|
||||||
if (!valid && app.state === 'stopped') {
|
if (!valid && app.state === 'stopped') {
|
||||||
app.state = 'invalid'
|
app.state = 'invalid'
|
||||||
update()
|
|
||||||
}
|
}
|
||||||
if (valid && app.state === 'invalid') {
|
if (valid && app.state === 'invalid') {
|
||||||
app.state = 'stopped'
|
app.state = 'stopped'
|
||||||
update()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,37 @@
|
||||||
import { Hype } from 'hype'
|
import { Hype } from 'hype'
|
||||||
import { allApps, initApps, onChange, startApp, stopApp } from './apps'
|
import { initApps, startApp, stopApp } from './apps'
|
||||||
import type { App as SharedApp } from '../shared/types'
|
|
||||||
|
|
||||||
const app = new Hype({ layout: false })
|
const app = new Hype()
|
||||||
|
|
||||||
console.log('🐾 Toes!')
|
console.log('🐾 Toes!')
|
||||||
initApps()
|
initApps()
|
||||||
|
|
||||||
// SSE endpoint for real-time app state updates
|
app.post('/apps/:app/start', c => {
|
||||||
app.get('/api/apps/stream', c => {
|
const app = c.req.param('app')
|
||||||
const encoder = new TextEncoder()
|
if (!app) return render404(c)
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
startApp(app)
|
||||||
start(controller) {
|
return c.redirect('/')
|
||||||
const send = () => {
|
|
||||||
// Strip proc field from apps before sending
|
|
||||||
const apps: SharedApp[] = allApps().map(({ name, state, port, started, logs }) => ({
|
|
||||||
name,
|
|
||||||
state,
|
|
||||||
port,
|
|
||||||
started,
|
|
||||||
logs,
|
|
||||||
}))
|
|
||||||
const data = JSON.stringify(apps)
|
|
||||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send initial state
|
|
||||||
send()
|
|
||||||
|
|
||||||
// Subscribe to changes
|
|
||||||
const unsub = onChange(send)
|
|
||||||
|
|
||||||
// Handle client disconnect via abort signal
|
|
||||||
c.req.raw.signal.addEventListener('abort', () => {
|
|
||||||
unsub()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/apps/:app/start', c => {
|
app.post('/apps/:app/restart', c => {
|
||||||
const appName = c.req.param('app')
|
const app = c.req.param('app')
|
||||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
if (!app) return render404(c)
|
||||||
|
|
||||||
startApp(appName)
|
stopApp(app)
|
||||||
return c.json({ ok: true })
|
startApp(app)
|
||||||
|
return c.redirect('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/apps/:app/restart', c => {
|
app.post('/apps/:app/stop', c => {
|
||||||
const appName = c.req.param('app')
|
const app = c.req.param('app')
|
||||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
if (!app) return render404(c)
|
||||||
|
|
||||||
stopApp(appName)
|
stopApp(app)
|
||||||
startApp(appName)
|
return c.redirect('/')
|
||||||
return c.json({ ok: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/apps/:app/stop', c => {
|
const render404 = (c: any) =>
|
||||||
const appName = c.req.param('app')
|
c.text('404 Not Found', { status: 404 })
|
||||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
||||||
|
|
||||||
stopApp(appName)
|
|
||||||
return c.json({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
export const Shell = () => (
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Toes</title>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/client/index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
|
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
|
||||||
|
|
||||||
export type LogLine = {
|
|
||||||
time: number
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type App = {
|
export type App = {
|
||||||
name: string
|
name: string
|
||||||
state: AppState
|
state: AppState
|
||||||
port?: number
|
port?: number
|
||||||
started?: number
|
started?: number
|
||||||
logs?: LogLine[]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user