This commit is contained in:
Chris Wanstrath 2026-02-12 16:24:40 -08:00
parent 75af5f3d31
commit 4a31d7bb69
11 changed files with 261 additions and 11 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
# dependencies (bun install)
node_modules
pub/client/index.js
toes/
# output
out

View File

@ -3,10 +3,11 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "toes",
"name": "@because/toes",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "*",
"commander": "^14.0.2",
"diff": "^8.0.3",
"kleur": "^4.1.5",
@ -25,24 +26,28 @@
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@because/sneaker": ["@because/sneaker@0.0.1", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.1.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-rN9hc13ofap+7SvfShJkTJQYBcViCiElyfb8FBMzP1SKIe8B71csZeLh+Ujye/5538ojWfM/5hRRPJ+Aa/0A+g=="],
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"@types/node": ["@types/node@25.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
}
}

View File

@ -44,6 +44,7 @@
"@because/hype": "^0.0.2",
"commander": "^14.0.2",
"diff": "^8.0.3",
"kleur": "^4.1.5"
"kleur": "^4.1.5",
"@because/sneaker": "*"
}
}

View File

@ -68,6 +68,9 @@ export async function infoApp(arg?: string) {
console.log(` Port: ${app.port}`)
console.log(` URL: ${makeAppUrl(app.port)}`)
}
if (app.tunnelUrl) {
console.log(` Tunnel: ${app.tunnelUrl}`)
}
if (app.pid) {
console.log(` PID: ${app.pid}`)
}

View File

@ -4,6 +4,12 @@ export const getLogDates = (name: string): Promise<string[]> =>
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const disableTunnel = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
export const enableTunnel = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })

View File

@ -1,6 +1,6 @@
import { define } from '@because/forge'
import type { App } from '../../shared/types'
import { restartApp, startApp, stopApp } from '../api'
import { disableTunnel, enableTunnel, restartApp, startApp, stopApp } from '../api'
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow } from '../state'
import {
@ -61,6 +61,13 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
)}
</MainTitle>
<HeaderActions>
{!app.tool && (
app.tunnelUrl
? <Button onClick={() => { disableTunnel(app.name) }}>Unshare</Button>
: app.tunnelEnabled
? <Button disabled>Sharing...</Button>
: <Button disabled={app.state !== 'running'} onClick={() => { enableTunnel(app.name) }}>Share</Button>
)}
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
</HeaderActions>
</MainHeader>
@ -87,6 +94,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
</InfoValue>
</InfoRow>
)}
{app.tunnelUrl && (
<InfoRow>
<InfoLabel>Tunnel</InfoLabel>
<InfoValue>
<Link href={app.tunnelUrl} target="_blank">{app.tunnelUrl}</Link>
</InfoValue>
</InfoRow>
)}
{app.state === 'running' && app.port && (
<InfoRow>
<InfoLabel>Port</InfoLabel>

View File

@ -12,6 +12,8 @@ export const Button = define('Button', {
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover') },
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
'&:disabled:hover': { background: theme('colors-bgElement') },
},
variants: {
variant: {

View File

@ -1,4 +1,5 @@
import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import { disableTunnel, enableTunnel, isTunnelsAvailable } from '../tunnels'
import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
@ -24,9 +25,9 @@ function convert(app: BackendApp): SharedApp {
router.sse('/stream', (send) => {
const broadcast = () => {
const apps: SharedApp[] = allApps().map(({
name, state, icon, error, port, started, logs, tool
name, state, icon, error, port, started, logs, tool, tunnelEnabled, tunnelUrl
}) => ({
name, state, icon, error, port, started, logs, tool,
name, state, icon, error, port, started, logs, tool, tunnelEnabled, tunnelUrl,
}))
send(apps)
}
@ -383,4 +384,32 @@ router.delete('/:app/env/:key', async c => {
return c.json({ ok: true })
})
// --- Tunnels ---
router.post('/:app/tunnel', c => {
if (!isTunnelsAvailable()) return c.json({ ok: false, error: 'Tunnels are not available' }, 400)
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400)
enableTunnel(appName, app.port ?? 0)
return c.json({ ok: true })
})
router.delete('/:app/tunnel', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
disableTunnel(appName)
return c.json({ ok: true })
})
export default router

View File

@ -5,6 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realp
import { hostname } from 'os'
import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env'
import { closeAllTunnels, closeTunnel, disableTunnel, openTunnelIfEnabled, renameTunnelConfig } from './tunnels'
import { appLog, hostLog, setApps } from './tui'
export type { AppState } from '@types'
@ -113,6 +114,8 @@ export function removeApp(dir: string) {
const app = _apps.get(dir)
if (!app) return
disableTunnel(dir)
// Clear all timers
clearTimers(app)
@ -184,6 +187,7 @@ export async function renameApp(oldName: string, newName: string): Promise<{ ok:
app.manuallyStopped = false
app.restartAttempts = 0
_apps.set(newName, app)
renameTunnelConfig(oldName, newName)
update()
@ -301,7 +305,7 @@ const logFile = (appName: string, date: string = formatLogDate()) =>
const isApp = (dir: string): boolean =>
!loadApp(dir).error
const update = () => {
export const update = () => {
setApps(allApps())
_listeners.forEach(cb => cb())
}
@ -396,6 +400,7 @@ function getPort(appName?: string): number {
async function gracefulShutdown(signal: string) {
if (_shuttingDown) return
_shuttingDown = true
closeAllTunnels()
hostLog(`Received ${signal}, shutting down gracefully...`)
@ -477,6 +482,7 @@ function markAsRunning(app: App, port: number, isHttpApp: boolean) {
app.started = Date.now()
app.isHttpApp = isHttpApp
update()
openTunnelIfEnabled(app.name, port)
if (isHttpApp) {
startHealthChecks(app, port)
@ -698,6 +704,8 @@ async function runApp(dir: string, port: number) {
writeLogLine(dir, 'system', 'Stopped')
}
closeTunnel(dir)
// Release port back to pool
if (app.port) {
releasePort(app.port)

178
src/server/tunnels.ts Normal file
View File

@ -0,0 +1,178 @@
import type { Tunnel } from 'sneaker/client'
import { connect } from 'sneaker/client'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { getApp, TOES_DIR, update } from '$apps'
import { hostLog } from './tui'
const SNEAKER_URL = process.env.SNEAKER_URL ?? 'https://toes.space'
type TunnelConfig = Record<string, { subdomain?: string }>
const _tunnels = new Map<string, Tunnel>()
const configPath = () =>
join(TOES_DIR, 'tunnels.json')
const loadConfig = (): TunnelConfig => {
const path = configPath()
if (!existsSync(path)) return {}
try {
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return {}
}
}
const saveConfig = (config: TunnelConfig) => {
const dir = TOES_DIR
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n')
}
const toWsUrl = (url: string): string =>
url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
function buildTunnelUrl(subdomain: string): string {
const parsed = new URL(SNEAKER_URL!)
const port = parsed.port && parsed.port !== '80' && parsed.port !== '443'
? `:${parsed.port}`
: ''
return `${parsed.protocol}//${subdomain}.${parsed.hostname}${port}`
}
export const isTunnelsAvailable = (): boolean =>
!!SNEAKER_URL
export function closeAllTunnels() {
for (const [, tunnel] of _tunnels) {
tunnel.close()
}
_tunnels.clear()
}
export function closeTunnel(appName: string) {
const tunnel = _tunnels.get(appName)
if (!tunnel) return
tunnel.close()
_tunnels.delete(appName)
const app = getApp(appName)
if (app) {
app.tunnelEnabled = false
app.tunnelUrl = undefined
update()
}
}
export function disableTunnel(appName: string) {
closeTunnel(appName)
const config = loadConfig()
delete config[appName]
saveConfig(config)
const app = getApp(appName)
if (app) {
app.tunnelEnabled = false
update()
}
}
export function enableTunnel(appName: string, port: number) {
if (!SNEAKER_URL) return
const app = getApp(appName)
if (app) {
app.tunnelEnabled = true
update()
}
// Save to config (even if port is 0, we want it enabled for when the app starts)
const config = loadConfig()
const existing = config[appName]
config[appName] = existing ?? {}
saveConfig(config)
if (port > 0) {
openTunnel(appName, port, config[appName]?.subdomain)
}
}
export function openTunnelIfEnabled(appName: string, port: number) {
if (!SNEAKER_URL) return
const config = loadConfig()
if (!config[appName]) return
const app = getApp(appName)
if (app) {
app.tunnelEnabled = true
update()
}
openTunnel(appName, port, config[appName]?.subdomain)
}
export function renameTunnelConfig(oldName: string, newName: string) {
const config = loadConfig()
if (!config[oldName]) return
config[newName] = config[oldName]!
delete config[oldName]
saveConfig(config)
}
function openTunnel(appName: string, port: number, subdomain?: string) {
// Close existing tunnel if any
const existing = _tunnels.get(appName)
if (existing) {
existing.close()
_tunnels.delete(appName)
}
const wsUrl = toWsUrl(SNEAKER_URL!)
const tunnel = connect({
server: wsUrl,
app: appName,
target: `http://localhost:${port}`,
subdomain,
onOpen(assignedSubdomain) {
hostLog(`Tunnel open: ${appName} -> ${assignedSubdomain}`)
// Save subdomain for reconnection
const config = loadConfig()
if (config[appName]) {
config[appName]!.subdomain = assignedSubdomain
saveConfig(config)
}
const app = getApp(appName)
if (app) {
app.tunnelUrl = buildTunnelUrl(assignedSubdomain)
update()
}
},
onClose() {
hostLog(`Tunnel closed: ${appName}`)
_tunnels.delete(appName)
const app = getApp(appName)
if (app) {
app.tunnelEnabled = false
app.tunnelUrl = undefined
update()
}
},
onError(error) {
hostLog(`Tunnel error (${appName}): ${error.message}`)
},
})
_tunnels.set(appName, tunnel)
}

View File

@ -28,4 +28,6 @@ export type App = {
started?: number
logs?: LogLine[]
tool?: boolean | string
tunnelEnabled?: boolean
tunnelUrl?: string
}