feat(streamer): page http server with sse + cover proxy

This commit is contained in:
devilreef 2026-04-30 15:21:42 +06:00
parent 40ac86717a
commit 6bcf18c1bf
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y

127
streamer/src/page-server.ts Normal file
View file

@ -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<ServerResponse>();
private specClients = new Set<ServerResponse>();
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<void> {
return new Promise((resolve) => this.server.listen(this.opts.port, "127.0.0.1", () => resolve()));
}
stop(): Promise<void> {
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 = `<script>window.__STREAMER_CONFIG__=${JSON.stringify({
style: this.opts.style,
station: this.opts.station,
tz: this.opts.tz,
tuneInUrl: this.opts.tuneInUrl,
})}</script>`;
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("</head>", `${inject}</head>`));
});
}
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<ServerResponse>, 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);
}
}