diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts index 7f0a600..e6485bc 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -4,7 +4,8 @@ import { readSyncState } from '%sync' import color from 'kleur' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { basename, join } from 'path' -import { del, get, getManifest, HOST, makeAppUrl, post } from '../http' +import { buildAppUrl } from '@urls' +import { del, get, getManifest, HOST, post } from '../http' import { confirm, prompt } from '../prompts' import { resolveAppName } from '../name' import { pushApp } from './sync' @@ -57,9 +58,11 @@ export async function infoApp(arg?: string) { const icon = STATE_ICONS[app.state] ?? '◯' console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`) console.log(` State: ${app.state}`) + if (app.state === 'running') { + console.log(` URL: ${buildAppUrl(app.name, HOST)}`) + } if (app.port) { console.log(` Port: ${app.port}`) - console.log(` URL: ${makeAppUrl(app.port)}`) } if (app.tunnelUrl) { console.log(` Tunnel: ${app.tunnelUrl}`) @@ -207,7 +210,7 @@ export async function openApp(arg?: string) { console.error(`App is not running: ${name}`) return } - const url = makeAppUrl(app.port!) + const url = buildAppUrl(app.name, HOST) console.log(`Opening ${url}`) Bun.spawn(['open', url]) } diff --git a/src/cli/http.ts b/src/cli/http.ts index bb52ce5..68d5455 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -22,12 +22,6 @@ export function makeUrl(path: string): string { return `${HOST}${path}` } -export function makeAppUrl(port: number): string { - const url = new URL(HOST) - url.port = String(port) - return url.toString().replace(/\/$/, '') -} - export function handleError(error: unknown): void { if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') { console.error(`🐾 Can't connect to toes server at ${HOST}`) diff --git a/src/client/api.ts b/src/client/api.ts index 3a65821..1671b3f 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -4,12 +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) => +export const shareApp = (name: string) => fetch(`/api/apps/${name}/tunnel`, { method: 'POST' }) +export const unshareApp = (name: string) => + fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' }) + 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 ba59c03..7265797 100644 --- a/src/client/components/AppDetail.tsx +++ b/src/client/components/AppDetail.tsx @@ -1,6 +1,7 @@ import { define } from '@because/forge' import type { App } from '../../shared/types' -import { disableTunnel, enableTunnel, restartApp, startApp, stopApp } from '../api' +import { buildAppUrl } from '../../shared/urls' +import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api' import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals' import { apps, getSelectedTab, isNarrow } from '../state' import { @@ -63,10 +64,10 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) { {!app.tool && ( app.tunnelUrl - ? + ? : app.tunnelEnabled ? - : + : )} @@ -84,12 +85,12 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) { {stateLabels[app.state]} - {app.state === 'running' && app.port && ( + {app.state === 'running' && ( URL - - {location.protocol}//{location.hostname}:{app.port} + + {buildAppUrl(app.name, location.origin)} diff --git a/src/client/tool-iframes.ts b/src/client/tool-iframes.ts index 3b45684..2719c2d 100644 --- a/src/client/tool-iframes.ts +++ b/src/client/tool-iframes.ts @@ -70,7 +70,7 @@ export function initToolIframes() { ` } -// Build URL with params using TOES_URL base +// Build URL with params using port-based routing function buildToolUrl(port: number, params: Record): string { const base = new URL(window.location.origin) base.port = String(port) diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index f1e99f2..9c136c5 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -1,5 +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 { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels' import type { App as BackendApp } from '$apps' import type { App as SharedApp } from '@types' import { generateTemplates, type TemplateType } from '%templates' @@ -398,7 +398,7 @@ router.post('/:app/tunnel', c => { if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400) - enableTunnel(appName, app.port ?? 0) + shareApp(appName, app.port ?? 0) return c.json({ ok: true }) }) @@ -409,7 +409,7 @@ router.delete('/:app/tunnel', c => { const app = allApps().find(a => a.name === appName) if (!app) return c.json({ error: 'App not found' }, 404) - disableTunnel(appName) + unshareApp(appName) return c.json({ ok: true }) }) diff --git a/src/server/apps.ts b/src/server/apps.ts index 37c79f6..7eaf4fc 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -2,11 +2,13 @@ import type { App as SharedApp, AppState } from '@types' import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' +import { buildAppUrl } from '@urls' import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs' import { hostname } from 'os' import { join, resolve } from 'path' import { loadAppEnv } from '../tools/env' -import { disableTunnel, openTunnelIfEnabled, renameTunnelConfig } from './tunnels' +import { publishApp, unpublishAll, unpublishApp } from './mdns' +import { closeAllTunnels, closeTunnel, openTunnelIfEnabled, renameTunnelConfig, unshareApp } from './tunnels' import { appLog, hostLog, setApps } from './tui' export type { AppState } from '@types' @@ -129,7 +131,8 @@ export function removeApp(dir: string) { const app = _apps.get(dir) if (!app) return - disableTunnel(dir) + unpublishApp(dir) + unshareApp(dir) // Clear all timers clearTimers(app) @@ -420,6 +423,8 @@ function getPort(appName?: string): number { async function gracefulShutdown(signal: string) { if (_shuttingDown) return _shuttingDown = true + unpublishAll() + closeAllTunnels() hostLog(`Received ${signal}, shutting down gracefully...`) @@ -521,6 +526,7 @@ function markAsRunning(app: App, port: number, isHttpApp: boolean) { app.isHttpApp = isHttpApp update() emit({ type: 'app:start', app: app.name }) + publishApp(app.name) openTunnelIfEnabled(app.name, port) if (isHttpApp) { @@ -632,7 +638,7 @@ async function runApp(dir: string, port: number) { const proc = Bun.spawn(['bun', 'run', 'toes'], { cwd, - env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APPS_DIR, DATA_DIR: join(process.env.DATA_DIR ?? '.', 'toes', dir), TOES_DIR, TOES_URL }, + env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APP_URL: buildAppUrl(dir, TOES_URL), APPS_DIR, DATA_DIR: join(process.env.DATA_DIR ?? '.', 'toes', dir), TOES_DIR, TOES_URL }, stdout: 'pipe', stderr: 'pipe', }) @@ -736,6 +742,9 @@ async function runApp(dir: string, port: number) { writeLogLine(dir, 'system', 'Stopped') } + unpublishApp(dir) + closeTunnel(dir) + // Release port back to pool if (app.port) { releasePort(app.port) diff --git a/src/server/index.tsx b/src/server/index.tsx index a2d0b20..0a2e899 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,9 +1,12 @@ import { allApps, initApps, TOES_URL } from '$apps' +import { buildAppUrl } from '@urls' import appsRouter from './api/apps' import eventsRouter from './api/events' import syncRouter from './api/sync' import systemRouter from './api/system' import { Hype } from '@because/hype' +import { cleanupStalePublishers } from './mdns' +import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy' const app = new Hype({ layout: false, logging: !!process.env.DEBUG }) @@ -12,7 +15,7 @@ app.route('/api/events', eventsRouter) app.route('/api/sync', syncRouter) app.route('/api/system', systemRouter) -// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port +// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain app.get('/tool/:tool', c => { const toolName = c.req.param('tool') const tool = allApps().find(a => a.tool && a.name === toolName) @@ -20,9 +23,8 @@ app.get('/tool/:tool', c => { return c.text(`Tool "${toolName}" not found or not running`, 404) } const params = new URLSearchParams(c.req.query()).toString() - const base = new URL(TOES_URL) - base.port = String(tool.port) - const url = params ? `${base.origin}?${params}` : base.origin + const base = buildAppUrl(toolName, TOES_URL) + const url = params ? `${base}?${params}` : base return c.redirect(url) }) @@ -55,9 +57,23 @@ app.all('/api/tools/:tool/:path{.+}', async c => { }) }) +cleanupStalePublishers() await initApps() +const defaults = app.defaults + export default { - ...app.defaults, + ...defaults, maxRequestBodySize: 1024 * 1024 * 50, // 50MB + fetch(req: Request, server: any) { + const subdomain = extractSubdomain(req.headers.get('host') ?? '') + if (subdomain) { + if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') { + return proxyWebSocket(subdomain, req, server) + } + return proxySubdomain(subdomain, req) + } + return defaults.fetch.call(app, req, server) + }, + websocket, } diff --git a/src/server/mdns.ts b/src/server/mdns.ts new file mode 100644 index 0000000..ebdb186 --- /dev/null +++ b/src/server/mdns.ts @@ -0,0 +1,81 @@ +import type { Subprocess } from 'bun' +import { networkInterfaces } from 'os' +import { hostLog } from './tui' + +const _publishers = new Map() + +const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux' + +function getLocalIp(): string | null { + const interfaces = networkInterfaces() + for (const iface of Object.values(interfaces)) { + if (!iface) continue + for (const addr of iface) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address + } + } + } + return null +} + +export function cleanupStalePublishers() { + if (!isEnabled) return + + try { + const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local']) + if (result.exitCode === 0) { + hostLog('mDNS: cleaned up stale avahi-publish processes') + } + } catch {} +} + +export function publishApp(name: string) { + if (!isEnabled) return + if (_publishers.has(name)) return + + const ip = getLocalIp() + if (!ip) { + hostLog(`mDNS: no local IP found, skipping ${name}`) + return + } + + const hostname = `${name}.toes.local` + + try { + const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], { + stdout: 'ignore', + stderr: 'ignore', + }) + + _publishers.set(name, proc) + hostLog(`mDNS: published ${hostname} -> ${ip}`) + + proc.exited.then(() => { + _publishers.delete(name) + }) + } catch { + hostLog(`mDNS: failed to publish ${hostname}`) + } +} + +export function unpublishApp(name: string) { + if (!isEnabled) return + + const proc = _publishers.get(name) + if (!proc) return + + proc.kill() + _publishers.delete(name) + hostLog(`mDNS: unpublished ${name}.toes.local`) +} + +export function unpublishAll() { + if (!isEnabled) return + + for (const [name, proc] of _publishers) { + proc.kill() + hostLog(`mDNS: unpublished ${name}.toes.local`) + } + _publishers.clear() +} diff --git a/src/server/proxy.test.ts b/src/server/proxy.test.ts new file mode 100644 index 0000000..c82c27f --- /dev/null +++ b/src/server/proxy.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { extractSubdomain } from './proxy' + +describe('extractSubdomain', () => { + describe('*.localhost (dev)', () => { + test('should extract subdomain from clock.localhost:3000', () => { + expect(extractSubdomain('clock.localhost:3000')).toBe('clock') + }) + + test('should extract subdomain from clock.localhost', () => { + expect(extractSubdomain('clock.localhost')).toBe('clock') + }) + + test('should return null for bare localhost:3000', () => { + expect(extractSubdomain('localhost:3000')).toBeNull() + }) + + test('should return null for bare localhost', () => { + expect(extractSubdomain('localhost')).toBeNull() + }) + + test('should handle hyphenated app names', () => { + expect(extractSubdomain('my-app.localhost:3000')).toBe('my-app') + }) + }) + + describe('*.toes.local (production)', () => { + test('should extract subdomain from clock.toes.local', () => { + expect(extractSubdomain('clock.toes.local')).toBe('clock') + }) + + test('should return null for bare toes.local', () => { + expect(extractSubdomain('toes.local')).toBeNull() + }) + + test('should handle hyphenated app names', () => { + expect(extractSubdomain('my-app.toes.local')).toBe('my-app') + }) + }) + + describe('other hosts', () => { + test('should return null for plain IP', () => { + expect(extractSubdomain('192.168.1.50:3000')).toBeNull() + }) + + test('should return null for empty string', () => { + expect(extractSubdomain('')).toBeNull() + }) + }) +}) diff --git a/src/server/proxy.ts b/src/server/proxy.ts new file mode 100644 index 0000000..0258496 --- /dev/null +++ b/src/server/proxy.ts @@ -0,0 +1,105 @@ +import type { ServerWebSocket } from 'bun' +import { getApp } from '$apps' + +const upstreams = new Map, WebSocket>() + +interface WsData { + port: number + path: string +} + +export function extractSubdomain(host: string): string | null { + // Strip port + const hostname = host.replace(/:\d+$/, '') + + // *.localhost -> take prefix (e.g. clock.localhost -> clock) + if (hostname.endsWith('.localhost')) { + const sub = hostname.slice(0, -'.localhost'.length) + return sub || null + } + + // *.X.local -> take first segment if 3+ parts (e.g. clock.toes.local -> clock) + if (hostname.endsWith('.local')) { + const parts = hostname.split('.') + if (parts.length >= 3) { + return parts[0]! + } + } + + return null +} + +export async function proxySubdomain(subdomain: string, req: Request): Promise { + const app = getApp(subdomain) + + if (!app || app.state !== 'running' || !app.port) { + return new Response(`App "${subdomain}" not found or not running`, { status: 502 }) + } + + const url = new URL(req.url) + const target = `http://localhost:${app.port}${url.pathname}${url.search}` + + try { + return await fetch(target, { + method: req.method, + headers: req.headers, + body: req.method !== 'GET' && req.method !== 'HEAD' ? req.body : undefined, + }) + } catch (e) { + console.error(`Proxy error for ${subdomain}:`, e) + return new Response(`App "${subdomain}" is not responding`, { status: 502 }) + } +} + +export function proxyWebSocket(subdomain: string, req: Request, server: any): Response | undefined { + const app = getApp(subdomain) + + if (!app || app.state !== 'running' || !app.port) { + return new Response(`App "${subdomain}" not found or not running`, { status: 502 }) + } + + const url = new URL(req.url) + const path = url.pathname + url.search + + const ok = server.upgrade(req, { data: { port: app.port, path } as WsData }) + if (ok) return undefined + return new Response('WebSocket upgrade failed', { status: 500 }) +} + +export const websocket = { + open(ws: ServerWebSocket) { + const { port, path } = ws.data + const upstream = new WebSocket(`ws://localhost:${port}${path}`) + + upstream.binaryType = 'arraybuffer' + upstreams.set(ws, upstream) + + upstream.addEventListener('message', e => { + ws.send(e.data as string | ArrayBuffer) + }) + + upstream.addEventListener('close', () => { + upstreams.delete(ws) + ws.close() + }) + + upstream.addEventListener('error', () => { + upstreams.delete(ws) + ws.close() + }) + }, + + message(ws: ServerWebSocket, msg: string | ArrayBuffer | Uint8Array) { + const upstream = upstreams.get(ws) + if (!upstream || upstream.readyState !== WebSocket.OPEN) return + upstream.send(msg) + }, + + close(ws: ServerWebSocket) { + const upstream = upstreams.get(ws) + if (upstream) { + upstream.close() + upstreams.delete(ws) + } + }, +} diff --git a/src/server/tunnels.ts b/src/server/tunnels.ts index e5fff6a..4c1cebc 100644 --- a/src/server/tunnels.ts +++ b/src/server/tunnels.ts @@ -85,7 +85,7 @@ export function closeTunnel(appName: string) { } } -export function disableTunnel(appName: string) { +export function unshareApp(appName: string) { closeTunnel(appName) const config = loadConfig() @@ -99,7 +99,7 @@ export function disableTunnel(appName: string) { } } -export function enableTunnel(appName: string, port: number) { +export function shareApp(appName: string, port: number) { if (!SNEAKER_URL) return const app = getApp(appName) @@ -200,7 +200,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) { _tunnels.delete(appName) _tunnelPorts.delete(appName) - // Intentional close (disableTunnel, closeAllTunnels, etc.) — don't reconnect + // Intentional close (unshareApp, closeAllTunnels, etc.) — don't reconnect if (_closing.delete(appName)) { hostLog(`Tunnel closed: ${appName}`) return diff --git a/src/shared/urls.ts b/src/shared/urls.ts new file mode 100644 index 0000000..11de449 --- /dev/null +++ b/src/shared/urls.ts @@ -0,0 +1,5 @@ +export function buildAppUrl(appName: string, baseUrl: string): string { + const url = new URL(baseUrl) + url.hostname = `${appName}.${url.hostname}` + return url.origin +}