forked from defunkt/toes
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
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'
|
|
import type { Server } from 'bun'
|
|
import type { WsData } from './proxy'
|
|
|
|
const app = new Hype({ layout: false, logging: !!process.env.DEBUG })
|
|
|
|
app.route('/api/apps', appsRouter)
|
|
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 subdomain
|
|
app.get('/tool/:tool', 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.text(`Tool "${toolName}" not found or not running`, 404)
|
|
}
|
|
const params = new URLSearchParams(c.req.query()).toString()
|
|
const base = buildAppUrl(toolName, TOES_URL)
|
|
const url = params ? `${base}?${params}` : base
|
|
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,
|
|
})
|
|
})
|
|
|
|
const CLI_ENTRY = import.meta.dir + '/../cli/index.ts'
|
|
const DIST_DIR = import.meta.dir + '/../../dist'
|
|
const INSTALL_SCRIPT = await Bun.file(import.meta.dir + '/install.sh').text()
|
|
|
|
interface BuildTarget {
|
|
arch: string
|
|
name: string
|
|
os: string
|
|
}
|
|
|
|
const BUILD_TARGETS: BuildTarget[] = [
|
|
{ os: 'darwin', arch: 'arm64', name: 'toes-macos-arm64' },
|
|
{ os: 'darwin', arch: 'x64', name: 'toes-macos-x64' },
|
|
{ os: 'linux', arch: 'arm64', name: 'toes-linux-arm64' },
|
|
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
|
|
]
|
|
|
|
const buildInFlight = new Map<string, Promise<boolean>>()
|
|
|
|
async function buildBinary(target: BuildTarget): Promise<boolean> {
|
|
const existing = buildInFlight.get(target.name)
|
|
if (existing) return existing
|
|
|
|
const promise = (async () => {
|
|
const { existsSync, mkdirSync } = await import('fs')
|
|
if (!existsSync(DIST_DIR)) mkdirSync(DIST_DIR, { recursive: true })
|
|
|
|
const proc = Bun.spawn([
|
|
'bun', 'build', CLI_ENTRY, '--compile',
|
|
'--target', `bun-${target.os}-${target.arch}`,
|
|
'--minify', '--sourcemap=external',
|
|
'--outfile', `${DIST_DIR}/${target.name}`,
|
|
], { stdout: 'inherit', stderr: 'inherit' })
|
|
|
|
const exitCode = await proc.exited
|
|
return exitCode === 0
|
|
})()
|
|
|
|
buildInFlight.set(target.name, promise)
|
|
promise.finally(() => buildInFlight.delete(target.name))
|
|
return promise
|
|
}
|
|
|
|
// Install script: curl -fsSL http://toes.local/install | bash
|
|
app.get('/install', c => {
|
|
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
|
|
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
|
|
return c.text(script, 200, { 'content-type': 'text/plain' })
|
|
})
|
|
|
|
// Serve built CLI binaries from dist/, building on-demand if needed
|
|
app.get('/dist/:file', async c => {
|
|
const file = c.req.param('file')
|
|
if (!file || file.includes('/') || file.includes('..')) {
|
|
return c.text('Not found', 404)
|
|
}
|
|
const bunFile = Bun.file(`${DIST_DIR}/${file}`)
|
|
if (!(await bunFile.exists())) {
|
|
const target = BUILD_TARGETS.find(t => t.name === file)
|
|
if (!target) return c.text('Not found', 404)
|
|
const ok = await buildBinary(target)
|
|
if (!ok) return c.text(`Failed to build "${file}"`, 500)
|
|
}
|
|
return new Response(Bun.file(`${DIST_DIR}/${file}`), {
|
|
headers: { 'content-type': 'application/octet-stream' },
|
|
})
|
|
})
|
|
|
|
cleanupStalePublishers()
|
|
await initApps()
|
|
|
|
const defaults = app.defaults
|
|
|
|
export default {
|
|
...defaults,
|
|
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
|
fetch(req: Request, server: Server<WsData>) {
|
|
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,
|
|
}
|