import type { LogSchema } from "@typed-assistant/logger" import { logger } from "@typed-assistant/logger" import { levels } from "@typed-assistant/logger/levels" import { ONE_MINUTE, ONE_SECOND } from "@typed-assistant/utils/durations" import { getSupervisorAPI } from "@typed-assistant/utils/getHassAPI" import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling" import Convert from "ansi-to-html" import type { Subprocess } from "bun" import { $ } from "bun" import { Elysia, t } from "elysia" import { watch } from "fs" import { basename, join } from "path" import type { List, String } from "ts-toolbelt" import { getAddonInfo } from "./getAddonInfo" import { addKillListener, killSubprocess } from "./killProcess" import { restartAddon } from "./restartAddon" const indexHtmlFilePath = `${import.meta.dir}/webserver/index.html` as const const cssFile = `${import.meta.dir}/webserver/input.css` as const const tsEntryPoint = `${import.meta.dir}/webserver/index.tsx` as const const tailwindConfig = `${import.meta.dir}/webserver/tailwind.config.js` as const const cssOutputFile = join( process.cwd(), `./build/output.css`, ) as `${string}/output.css` const convert = new Convert({ escapeXML: true }) const subscribers = new Map void>() const logSubscribers = new Map void>() let lastMessage = "" let stats = { cpu_percent: null as number | null, memory_usage: null as number | null, memory_limit: null as number | null, memory_percent: null as number | null, max_memory_usage: 0, } const getStats = async () => { const { data, error } = await withErrorHandling( getSupervisorAPI<{ data: { error?: never cpu_percent: number memory_usage: number memory_limit: number memory_percent: number max_memory_usage: number } }>, )("/addons/self/stats") if (error) { logger.error( { additionalDetails: error.message, emoji: "❌" }, "Error getting stats", ) } else { stats = { ...data.data, max_memory_usage: data.data.memory_usage > stats.max_memory_usage ? data.data.memory_usage : stats.max_memory_usage, } } setTimeout(getStats, 10 * ONE_SECOND) } export const startWebappServer = async ({ basePath, getSubprocesses, onRestartAppRequest, }: { basePath: string getSubprocesses: () => { app: Subprocess<"ignore", "pipe", "pipe"> } onRestartAppRequest: () => void }) => { const buildResult = await Bun.build({ entrypoints: [tsEntryPoint], outdir: "./build", define: { "process.env.BASE_PATH": `"${basePath}"`, }, }) if (!buildResult.success) { for (const message of buildResult.logs) { // Bun will pretty print the message object console.error(message) } throw new Error("Build failed") } logger.debug({ emoji: "🛠️" }, "Web server built successfully") await $`bunx tailwindcss -c ${tailwindConfig} -i ${cssFile} -o ${cssOutputFile}`.quiet() logger.debug({ emoji: "💄" }, "Tailwind built successfully") const indexHtml = (await Bun.file(indexHtmlFilePath).text()) .replace( "{{ STYLESHEET }}", `${basePath}/assets/${getBaseName(cssOutputFile)}`, ) .replace( "{{ SCRIPTS }}", buildResult.outputs .map( (output) => ``, ) .join("\n"), ) const server = new Elysia() .get( "/", () => new Response(indexHtml, { headers: { "content-type": "text/html" }, }), ) .get("/restart-app", async () => { onRestartAppRequest() return { message: "Restarting app..." } }) .get("/restart-addon", async () => { await killSubprocess(getSubprocesses().app) restartAddon().catch((error) => { logger.error( { additionalDetails: error instanceof Error ? error.message : `${error}`, emoji: "🚨", }, "Failed to restart addon", ) }) return { message: "Restarting addon..." } }) .get("/force-sync-with-github", async () => { if (!process.env.GITHUB_BRANCH) { logger.error({ emoji: "🔄🚨" }, "GITHUB_BRANCH is not set") return { message: "GITHUB_BRANCH is not set" } } const { exitCode, stderr } = await $`git reset --hard origin/${process.env.GITHUB_BRANCH}` .nothrow() .quiet() if (exitCode) { logger.error( { additionalDetails: stderr.toString().trim(), emoji: "🔄🚨", }, "Failed to reset to origin", ) return { message: "Failed to reset to origin", } } logger.info({ emoji: "🔄" }, "Reset to origin") return { message: "Reset to origin" } }) .get("/addon-info", async () => { const { data, error } = await getAddonInfo() if (error) return error return { ...data.data, options: "HIDDEN", schema: "HIDDEN", translations: "HIDDEN", } }) .get("/stats", async () => stats) .get( "/log.txt", async ({ query }) => { return getLogsFromFile({ level: "trace", limit: query.limit, filter: query.filter, }) }, { query: t.Object({ limit: t.Optional(t.String()), filter: t.Optional(t.String()), }), }, ) .ws("/logsws", { query: t.Object({ limit: t.Optional(t.String()), level: t.Union([ t.Literal("trace"), t.Literal("debug"), t.Literal("info"), t.Literal("warn"), t.Literal("error"), t.Literal("fatal"), ]), offset: t.Optional(t.String()), filter: t.Optional(t.String()), }), async open(ws) { ws.send( await getLogsFromFile({ filter: ws.data.query.filter, level: ws.data.query.level, limit: ws.data.query.limit, offset: ws.data.query.offset, }), ) logSubscribers.set(ws.id, async () => { ws.send( await getLogsFromFile({ filter: ws.data.query.filter, level: ws.data.query.level, limit: ws.data.query.limit, offset: ws.data.query.offset, }), ) }) }, close(ws) { logSubscribers.delete(ws.id) }, }) .ws("/ws", { response: t.String(), async open(ws) { ws.send(lastMessage || "Connected successfully. Awaiting messages...") subscribers.set(ws.id, (message) => { ws.send(message) }) }, close(ws) { subscribers.delete(ws.id) }, }) .get( `/assets/${getBaseName(cssOutputFile)}`, () => new Response(Bun.file(cssOutputFile), { headers: { "content-type": "text/css" }, }), ) buildResult.outputs.forEach((output) => { server.get( `/assets/${getBaseName(output.path)}`, () => new Response(Bun.file(output.path), { headers: { "content-type": "text/javascript" }, }), ) }) server.listen(8099) logger.info({ emoji: "🌐" }, "Web server listening on port 8099") const directory = join(process.cwd(), ".") logger.debug({ emoji: "👀" }, "Watching log.txt") const watcher = watch(directory, function onFileChange(_event, filename) { if (filename === "log.txt") { logSubscribers.forEach((send) => send()) } }) const watchLogFileSize = async () => { const logFileSize = Bun.file("./log.txt").size const limit = 3 * ONE_MEGABYTE logger.debug( { emoji: "🗒️" }, `log.txt size: ${logFileSize}. Limit is ${limit}.`, ) if (logFileSize > limit) { logger.debug( { emoji: "🗑️" }, "log.txt is too big, deleting old log.txt and renaming new log.txt to old log.txt", ) await $`rm -f ./log.txt.old`.quiet().catch((e) => { logger.error( { emoji: "🚨", additionalDetails: e.message }, "Failed to delete old log.txt", ) }) await $`cp ./log.txt ./log.txt.old`.catch((e) => { logger.error( { emoji: "🚨", additionalDetails: e.message }, "Failed copying log.txt to log.txt.old", ) }) await $`cat /dev/null > ./log.txt`.catch((e) => { logger.error( { emoji: "🚨", additionalDetails: e.message }, "Failed to empty log.txt", ) }) } setTimeout(watchLogFileSize, 10 * ONE_MINUTE) } watchLogFileSize() getStats() addKillListener(async () => { watcher.close() await server.stop() }) streamAppOutputToSubscribers(getSubprocesses) return server } const streamAppOutputToSubscribers = async ( getSubprocesses: () => { app: Subprocess<"ignore", "pipe", "pipe"> }, ) => { const pumpStream = async (stream: ReadableStream) => { const reader = stream.getReader() const decoder = new TextDecoder() try { // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read() // A final decode() flushes any buffered partial multi-byte character const text = done ? decoder.decode() : decoder.decode(value, { stream: true }) const convertedMessage = convert.toHtml(text) if (convertedMessage !== "") { lastMessage = convertedMessage subscribers.forEach((send) => send(convertedMessage)) } if (done) break } } finally { reader.releaseLock() } } let currentApp: Subprocess<"ignore", "pipe", "pipe"> | null = null // eslint-disable-next-line no-constant-condition while (true) { const app = getSubprocesses().app if (app === currentApp) { // This app's streams have ended; wait for a replacement to be spawned await new Promise((resolve) => setTimeout(resolve, ONE_SECOND)) continue } currentApp = app await Promise.all([pumpStream(app.stdout), pumpStream(app.stderr)]).catch( (error) => { logger.error( { additionalDetails: error instanceof Error ? error.message : `${error}`, emoji: "🚨", }, "Error reading app process output", ) }, ) logger.warn({ emoji: "💀" }, "App process output streams ended") subscribers.forEach((send) => send( "App process exited. Waiting for it to restart...\n\nThis was the last message:\n\n" + lastMessage, ), ) } } export type WebServer = Awaited> const getLogsFromFile = async ({ level, limit: limitProp, offset: offsetProp = "0", filter, }: { level: keyof typeof levels limit?: string offset?: string filter?: string }) => { try { const parsedLimit = Number(limitProp) const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined const parsedOffset = Number(offsetProp) const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0 const normalizedFilter = filter?.toLowerCase().trim() const parsedLogs = (await Bun.file("./log.txt").text()) .split("\n") .reduce((result, line) => { if (!line) return result try { const log = JSON.parse(line) as LogSchema return result.concat(log) } catch (e) { return result.concat({ msg: e instanceof Error ? e.message : "Unknown parse error", level: levels.fatal, } as LogSchema) } }, [] as LogSchema[]) const filteredLogs = parsedLogs.filter((log) => { if (log.level < levels[level]) return false if (normalizedFilter) { const haystack = JSON.stringify(log).toLowerCase() if (!haystack.includes(normalizedFilter)) return false } return true }) const paginatedLogs = limit ? filteredLogs.slice( Math.max(filteredLogs.length - limit * (offset + 1), 0), filteredLogs.length - limit * offset || filteredLogs.length, ) : filteredLogs return { logs: paginatedLogs } } catch (e) { return { logs: [ { msg: "Error reading log.txt file", level: levels.fatal, }, { msg: e instanceof Error ? e.message : e, level: levels.fatal, }, ] as LogSchema[], } } } const ONE_MEGABYTE = 1024 * 1024 const getBaseName = (path: TString) => { return basename(path) as List.Last> }