diff --git a/.gitignore b/.gitignore index d419c91..0b364b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # dependencies (bun install) node_modules pub/client/index.js +toes/ # output out diff --git a/bun.lock b/bun.lock index a474b29..00c70ad 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/package.json b/package.json index deb80b9..7f7067a 100644 --- a/package.json +++ b/package.json @@ -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": "*" } } diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts index aaf6ccb..82427d7 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -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}`) } diff --git a/src/client/api.ts b/src/client/api.ts index accc7c1..3a65821 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -4,6 +4,12 @@ export const getLogDates = (name: string): Promise => export const getLogsForDate = (name: string, date: string): Promise => 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' }) diff --git a/src/client/components/AppDetail.tsx b/src/client/components/AppDetail.tsx index 84b7442..e2f3868 100644 --- a/src/client/components/AppDetail.tsx +++ b/src/client/components/AppDetail.tsx @@ -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 }) { )} + {!app.tool && ( + app.tunnelUrl + ? + : app.tunnelEnabled + ? + : + )} @@ -87,6 +94,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) { )} + {app.tunnelUrl && ( + + Tunnel + + {app.tunnelUrl} + + + )} {app.state === 'running' && app.port && ( Port diff --git a/src/client/styles/buttons.ts b/src/client/styles/buttons.ts index 600485f..0b2720e 100644 --- a/src/client/styles/buttons.ts +++ b/src/client/styles/buttons.ts @@ -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: { diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 51308bc..c951513 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -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 diff --git a/src/server/apps.ts b/src/server/apps.ts index 95cb936..4bc7a9d 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -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) diff --git a/src/server/tunnels.ts b/src/server/tunnels.ts new file mode 100644 index 0000000..c9f4f45 --- /dev/null +++ b/src/server/tunnels.ts @@ -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 + +const _tunnels = new Map() + +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) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 457b5ef..d0bdf71 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,4 +28,6 @@ export type App = { started?: number logs?: LogLine[] tool?: boolean | string + tunnelEnabled?: boolean + tunnelUrl?: string }