nose-pluto/src/server.tsx
2025-09-20 19:16:45 -07:00

159 lines
4.0 KiB
TypeScript

import { Hono } from "hono"
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
import { prettyJSON } from "hono/pretty-json"
import color from "kleur"
import { NOSE_ICON, NOSE_DIR, NOSE_BIN, NOSE_WWW } from "./config"
import { transpile, isFile, tilde } from "./utils"
import { apps, serveApp, publishDNS } from "./webapp"
import { runCommand } from "./shell"
import type { Message } from "./shared/types"
import { Layout } from "./components/layout"
import { Terminal } from "./components/terminal"
//
// Hono setup
//
const app = new Hono()
app.use("*", prettyJSON())
app.use('/img/*', serveStatic({ root: './public' }))
app.use('/vendor/*', serveStatic({ root: './public' }))
app.use('/css/*', serveStatic({ root: './src' }))
app.use("*", async (c, next) => {
const start = Date.now()
await next()
const end = Date.now()
const fn = c.res.status < 300 ? color.green : c.res.status < 500 ? color.yellow : color.red
console.log(fn(`${c.res.status} ${c.req.method} ${c.req.url} (${end - start}ms)`))
})
app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
const path = "./src/" + c.req.path.replace("..", ".")
// path must end in .js
if (!path.endsWith(".js")) return c.text("File not found", 404)
const ts = path.replace(".js", ".ts")
if (isFile(ts)) {
return new Response(await transpile(ts), { headers: { "Content-Type": "text/javascript" } })
} else if (isFile(path)) {
return new Response(Bun.file(path), { headers: { "Content-Type": "text/javascript" } })
} else {
return c.text("File not found", 404)
}
})
//
// websocket
//
const wsConnections: any[] = []
app.get("/ws", upgradeWebSocket(async c => {
return {
onOpen(_e, ws) {
wsConnections.push(ws)
},
async onMessage(event, ws) {
let data: Message | undefined
try {
data = JSON.parse(event.data.toString())
} catch (e) {
console.error("JSON parsing error", e)
ws.send(JSON.stringify({ type: "error", data: "json parsing error" }))
return
}
if (!data) return
const result = await runCommand(data.data as string)
ws.send(JSON.stringify({ id: data.id, type: "output", data: result }))
},
onClose: () => console.log('Connection closed'),
}
}))
//
// app routes
//
app.use("*", async (c, next) => {
const url = new URL(c.req.url)
const localhost = url.hostname.endsWith("localhost")
const domains = url.hostname.split(".")
let subdomain = ""
if (domains.length > (localhost ? 1 : 2))
subdomain = domains[0]!
if (subdomain) {
const app = serveApp(c, subdomain)
return await app
}
return next()
})
app.get("/apps", c => {
const url = new URL(c.req.url)
const domain = url.hostname
let port = url.port
port = port && port !== "80" ? `:${port}` : ""
return c.html(<>
<h1>apps</h1>
<ul>{apps().map(app => <li><a href={`http://${app}.${domain}${port}`}>{app}</a></li>)}
</ul>
</>)
})
app.get("/", c => c.html(<Layout><Terminal /></Layout>))
//
// hot mode cleanup
//
if (process.env.BUN_HOT) {
// @ts-ignore
globalThis.__hot_reload_cleanup?.()
// @ts-ignore
globalThis.__hot_reload_cleanup = () => {
wsConnections.forEach(conn => conn?.close())
}
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, () => {
// @ts-ignore
globalThis.__hot_reload_cleanup?.()
process.exit()
})
}
} else {
publishDNS()
}
//
// server start
//
console.log(color.cyan(NOSE_ICON))
console.log(color.blue("NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
console.log(color.blue("NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
console.log(color.blue("NOSE_WWW:"), color.yellow(tilde(NOSE_WWW)))
export default {
port: process.env.PORT || 3000,
hostname: "0.0.0.0",
fetch: app.fetch,
idleTimeout: 0,
websocket
}