refactor: replace MJPEG streaming with screenshot + meta-refresh

- Remove multipart/x-mixed-replace streaming (broken on Chromium)
- Add per-view screenshot rendering with base64 HTML embedding
- Implement resource inlining for CSS, images, and favicons
- Add render locks to prevent overlapping Puppeteer operations
- Simplify UpdateViewBody type to extend Partial<CreateViewBody>
- Update browser args for better stability
- Standardize formatting (tabs to spaces)
This commit is contained in:
2026-05-12 23:31:33 +02:00
parent edbe3f9bf2
commit 722a3143dc
3 changed files with 605 additions and 410 deletions
+461 -266
View File
@@ -5,10 +5,12 @@ import puppeteer from "puppeteer";
// ═══════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════
interface URLItem {
url: string;
durationSec: number;
}
interface View {
id: string;
name: string;
@@ -17,33 +19,42 @@ interface View {
metaRefreshEnabled: boolean;
createdAt: number;
}
interface ViewRow {
id: string;
name: string;
urls: string; // JSON text
urls: string;
method: string;
meta_refresh_enabled: number;
created_at: number;
}
interface CreateViewBody {
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
}
interface UpdateViewBody {
name?: string;
urls?: URLItem[];
method?: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
}
interface UpdateViewBody extends Partial<CreateViewBody> {}
// ═══════════════════════════════════════════════════════════════
// DATABASE
// ═══════════════════════════════════════════════════════════════
const db = new Database("views.db", { create: true });
db.run(
`CREATE TABLE IF NOT EXISTS views ( id TEXT PRIMARY KEY, name TEXT NOT NULL, urls TEXT NOT NULL DEFAULT '[]', method TEXT NOT NULL DEFAULT 'ssr', meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) )`,
);
db.run(`
CREATE TABLE IF NOT EXISTS views (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
urls TEXT NOT NULL DEFAULT '[]',
method TEXT NOT NULL DEFAULT 'ssr',
meta_refresh_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
function rowToView(row: ViewRow): View {
return {
id: row.id,
@@ -54,18 +65,21 @@ function rowToView(row: ViewRow): View {
createdAt: row.created_at,
};
}
function loadView(id: string): View | null {
const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as
| ViewRow
| undefined;
const row = db
.query("SELECT * FROM views WHERE id = ?")
.get(id) as ViewRow | undefined;
return row ? rowToView(row) : null;
}
function loadAllViews(): View[] {
const rows = db
.query("SELECT * FROM views ORDER BY created_at DESC")
.all() as ViewRow[];
return rows.map(rowToView);
}
function insertView(v: View): void {
db.run(
"INSERT INTO views (id, name, urls, method, meta_refresh_enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)",
@@ -76,9 +90,10 @@ function insertView(v: View): void {
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.createdAt,
],
]
);
}
function updateView(v: View): void {
db.run(
"UPDATE views SET name=?, urls=?, method=?, meta_refresh_enabled=? WHERE id=?",
@@ -88,20 +103,25 @@ function updateView(v: View): void {
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.id,
],
]
);
}
function deleteViewById(id: string): boolean {
const result = db.run("DELETE FROM views WHERE id = ?", [id]);
return result.changes > 0;
}
// ═══════════════════════════════════════════════════════════════
// BROWSER MANAGER
// BROWSER MANAGER (single browser, pages created per-request)
// ═══════════════════════════════════════════════════════════════
let browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (browser && browser.isConnected()) return browser;
console.log("[browser] Launching headless Chromium…");
if (browser?.isConnected()) return browser;
console.log("[browser] Launching headless Chrome…");
browser = await puppeteer.launch({
executablePath: "/usr/bin/chromium",
headless: true,
@@ -111,131 +131,26 @@ async function getBrowser(): Promise<Browser> {
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-extensions",
"--mute-audio",
],
});
// Handle browser crash/disconnect
browser.on("disconnected", () => {
console.log(
"[browser] Browser disconnected — will restart on next request",
);
console.log("[browser] Disconnected — will restart on next request");
browser = null;
});
console.log("[browser] Ready");
return browser;
}
async function createPage(): Promise<Page> {
const b = await getBrowser();
const page = await b.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// Block unnecessary resource types for faster loads on TV-targeted renders
await page.setRequestInterception(true);
page.on("request", (req) => {
const type = req.resourceType();
if (type === "media" || type === "websocket" || type === "manifest") {
req.abort();
} else {
req.continue();
}
});
return page;
}
// ═══════════════════════════════════════════════════════════════
// ROTATION LOGIC
// ═══════════════════════════════════════════════════════════════
/**
* Returns which URL index is "current" based on wall-clock time
* and the cumulative durations of all URLs in the view.
*/
function getCurrentURLIndex(view: View): number {
if (view.urls.length <= 1) return 0;
const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0);
if (totalCycle <= 0) return 0;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let accumulated = 0;
for (let i = 0; i < view.urls.length; i++) {
accumulated += view.urls[i].durationSec;
if (elapsed < accumulated) return i;
}
return view.urls.length - 1;
}
/**
* Returns how many seconds remain before the _next_ URL switch.
* Used for meta-refresh interval so the TV reloads exactly when
* the rotation advances.
*/
function getRemainingSec(view: View): number {
if (view.urls.length <= 1) return view.urls[0]?.durationSec || 30;
const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0);
if (totalCycle <= 0) return 30;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let accumulated = 0;
for (let i = 0; i < view.urls.length; i++) {
accumulated += view.urls[i].durationSec;
if (elapsed < accumulated) {
return accumulated - elapsed;
}
}
// Shouldn't reach here — wrap around
return view.urls[0].durationSec;
}
// ═══════════════════════════════════════════════════════════════
// SSR RENDERING
// ═══════════════════════════════════════════════════════════════
function injectMetaRefresh(html: string, intervalSec: number): string {
// Use integer seconds for meta refresh
const sec = Math.max(1, Math.ceil(intervalSec));
const tag = `<meta http-equiv="refresh" content="${sec}">`;
if (html.includes("</head>")) {
return html.replace("</head>", `${tag}\n</head>`);
}
if (/<html[^>]*>/i.test(html)) {
return html.replace(/(<html[^>]*>)/i, `$1\n<head>${tag}</head>`);
}
return `<html><head>${tag}</head><body>${html}</body></html>`;
}
function stripScriptTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
}
async function renderSSR(url: string): Promise<string> {
const page = await createPage();
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 });
const html = await page.content();
return stripScriptTags(html);
} finally {
await page.close().catch(() => {});
}
}
function errorPage(message: string): string {
return `
${message}
Samsung TV Proxy
`;
}
// ═══════════════════════════════════════════════════════════════
// MJPEG STREAMING
// ═══════════════════════════════════════════════════════════════
interface ActiveStream {
aborted: boolean;
page: Page | null;
}
const activeStreams = new Map<string, ActiveStream>();
async function serveMJPEG(view: View): Promise<Response> {
const b = await getBrowser();
const page = await b.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// Block heavy resources during MJPEG capture
// Block heavy/font/media resources for speed
await page.setRequestInterception(true);
page.on("request", (req) => {
const type = req.resourceType();
@@ -251,162 +166,443 @@ async function serveMJPEG(view: View): Promise<Response> {
}
});
// Per-connection key so multiple viewers of the same view don't collide
const connKey = `${view.id}:${crypto.randomUUID()}`;
const boundary = "MJPEG_FRAME";
let currentIdx = -1;
const streamRecord: ActiveStream = { aborted: false, page };
activeStreams.set(connKey, streamRecord);
return page;
}
// Navigate to the first URL before returning the response so the first
// frame is ready immediately and Chrome has something to display.
const firstIdx = getCurrentURLIndex(view);
// ═══════════════════════════════════════════════════════════════
// ROTATION LOGIC (wall-clock based, stateless)
// ═══════════════════════════════════════════════════════════════
function getCurrentURLIndex(view: View): number {
if (view.urls.length <= 1) return 0;
const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0);
if (totalCycle <= 0) return 0;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let accumulated = 0;
for (let i = 0; i < view.urls.length; i++) {
accumulated += view.urls[i].durationSec;
if (elapsed < accumulated) return i;
}
return view.urls.length - 1;
}
function getRemainingSec(view: View): number {
if (view.urls.length <= 1) {
return view.urls[0]?.durationSec || 30;
}
const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0);
if (totalCycle <= 0) return 30;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let accumulated = 0;
for (let i = 0; i < view.urls.length; i++) {
accumulated += view.urls[i].durationSec;
if (elapsed < accumulated) {
return accumulated - elapsed;
}
}
return view.urls[0].durationSec;
}
// ═══════════════════════════════════════════════════════════════
// SSR RENDERING
// ═══════════════════════════════════════════════════════════════
function injectMetaRefresh(html: string, intervalSec: number): string {
const sec = Math.max(1, Math.ceil(intervalSec));
const tag = `<meta http-equiv="refresh" content="${sec}">`;
if (html.includes("</head>")) {
return html.replace("</head>", ` ${tag}\n</head>`);
}
if (/<html[^>]*>/i.test(html)) {
return html.replace(/(<html[^>]*>)/i, `$1\n<head>${tag}</head>`);
}
return `<html><head>${tag}</head><body>${html}</body></html>`;
}
function stripScriptTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
}
// ── Inline external CSS <link> → <style> ──
async function inlineCSS(page: Page, html: string): Promise<string> {
// Collect all <link rel="stylesheet"> hrefs
const linkRegex = /<link\b[^>]*\brel\s*=\s*"stylesheet"[^>]*\bhref\s*=\s*"([^"]*)"[^>]*>/gi;
const links: { full: string; href: string }[] = [];
let m;
while ((m = linkRegex.exec(html)) !== null) {
links.push({ full: m[0], href: m[1] });
}
for (const link of links) {
try {
console.log(`[mjpeg:${connKey}] Initial navigation to URL ${firstIdx}: ${view.urls[firstIdx].url}`);
await page.goto(view.urls[firstIdx].url, {
waitUntil: "networkidle2",
timeout: 20000,
});
currentIdx = firstIdx;
} catch (err: any) {
console.error(`[mjpeg:${connKey}] Initial navigation failed: ${err.message}`);
// Resolve relative URLs
const resolved = new URL(link.href, page.url()).href;
const cssText = await page.evaluate(async (url) => {
const res = await fetch(url);
if (!res.ok) return null;
return res.text();
}, resolved);
if (cssText) {
html = html.replace(
link.full,
`<style>/* inlined: ${link.href} */\n${cssText}\n</style>`
);
}
} catch {
// Leave as-is if fetch fails
}
}
const enc = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const FRAME_MS = 200; // ~5 fps
return html;
}
const enqueueFrame = async () => {
if (streamRecord.aborted) return;
// ── Inline images → base64 data URIs ──
const idx = getCurrentURLIndex(view);
if (idx !== currentIdx) {
async function inlineImages(page: Page, html: string): Promise<string> {
const imgRegex = /<img\b[^>]*\bsrc\s*=\s*"([^"]*)"([^>]*)>/gi;
const imgs: { full: string; src: string; rest: string }[] = [];
let m;
while ((m = imgRegex.exec(html)) !== null) {
imgs.push({ full: m[0], src: m[1], rest: m[2] });
}
for (const img of imgs) {
// Skip already-inlined
if (img.src.startsWith("data:")) continue;
try {
console.log(`[mjpeg:${connKey}] Switching to URL ${idx}: ${view.urls[idx].url}`);
await page.goto(view.urls[idx].url, {
waitUntil: "networkidle2",
timeout: 20000,
});
currentIdx = idx;
} catch (err: any) {
console.error(`[mjpeg:${connKey}] Navigation failed: ${err.message}`);
// Stay on current page, will retry next cycle
}
}
try {
const screenshot = await page.screenshot({
type: "jpeg",
quality: 70,
});
const part =
`--${boundary}\r\n` +
`Content-Type: image/jpeg\r\n` +
`Content-Length: ${screenshot.length}\r\n` +
`\r\n`;
controller.enqueue(enc.encode(part));
controller.enqueue(new Uint8Array(screenshot));
controller.enqueue(enc.encode("\r\n"));
} catch (err: any) {
if (!streamRecord.aborted) {
console.error(`[mjpeg:${connKey}] Screenshot failed: ${err.message}`);
}
}
const resolved = new URL(img.src, page.url()).href;
const base64 = await page.evaluate(async (url) => {
const res = await fetch(url);
if (!res.ok) return null;
const blob = await res.blob();
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result);
};
reader.readAsDataURL(blob);
});
}, resolved);
while (!streamRecord.aborted) {
const start = Date.now();
await enqueueFrame();
const elapsed = Date.now() - start;
const delay = Math.max(0, FRAME_MS - elapsed);
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
if (base64) {
html = html.replace(
img.full,
`<img src="${base64}"${img.rest}>`
);
}
} catch {
// Leave as-is
}
}
controller.close();
},
cancel() {
streamRecord.aborted = true;
if (streamRecord.page) {
streamRecord.page.close().catch(() => {});
streamRecord.page = null;
// Also handle CSS background-image: url(...) — inline style blocks
const bgRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi;
const bgs: { full: string; url: string }[] = [];
while ((m = bgRegex.exec(html)) !== null) {
if (m[1].startsWith("data:") || m[1].startsWith("#")) continue;
bgs.push({ full: m[0], url: m[1] });
}
activeStreams.delete(connKey);
console.log(`[mjpeg:${connKey}] Stream closed`);
},
});
return new Response(stream, {
headers: {
"Content-Type": `multipart/x-mixed-replace; boundary=${boundary}`,
"Cache-Control": "no-cache, no-store, must-revalidate",
"Connection": "keep-alive",
"X-Content-Type-Options": "nosniff",
},
for (const bg of bgs) {
try {
const resolved = new URL(bg.url, page.url()).href;
// Only process if it looks like an image URL
if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(resolved)) continue;
const base64 = await page.evaluate(async (url) => {
const res = await fetch(url);
if (!res.ok) return null;
const blob = await res.blob();
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result);
};
reader.readAsDataURL(blob);
});
}, resolved);
if (base64) {
html = html.replace(bg.full, `url(${base64})`);
}
} catch {
// Leave as-is
}
}
return html;
}
// ── Inline favicon / other <link> icons ──
async function inlineFavicons(page: Page, html: string): Promise<string> {
const iconRegex = /<link\b[^>]*\brel\s*=\s*"(?:icon|shortcut icon|apple-touch-icon)"[^>]*\bhref\s*=\s*"([^"]*)"[^>]*>/gi;
const icons: { full: string; href: string }[] = [];
let m;
while ((m = iconRegex.exec(html)) !== null) {
icons.push({ full: m[0], href: m[1] });
}
for (const icon of icons) {
if (icon.href.startsWith("data:")) continue;
try {
const resolved = new URL(icon.href, page.url()).href;
const base64 = await page.evaluate(async (url) => {
const res = await fetch(url);
if (!res.ok) return null;
const blob = await res.blob();
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result);
};
reader.readAsDataURL(blob);
});
}, resolved);
if (base64) {
html = html.replace(icon.full, icon.full.replace(icon.href, base64));
}
} catch {
// Leave as-is
}
}
return html;
}
// ── Master inliner ──
async function inlineAllResources(page: Page, html: string): Promise<string> {
html = await inlineCSS(page, html);
html = await inlineImages(page, html);
html = await inlineFavicons(page, html);
// Strip remaining external resource hints that the TV can't use
html = html.replace(/<link\b[^>]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, "");
// Remove crossorigin/integrity attributes that break without external context
html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, "");
html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, "");
return html;
}
async function renderSSR(url: string): Promise<string> {
const page = await createPage();
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 });
let html = await page.content();
// Inline all external resources so TV makes zero extra requests
html = await inlineAllResources(page, html);
// Strip JS last — after inlining (fetch in evaluate needs JS)
html = stripScriptTags(html);
return html;
} finally {
await page.close().catch(() => {});
}
}
function errorPage(message: string, status: number): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Error</title>
<meta http-equiv="refresh" content="10">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#1a1a2e;color:#e94560;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;font-size:clamp(14px,3vw,24px);}
.box{text-align:center;padding:40px;}
.icon{font-size:clamp(32px,8vw,64px);margin:0;}
.tag{font-size:clamp(10px,2vw,14px);color:#888;}
</style>
</head>
<body>
<div class="box">
<p class="icon">⚠</p>
<p>${message}</p>
<p class="tag">Samsung TV Proxy — retrying in 10s</p>
</div>
</body>
</html>`;
}
// ═══════════════════════════════════════════════════════════════
// MJPEG/SCREENSHOT MODE — single frame via meta-refresh
//
// Instead of multipart/x-mixed-replace (broken on Chromium since
// ~2014), we take a Puppeteer screenshot, embed it as a base64 data
// URI in an HTML page, and use meta-refresh to reload. Every browser
// since HTML 2.0 handles this. Frame rate = 1 / refresh interval.
// ═══════════════════════════════════════════════════════════════
async function renderScreenshotFrame(url: string): Promise<Buffer> {
const page = await createPage();
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 20000 });
const buf = await page.screenshot({
type: "jpeg",
quality: 75,
fullPage: false,
});
return Buffer.from(buf);
} finally {
await page.close().catch(() => {});
}
}
function screenshotHTML(jpegBase64: string, refreshSec: number): string {
const sec = Math.max(2, Math.ceil(refreshSec));
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="${sec}">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width:100%;height:100%;background:#000;}
img{display:block;width:100%;height:100%;object-fit:contain;}
</style>
</head>
<body>
<img src="data:image/jpeg;base64,${jpegBase64}" alt="">
</body>
</html>`;
}
// ── Per-view render lock: prevent overlapping Puppeteer renders
// when meta-refresh fires faster than render completes ──
const renderLocks = new Map<string, Promise<void>>();
async function withRenderLock<T>(
viewId: string,
fn: () => Promise<T>
): Promise<T> {
// Wait for any in-flight render to finish
while (renderLocks.has(viewId)) {
await renderLocks.get(viewId)!.catch(() => {});
}
const promise = fn().finally(() => renderLocks.delete(viewId));
renderLocks.set(viewId, promise.then(() => {}));
return promise;
}
// ═══════════════════════════════════════════════════════════════
// VIEW SERVING — /v/:id
// ═══════════════════════════════════════════════════════════════
async function serveView(view: View): Promise<Response> {
if (view.method === "mjpeg") {
return serveMJPEG(view);
}
// SSR method
const idx = getCurrentURLIndex(view);
const urlItem = view.urls[idx];
let html: string;
if (view.method === "mjpeg") {
return withRenderLock(view.id, async () => {
try {
html = await renderSSR(urlItem.url);
} catch (err: any) {
console.error(`[ssr:${view.id}] Render failed: ${err.message}`);
return new Response(errorPage(`Failed to load: ${urlItem.url}`), {
status: 502,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (view.metaRefreshEnabled) {
const interval = getRemainingSec(view);
html = injectMetaRefresh(html, interval);
}
const jpeg = await renderScreenshotFrame(urlItem.url);
const b64 = jpeg.toString("base64");
const interval = view.metaRefreshEnabled
? getRemainingSec(view)
: Math.max(3, urlItem.durationSec);
const html = screenshotHTML(b64, interval);
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
} catch (err: any) {
console.error(`[shot:${view.id}] Failed: ${err.message}`);
return new Response(errorPage(`Failed: ${urlItem.url}`, 502), {
status: 502,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
});
}
// SSR method
return withRenderLock(view.id, async () => {
let html: string;
try {
html = await renderSSR(urlItem.url);
} catch (err: any) {
console.error(`[ssr:${view.id}] Render failed: ${err.message}`);
return new Response(errorPage(`Failed: ${urlItem.url}`, 502), {
status: 502,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (view.metaRefreshEnabled) {
const interval = getRemainingSec(view);
html = injectMetaRefresh(html, interval);
}
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
});
}
// ═══════════════════════════════════════════════════════════════
// HTTP SERVER
// ═══════════════════════════════════════════════════════════════
const PORT = parseInt(process.env.PORT || "3099");
const STATIC_DIR = new URL("../public", import.meta.url).pathname;
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function readBody(req: Request): Promise<string> {
async function readBody(req: Request): Promise<string> {
return req.text();
}
async function handleAPI(req: Request, path: string): Promise<Response> {
const method = req.method;
// GET /api/views — list all
// GET /api/views
if (path === "/api/views" && method === "GET") {
return json(loadAllViews());
}
// POST /api/views — create
// POST /api/views
if (path === "/api/views" && method === "POST") {
try {
const body: CreateViewBody = JSON.parse(await readBody(req));
if (!body.name || !body.name.trim()) {
if (!body.name?.trim()) {
return json({ error: "Name is required" }, 400);
}
if (!body.urls || body.urls.length === 0) {
if (!body.urls?.length) {
return json({ error: "At least one URL is required" }, 400);
}
for (const u of body.urls) {
if (!u.url || !u.url.trim()) {
if (!u.url?.trim()) {
return json({ error: "Each URL entry must have a url" }, 400);
}
if (typeof u.durationSec !== "number" || u.durationSec <= 0) {
@@ -414,7 +610,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
}
}
if (!["mjpeg", "ssr"].includes(body.method)) {
return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400);
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
}
const view: View = {
@@ -430,25 +626,24 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
};
insertView(view);
console.log(`[api] Created view "${view.name}" (${view.id}) method=${view.method}`);
console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// Match /api/views/:id
// /api/views/:id
const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/);
if (viewMatch) {
const viewId = viewMatch[1];
// GET /api/views/:id
if (method === "GET") {
const view = loadView(viewId);
if (!view) return json({ error: "View not found" }, 404);
return json(view);
}
// PUT /api/views/:id
if (method === "PUT") {
const view = loadView(viewId);
if (!view) return json({ error: "View not found" }, 404);
@@ -468,7 +663,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
}
if (body.method !== undefined) {
if (!["mjpeg", "ssr"].includes(body.method)) {
return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400);
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
}
view.method = body.method;
}
@@ -477,55 +672,53 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
}
updateView(view);
console.log(`[api] Updated view "${view.name}" (${view.id})`);
console.log(`[api] Updated "${view.name}" (${view.id})`);
return json(view);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// DELETE /api/views/:id
if (method === "DELETE") {
// Close all active MJPEG streams for this view (one per connected client)
for (const [key, active] of activeStreams) {
if (key.startsWith(viewId + ":")) {
active.aborted = true;
if (active.page) active.page.close().catch(() => {});
activeStreams.delete(key);
}
// Wait for any in-flight render before deleting
while (renderLocks.has(viewId)) {
await renderLocks.get(viewId)!.catch(() => {});
}
const deleted = deleteViewById(viewId);
if (!deleted) return json({ error: "View not found" }, 404);
console.log(`[api] Deleted view ${viewId}`);
console.log(`[api] Deleted ${viewId}`);
return json({ ok: true });
}
}
return json({ error: "Not found" }, 404);
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// API routes
// API
if (path.startsWith("/api/")) {
return handleAPI(req, path);
}
// View serving — /v/:id
// View serving
const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/);
if (viewMatch) {
const view = loadView(viewMatch[1]);
if (!view) {
return new Response(errorPage("View not found"), {
return new Response(errorPage("View not found", 404), {
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (view.urls.length === 0) {
return new Response(errorPage("View has no URLs configured"), {
return new Response(errorPage("No URLs configured", 500), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
@@ -533,55 +726,57 @@ const server = Bun.serve({
return serveView(view);
}
// Health check
// Health
if (path === "/health") {
return json({ status: "ok", uptime: process.uptime() });
}
// Static files from public/
// Static files
const filePath = path === "/" ? "/index.html" : path;
const fullPath = STATIC_DIR + filePath;
try {
const file = Bun.file(fullPath);
// Check if file exists (will throw if not)
await file.slice(0, 0).text(); // probe
await file.slice(0, 0).text();
return new Response(file);
} catch {
// SPA fallback: serve index.html for non-API routes
try {
return new Response(Bun.file(STATIC_DIR + "/index.html"));
} catch {
return new Response("Not found", { status: 404 });
}
}
},
error(err) {
console.error("[server]", err);
return new Response("Internal Server Error", { status: 500 });
},
});
console.log(`\n🚀 Samsung TV Proxy Server`);
console.log(` http://localhost:${PORT}`);
console.log(`Static: ${STATIC_DIR}`);
console.log(` Views: http://localhost:${PORT}/v/<view-id>\n`);
// ── Graceful shutdown ──
async function shutdown() {
console.log("\n[shutdown] Closing…");
// Close all active MJPEG streams
for (const [, stream] of activeStreams) {
stream.aborted = true;
if (stream.page) await stream.page.close().catch(() => { });
// Wait for in-flight renders
const locks = [...renderLocks.values()];
if (locks.length) {
console.log(`[shutdown] Waiting for ${locks.length} in-flight render(s)…`);
await Promise.allSettled(locks);
}
activeStreams.clear();
// Close browser
if (browser) {
await browser.close().catch(() => {});
browser = null;
}
server.stop();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
BIN
View File
Binary file not shown.