nose-pluto/src/webapp/server.ts

129 lines
3.5 KiB
TypeScript

////
// Hosting for your NOSE webapps!
import type { Child } from "hono/jsx"
import { type Context, Hono } from "hono"
import { join } from "path"
import { watch } from "fs"
import { sendAll } from "../websocket"
import { expectDir } from "../utils"
import { NOSE_DIR, BUN_BIN } from "../config"
import { isFile } from "../utils"
import { apps, isApp, appDir, isStaticApp, webappLog } from "./utils"
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
export type App = Hono | Handler
const processes = new Map<string, { port: string, proc: ReturnType<typeof Bun.spawn> }>()
const restarting = new Set<string>()
export async function initWebapps() {
await startSubprocs()
startWatcher()
}
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
if (!isApp(subdomain)) return c.text(`App not found: ${subdomain}`, 404)
const staticPath = join(appDir(subdomain)!, "pub", c.req.path === "/" ? "/index.html" : c.req.path)
if (isFile(staticPath)) return serveStatic(staticPath)
if (isStaticApp(subdomain)) return c.text("File not found", 404)
const port = await startApp(subdomain)
if (!port) return c.text(`App not found: ${subdomain}`, 404)
try {
const res = await fetch(`http://localhost:${port}${c.req.path}`, {
method: c.req.method,
headers: c.req.raw.headers,
body: c.req.raw.body,
})
return new Response(res.body, { status: res.status, headers: res.headers })
} catch {
return c.text("File not found", 404)
}
}
async function startApp(name: string): Promise<string | undefined> {
if (isStaticApp(name)) return
const existing = processes.get(name)
if (existing) return existing.port
const port = String(4000 + processes.size)
const proc = Bun.spawn({
cmd: [BUN_BIN, "run", "src/webapp/worker.ts", name],
env: { PORT: port },
stdout: "inherit",
stderr: "inherit",
})
processes.set(name, { port, proc })
proc.exited.then(() => restartApp(name))
await Bun.sleep(100) // give it time before first request
return port
}
function serveStatic(path: string): Response {
const file = Bun.file(path)
return new Response(file, {
headers: {
"Content-Type": file.type
}
})
}
async function shutdown() {
for (const [name, { port, proc }] of processes) {
webappLog(name, "Shutting down")
try { proc.kill() } catch { }
}
process.exit(0)
}
async function restartApp(name: string) {
if (restarting.has(name)) return
restarting.add(name)
webappLog(name, "restarting")
const existing = processes.get(name)
if (existing) {
try { existing.proc.kill() } catch { }
processes.delete(name)
}
await Bun.sleep(200) // give bun time to release the port
restarting.delete(name)
await startApp(name)
}
async function startSubprocs() {
const list = apps()
await Promise.all(list.map(app => startApp(app)))
}
let wwwWatcher
function startWatcher() {
if (!expectDir(NOSE_DIR)) return
wwwWatcher = watch(NOSE_DIR, { recursive: true }, async (event, filename) => {
if (!filename) return
const [appName,] = filename.split("/")
if (appName && !filename.includes("/pub/"))
restartApp(appName)
if (/^.+\/index\.tsx?$/.test(filename))
sendAll({ type: "apps", data: apps() })
})
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)