253 lines
7.1 KiB
TypeScript
253 lines
7.1 KiB
TypeScript
////
|
|
// Web server that serves shell commands, websocket connections, etc
|
|
|
|
import { Hono } from "hono"
|
|
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
|
|
import { prettyJSON } from "hono/pretty-json"
|
|
import color from "kleur"
|
|
|
|
import type { Message } from "./shared/types"
|
|
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN, DEFAULT_PROJECT } from "./config"
|
|
import { transpile, isFile, tilde, isDir } from "./utils"
|
|
import { serveApp } from "./webapp"
|
|
import { commands, commandPath, loadCommandModule } from "./commands"
|
|
import { runCommandFn } from "./shell"
|
|
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
|
import { initSneakers, disconnectSneakers } from "./sneaker"
|
|
import { dispatchMessage } from "./dispatch"
|
|
import { fatal } from "./fatal"
|
|
import { getState } from "./state"
|
|
import { sessionRun } from "./session"
|
|
|
|
import { Layout } from "./html/layout"
|
|
import { Terminal } from "./html/terminal"
|
|
import { Error } from "./html/error"
|
|
|
|
import { initDNS } from "./dns"
|
|
import { initNoseDir } from "./nosedir"
|
|
import { initCommands } from "./commands"
|
|
|
|
//
|
|
// Hono setup
|
|
//
|
|
|
|
const app = new Hono()
|
|
|
|
app.use("*", prettyJSON())
|
|
|
|
app.use('/*', serveStatic({ root: './public' }))
|
|
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.use("*", async (c, next) => {
|
|
const error = fatal ? fatal : !isDir(NOSE_DIR) ? `NOSE_DIR doesn't exist: ${NOSE_DIR}` : undefined
|
|
|
|
if (!error || (["/cmd/restart", "/cmd/reboot"].includes(c.req.path))) {
|
|
await next()
|
|
} else {
|
|
return c.html(<Layout><Error error={error} /></Layout>, 500)
|
|
}
|
|
})
|
|
|
|
app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
|
|
let path = "./src/" + c.req.path.replace("..", ".")
|
|
|
|
// path must end in .js or .ts
|
|
if (!path.endsWith(".js") && !path.endsWith(".ts")) path += ".ts"
|
|
|
|
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)
|
|
}
|
|
})
|
|
|
|
//
|
|
// app routes
|
|
//
|
|
|
|
// inject browser-nav.js into NOSE apps when loaded in a NOSE iframe
|
|
app.use("*", async (c, next) => {
|
|
await next()
|
|
|
|
const url = new URL(c.req.url)
|
|
const domains = url.hostname.split(".")
|
|
const isSubdomain = domains.length > (url.hostname.endsWith("localhost") ? 1 : 2)
|
|
|
|
if (!isSubdomain) return
|
|
|
|
const contentType = c.res.headers.get('content-type')
|
|
if (!contentType?.includes('text/html')) return
|
|
|
|
const secFetchDest = c.req.header('Sec-Fetch-Dest')
|
|
const isIframe = secFetchDest === 'iframe'
|
|
if (!isIframe) return
|
|
|
|
const originalBody = await c.res.text()
|
|
const shimScript = `<script src="/browser-nav.js"></script>`
|
|
|
|
let modifiedBody = originalBody
|
|
if (originalBody.includes('</head>')) {
|
|
modifiedBody = originalBody.replace('</head>', `${shimScript}\n</head>`)
|
|
} else if (originalBody.includes('<body>')) {
|
|
modifiedBody = originalBody.replace('<body>', `<body>\n${shimScript}`)
|
|
}
|
|
|
|
c.res = new Response(modifiedBody, {
|
|
status: c.res.status,
|
|
headers: c.res.headers
|
|
})
|
|
})
|
|
|
|
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("/source/:name", async c => {
|
|
const name = c.req.param("name")
|
|
const sessionId = c.req.query("session") || "0"
|
|
const path = await sessionRun(sessionId, () => commandPath(name))
|
|
if (!path) return c.text("Command not found", 404)
|
|
return new Response(await transpile(path), {
|
|
headers: {
|
|
"Content-Type": "text/javascript"
|
|
}
|
|
})
|
|
})
|
|
|
|
app.on(["GET", "POST"], ["/cmd/:name"], async c => {
|
|
const sessionId = c.req.header("X-Session") || "0"
|
|
const cmd = c.req.param("name")
|
|
const method = c.req.method
|
|
|
|
try {
|
|
const mod = await sessionRun(sessionId, () => loadCommandModule(cmd))
|
|
if (!mod || !mod[method])
|
|
return c.json({ status: "error", output: `No ${method} export in ${cmd}` }, 500)
|
|
|
|
return c.json(await runCommandFn({ sessionId }, async () => mod[method](c)))
|
|
} catch (e: any) {
|
|
return c.json({ status: "error", output: e.message || e.toString() }, 500)
|
|
}
|
|
})
|
|
|
|
app.get("/", c => c.html(<Layout><Terminal /></Layout>))
|
|
|
|
//
|
|
// websocket
|
|
//
|
|
|
|
app.get("/ws", c => {
|
|
const _sessionId = c.req.query("session")
|
|
|
|
return upgradeWebSocket(c, {
|
|
async onOpen(_e, ws) {
|
|
addWebsocket(ws)
|
|
send(ws, { type: "commands", data: await commands() })
|
|
|
|
send(ws, {
|
|
type: "session:start",
|
|
data: {
|
|
NOSE_DIR: NOSE_DIR,
|
|
project: DEFAULT_PROJECT,
|
|
cwd: "/",
|
|
mode: getState("ui:mode") || "tall"
|
|
}
|
|
})
|
|
},
|
|
async onMessage(event, ws) {
|
|
let data: Message | undefined
|
|
|
|
try {
|
|
data = JSON.parse(event.data.toString())
|
|
} catch (e) {
|
|
console.error("JSON parsing error", e)
|
|
send(ws, { type: "error", data: "json parsing error" })
|
|
return
|
|
}
|
|
|
|
if (!data) return
|
|
|
|
await dispatchMessage(ws, data)
|
|
},
|
|
onClose: (event, ws) => removeWebsocket(ws)
|
|
})
|
|
})
|
|
|
|
//
|
|
// hot reload mode cleanup
|
|
//
|
|
|
|
if (process.env.BUN_HOT) {
|
|
// @ts-ignore
|
|
globalThis.__hot_reload_cleanup?.()
|
|
|
|
// @ts-ignore
|
|
globalThis.__hot_reload_cleanup = () => {
|
|
closeWebsockets()
|
|
disconnectSneakers()
|
|
}
|
|
|
|
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
process.on(sig, () => {
|
|
// @ts-ignore
|
|
globalThis.__hot_reload_cleanup?.()
|
|
process.exit(0)
|
|
})
|
|
}
|
|
}
|
|
|
|
//
|
|
// production mode
|
|
//
|
|
|
|
if (process.env.NODE_ENV === "production") {
|
|
initDNS()
|
|
}
|
|
|
|
//
|
|
// server start
|
|
//
|
|
|
|
console.log(color.cyan(NOSE_ICON))
|
|
console.log(color.blue(" NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
|
|
console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
|
|
console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
|
|
console.log(color.blue("NOSE_ROOT_BIN:"), color.yellow(tilde(NOSE_ROOT_BIN)))
|
|
|
|
await initNoseDir()
|
|
initCommands()
|
|
initSneakers()
|
|
|
|
export default {
|
|
port: process.env.PORT || 3000,
|
|
hostname: "0.0.0.0",
|
|
fetch: app.fetch,
|
|
idleTimeout: 0,
|
|
websocket
|
|
} |