diff --git a/streamer/src/page-server.ts b/streamer/src/page-server.ts new file mode 100644 index 0000000..262c371 --- /dev/null +++ b/streamer/src/page-server.ts @@ -0,0 +1,127 @@ +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { createReadStream, existsSync } from "node:fs"; +import { extname, join } from "node:path"; +import { EventEmitter } from "node:events"; + +export interface PageServerOpts { + port: number; + staticDir: string; + libraryDir: string; + style: "denpa" | "modern"; + station: string; + tz: string; + tuneInUrl: string; +} + +export class PageServer extends EventEmitter { + private nowClients = new Set(); + private specClients = new Set(); + private latestNowJson: string | null = null; + private latestListeners: { current: number; peak: number } = { current: 0, peak: 0 }; + private server = createServer((req, res) => this.handle(req, res)); + + constructor(private opts: PageServerOpts) { super(); } + + start(): Promise { + return new Promise((resolve) => this.server.listen(this.opts.port, "127.0.0.1", () => resolve())); + } + + stop(): Promise { + for (const c of this.nowClients) c.end(); + for (const c of this.specClients) c.end(); + return new Promise((resolve) => this.server.close(() => resolve())); + } + + pushNowPlaying(json: unknown): void { + this.latestNowJson = JSON.stringify(json); + const payload = `data: ${this.latestNowJson}\n\n`; + for (const c of this.nowClients) c.write(payload); + } + + pushSpectrum(bars: number[]): void { + const payload = `data: ${JSON.stringify(bars)}\n\n`; + for (const c of this.specClients) c.write(payload); + } + + pushListeners(l: { current: number; peak: number }): void { + this.latestListeners = l; + } + + private handle(req: IncomingMessage, res: ServerResponse): void { + const url = new URL(req.url ?? "/", "http://localhost"); + + if (url.pathname === "/") return this.serveIndex(res); + if (url.pathname.startsWith("/assets/")) return this.serveStatic(url.pathname, res); + if (url.pathname === "/now-playing") return this.serveSse(res, this.nowClients, this.latestNowJson); + if (url.pathname === "/spectrum") return this.serveSse(res, this.specClients, null); + if (url.pathname === "/listeners") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(this.latestListeners)); + return; + } + if (url.pathname === "/cover") return this.serveCover(url, res); + + res.statusCode = 404; + res.end("not found"); + } + + private serveIndex(res: ServerResponse): void { + const indexPath = join(this.opts.staticDir, "index.html"); + if (!existsSync(indexPath)) { + res.statusCode = 500; + res.end("index not built"); + return; + } + const inject = ``; + let html = ""; + createReadStream(indexPath, "utf8") + .on("data", (chunk) => (html += chunk)) + .on("end", () => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(html.replace("", `${inject}`)); + }); + } + + private serveStatic(pathname: string, res: ServerResponse): void { + const file = join(this.opts.staticDir, pathname.replace(/^\/assets\//, "")); + if (!file.startsWith(this.opts.staticDir) || !existsSync(file)) { + res.statusCode = 404; res.end("not found"); return; + } + const ct = ({ + ".js": "application/javascript", + ".css": "text/css", + ".woff2": "font/woff2", + } as const)[extname(file) as ".js" | ".css" | ".woff2"] ?? "application/octet-stream"; + res.setHeader("content-type", ct); + res.setHeader("cache-control", "max-age=86400"); + createReadStream(file).pipe(res); + } + + private serveSse(res: ServerResponse, set: Set, initial: string | null): void { + res.setHeader("content-type", "text/event-stream"); + res.setHeader("cache-control", "no-cache"); + res.setHeader("connection", "keep-alive"); + res.flushHeaders(); + set.add(res); + if (initial) res.write(`data: ${initial}\n\n`); + res.on("close", () => set.delete(res)); + } + + private serveCover(url: URL, res: ServerResponse): void { + const requested = url.searchParams.get("path") ?? ""; + if (!requested.startsWith("/library/")) { res.statusCode = 400; res.end(); return; } + const file = join(this.opts.libraryDir, requested.slice("/library/".length)); + if (!file.startsWith(this.opts.libraryDir) || !existsSync(file)) { + res.statusCode = 404; res.end(); return; + } + const ext = extname(file).toLowerCase(); + res.setHeader("content-type", ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg"); + res.setHeader("cache-control", "no-store"); + createReadStream(file).pipe(res); + } +}