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:
+461
-266
@@ -5,10 +5,12 @@ import puppeteer from "puppeteer";
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// TYPES
|
// TYPES
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
interface URLItem {
|
interface URLItem {
|
||||||
url: string;
|
url: string;
|
||||||
durationSec: number;
|
durationSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface View {
|
interface View {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,33 +19,42 @@ interface View {
|
|||||||
metaRefreshEnabled: boolean;
|
metaRefreshEnabled: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ViewRow {
|
interface ViewRow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
urls: string; // JSON text
|
urls: string;
|
||||||
method: string;
|
method: string;
|
||||||
meta_refresh_enabled: number;
|
meta_refresh_enabled: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateViewBody {
|
interface CreateViewBody {
|
||||||
name: string;
|
name: string;
|
||||||
urls: URLItem[];
|
urls: URLItem[];
|
||||||
method: "mjpeg" | "ssr";
|
method: "mjpeg" | "ssr";
|
||||||
metaRefreshEnabled?: boolean;
|
metaRefreshEnabled?: boolean;
|
||||||
}
|
}
|
||||||
interface UpdateViewBody {
|
|
||||||
name?: string;
|
interface UpdateViewBody extends Partial<CreateViewBody> {}
|
||||||
urls?: URLItem[];
|
|
||||||
method?: "mjpeg" | "ssr";
|
|
||||||
metaRefreshEnabled?: boolean;
|
|
||||||
}
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// DATABASE
|
// DATABASE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const db = new Database("views.db", { create: true });
|
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 {
|
function rowToView(row: ViewRow): View {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -54,18 +65,21 @@ function rowToView(row: ViewRow): View {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadView(id: string): View | null {
|
function loadView(id: string): View | null {
|
||||||
const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as
|
const row = db
|
||||||
| ViewRow
|
.query("SELECT * FROM views WHERE id = ?")
|
||||||
| undefined;
|
.get(id) as ViewRow | undefined;
|
||||||
return row ? rowToView(row) : null;
|
return row ? rowToView(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAllViews(): View[] {
|
function loadAllViews(): View[] {
|
||||||
const rows = db
|
const rows = db
|
||||||
.query("SELECT * FROM views ORDER BY created_at DESC")
|
.query("SELECT * FROM views ORDER BY created_at DESC")
|
||||||
.all() as ViewRow[];
|
.all() as ViewRow[];
|
||||||
return rows.map(rowToView);
|
return rows.map(rowToView);
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertView(v: View): void {
|
function insertView(v: View): void {
|
||||||
db.run(
|
db.run(
|
||||||
"INSERT INTO views (id, name, urls, method, meta_refresh_enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
"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.method,
|
||||||
v.metaRefreshEnabled ? 1 : 0,
|
v.metaRefreshEnabled ? 1 : 0,
|
||||||
v.createdAt,
|
v.createdAt,
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateView(v: View): void {
|
function updateView(v: View): void {
|
||||||
db.run(
|
db.run(
|
||||||
"UPDATE views SET name=?, urls=?, method=?, meta_refresh_enabled=? WHERE id=?",
|
"UPDATE views SET name=?, urls=?, method=?, meta_refresh_enabled=? WHERE id=?",
|
||||||
@@ -88,20 +103,25 @@ function updateView(v: View): void {
|
|||||||
v.method,
|
v.method,
|
||||||
v.metaRefreshEnabled ? 1 : 0,
|
v.metaRefreshEnabled ? 1 : 0,
|
||||||
v.id,
|
v.id,
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteViewById(id: string): boolean {
|
function deleteViewById(id: string): boolean {
|
||||||
const result = db.run("DELETE FROM views WHERE id = ?", [id]);
|
const result = db.run("DELETE FROM views WHERE id = ?", [id]);
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// BROWSER MANAGER
|
// BROWSER MANAGER (single browser, pages created per-request)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
let browser: Browser | null = null;
|
let browser: Browser | null = null;
|
||||||
|
|
||||||
async function getBrowser(): Promise<Browser> {
|
async function getBrowser(): Promise<Browser> {
|
||||||
if (browser && browser.isConnected()) return browser;
|
if (browser?.isConnected()) return browser;
|
||||||
console.log("[browser] Launching headless Chromium…");
|
|
||||||
|
console.log("[browser] Launching headless Chrome…");
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
executablePath: "/usr/bin/chromium",
|
executablePath: "/usr/bin/chromium",
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -111,131 +131,26 @@ async function getBrowser(): Promise<Browser> {
|
|||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
"--disable-gpu",
|
"--disable-gpu",
|
||||||
"--disable-software-rasterizer",
|
"--disable-software-rasterizer",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--mute-audio",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
// Handle browser crash/disconnect
|
|
||||||
browser.on("disconnected", () => {
|
browser.on("disconnected", () => {
|
||||||
console.log(
|
console.log("[browser] Disconnected — will restart on next request");
|
||||||
"[browser] Browser disconnected — will restart on next request",
|
|
||||||
);
|
|
||||||
browser = null;
|
browser = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[browser] Ready");
|
console.log("[browser] Ready");
|
||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPage(): Promise<Page> {
|
async function createPage(): Promise<Page> {
|
||||||
const b = await getBrowser();
|
const b = await getBrowser();
|
||||||
const page = await b.newPage();
|
const page = await b.newPage();
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
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
|
// Block heavy/font/media resources for speed
|
||||||
|
|
||||||
* 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
|
|
||||||
await page.setRequestInterception(true);
|
await page.setRequestInterception(true);
|
||||||
page.on("request", (req) => {
|
page.on("request", (req) => {
|
||||||
const type = req.resourceType();
|
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
|
return page;
|
||||||
const connKey = `${view.id}:${crypto.randomUUID()}`;
|
}
|
||||||
const boundary = "MJPEG_FRAME";
|
|
||||||
let currentIdx = -1;
|
|
||||||
const streamRecord: ActiveStream = { aborted: false, page };
|
|
||||||
activeStreams.set(connKey, streamRecord);
|
|
||||||
|
|
||||||
// Navigate to the first URL before returning the response so the first
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// frame is ready immediately and Chrome has something to display.
|
// ROTATION LOGIC (wall-clock based, stateless)
|
||||||
const firstIdx = getCurrentURLIndex(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
console.log(`[mjpeg:${connKey}] Initial navigation to URL ${firstIdx}: ${view.urls[firstIdx].url}`);
|
// Resolve relative URLs
|
||||||
await page.goto(view.urls[firstIdx].url, {
|
const resolved = new URL(link.href, page.url()).href;
|
||||||
waitUntil: "networkidle2",
|
const cssText = await page.evaluate(async (url) => {
|
||||||
timeout: 20000,
|
const res = await fetch(url);
|
||||||
});
|
if (!res.ok) return null;
|
||||||
currentIdx = firstIdx;
|
return res.text();
|
||||||
} catch (err: any) {
|
}, resolved);
|
||||||
console.error(`[mjpeg:${connKey}] Initial navigation failed: ${err.message}`);
|
|
||||||
|
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();
|
return html;
|
||||||
const stream = new ReadableStream({
|
}
|
||||||
async start(controller) {
|
|
||||||
const FRAME_MS = 200; // ~5 fps
|
|
||||||
|
|
||||||
const enqueueFrame = async () => {
|
// ── Inline images → base64 data URIs ──
|
||||||
if (streamRecord.aborted) return;
|
|
||||||
|
|
||||||
const idx = getCurrentURLIndex(view);
|
async function inlineImages(page: Page, html: string): Promise<string> {
|
||||||
if (idx !== currentIdx) {
|
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 {
|
try {
|
||||||
console.log(`[mjpeg:${connKey}] Switching to URL ${idx}: ${view.urls[idx].url}`);
|
const resolved = new URL(img.src, page.url()).href;
|
||||||
await page.goto(view.urls[idx].url, {
|
const base64 = await page.evaluate(async (url) => {
|
||||||
waitUntil: "networkidle2",
|
const res = await fetch(url);
|
||||||
timeout: 20000,
|
if (!res.ok) return null;
|
||||||
});
|
const blob = await res.blob();
|
||||||
currentIdx = idx;
|
return new Promise<string>((resolve) => {
|
||||||
} catch (err: any) {
|
const reader = new FileReader();
|
||||||
console.error(`[mjpeg:${connKey}] Navigation failed: ${err.message}`);
|
reader.onloadend = () => {
|
||||||
// Stay on current page, will retry next cycle
|
const result = reader.result as string;
|
||||||
}
|
resolve(result);
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}, resolved);
|
||||||
|
|
||||||
while (!streamRecord.aborted) {
|
if (base64) {
|
||||||
const start = Date.now();
|
html = html.replace(
|
||||||
await enqueueFrame();
|
img.full,
|
||||||
const elapsed = Date.now() - start;
|
`<img src="${base64}"${img.rest}>`
|
||||||
const delay = Math.max(0, FRAME_MS - elapsed);
|
);
|
||||||
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave as-is
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.close();
|
// Also handle CSS background-image: url(...) — inline style blocks
|
||||||
},
|
const bgRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi;
|
||||||
cancel() {
|
const bgs: { full: string; url: string }[] = [];
|
||||||
streamRecord.aborted = true;
|
while ((m = bgRegex.exec(html)) !== null) {
|
||||||
if (streamRecord.page) {
|
if (m[1].startsWith("data:") || m[1].startsWith("#")) continue;
|
||||||
streamRecord.page.close().catch(() => {});
|
bgs.push({ full: m[0], url: m[1] });
|
||||||
streamRecord.page = null;
|
|
||||||
}
|
}
|
||||||
activeStreams.delete(connKey);
|
|
||||||
console.log(`[mjpeg:${connKey}] Stream closed`);
|
for (const bg of bgs) {
|
||||||
},
|
try {
|
||||||
});
|
const resolved = new URL(bg.url, page.url()).href;
|
||||||
return new Response(stream, {
|
// Only process if it looks like an image URL
|
||||||
headers: {
|
if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(resolved)) continue;
|
||||||
"Content-Type": `multipart/x-mixed-replace; boundary=${boundary}`,
|
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
const base64 = await page.evaluate(async (url) => {
|
||||||
"Connection": "keep-alive",
|
const res = await fetch(url);
|
||||||
"X-Content-Type-Options": "nosniff",
|
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
|
// VIEW SERVING — /v/:id
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async function serveView(view: View): Promise<Response> {
|
async function serveView(view: View): Promise<Response> {
|
||||||
if (view.method === "mjpeg") {
|
|
||||||
return serveMJPEG(view);
|
|
||||||
}
|
|
||||||
// SSR method
|
|
||||||
const idx = getCurrentURLIndex(view);
|
const idx = getCurrentURLIndex(view);
|
||||||
const urlItem = view.urls[idx];
|
const urlItem = view.urls[idx];
|
||||||
let html: string;
|
|
||||||
|
if (view.method === "mjpeg") {
|
||||||
|
return withRenderLock(view.id, async () => {
|
||||||
try {
|
try {
|
||||||
html = await renderSSR(urlItem.url);
|
const jpeg = await renderScreenshotFrame(urlItem.url);
|
||||||
} catch (err: any) {
|
const b64 = jpeg.toString("base64");
|
||||||
console.error(`[ssr:${view.id}] Render failed: ${err.message}`);
|
const interval = view.metaRefreshEnabled
|
||||||
return new Response(errorPage(`Failed to load: ${urlItem.url}`), {
|
? getRemainingSec(view)
|
||||||
status: 502,
|
: Math.max(3, urlItem.durationSec);
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
const html = screenshotHTML(b64, interval);
|
||||||
});
|
|
||||||
}
|
|
||||||
if (view.metaRefreshEnabled) {
|
|
||||||
const interval = getRemainingSec(view);
|
|
||||||
html = injectMetaRefresh(html, interval);
|
|
||||||
}
|
|
||||||
return new Response(html, {
|
return new Response(html, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"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
|
// HTTP SERVER
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "3099");
|
const PORT = parseInt(process.env.PORT || "3099");
|
||||||
const STATIC_DIR = new URL("../public", import.meta.url).pathname;
|
const STATIC_DIR = new URL("../public", import.meta.url).pathname;
|
||||||
|
|
||||||
function json(data: unknown, status = 200): Response {
|
function json(data: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function readBody(req: Request): Promise<string> {
|
|
||||||
|
async function readBody(req: Request): Promise<string> {
|
||||||
return req.text();
|
return req.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAPI(req: Request, path: string): Promise<Response> {
|
async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||||
const method = req.method;
|
const method = req.method;
|
||||||
// GET /api/views — list all
|
|
||||||
|
// GET /api/views
|
||||||
if (path === "/api/views" && method === "GET") {
|
if (path === "/api/views" && method === "GET") {
|
||||||
return json(loadAllViews());
|
return json(loadAllViews());
|
||||||
}
|
}
|
||||||
// POST /api/views — create
|
|
||||||
|
// POST /api/views
|
||||||
if (path === "/api/views" && method === "POST") {
|
if (path === "/api/views" && method === "POST") {
|
||||||
try {
|
try {
|
||||||
const body: CreateViewBody = JSON.parse(await readBody(req));
|
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);
|
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);
|
return json({ error: "At least one URL is required" }, 400);
|
||||||
}
|
}
|
||||||
for (const u of body.urls) {
|
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);
|
return json({ error: "Each URL entry must have a url" }, 400);
|
||||||
}
|
}
|
||||||
if (typeof u.durationSec !== "number" || u.durationSec <= 0) {
|
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)) {
|
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 = {
|
const view: View = {
|
||||||
@@ -430,25 +626,24 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
insertView(view);
|
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);
|
return json(view, 201);
|
||||||
} catch {
|
} catch {
|
||||||
return json({ error: "Invalid JSON body" }, 400);
|
return json({ error: "Invalid JSON body" }, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Match /api/views/:id
|
|
||||||
|
// /api/views/:id
|
||||||
const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/);
|
const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/);
|
||||||
if (viewMatch) {
|
if (viewMatch) {
|
||||||
const viewId = viewMatch[1];
|
const viewId = viewMatch[1];
|
||||||
|
|
||||||
// GET /api/views/:id
|
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const view = loadView(viewId);
|
const view = loadView(viewId);
|
||||||
if (!view) return json({ error: "View not found" }, 404);
|
if (!view) return json({ error: "View not found" }, 404);
|
||||||
return json(view);
|
return json(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/views/:id
|
|
||||||
if (method === "PUT") {
|
if (method === "PUT") {
|
||||||
const view = loadView(viewId);
|
const view = loadView(viewId);
|
||||||
if (!view) return json({ error: "View not found" }, 404);
|
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 (body.method !== undefined) {
|
||||||
if (!["mjpeg", "ssr"].includes(body.method)) {
|
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;
|
view.method = body.method;
|
||||||
}
|
}
|
||||||
@@ -477,55 +672,53 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateView(view);
|
updateView(view);
|
||||||
console.log(`[api] Updated view "${view.name}" (${view.id})`);
|
console.log(`[api] Updated "${view.name}" (${view.id})`);
|
||||||
return json(view);
|
return json(view);
|
||||||
} catch {
|
} catch {
|
||||||
return json({ error: "Invalid JSON body" }, 400);
|
return json({ error: "Invalid JSON body" }, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/views/:id
|
|
||||||
if (method === "DELETE") {
|
if (method === "DELETE") {
|
||||||
// Close all active MJPEG streams for this view (one per connected client)
|
// Wait for any in-flight render before deleting
|
||||||
for (const [key, active] of activeStreams) {
|
while (renderLocks.has(viewId)) {
|
||||||
if (key.startsWith(viewId + ":")) {
|
await renderLocks.get(viewId)!.catch(() => {});
|
||||||
active.aborted = true;
|
|
||||||
if (active.page) active.page.close().catch(() => {});
|
|
||||||
activeStreams.delete(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = deleteViewById(viewId);
|
const deleted = deleteViewById(viewId);
|
||||||
if (!deleted) return json({ error: "View not found" }, 404);
|
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({ ok: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ error: "Not found" }, 404);
|
return json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
// API routes
|
// API
|
||||||
if (path.startsWith("/api/")) {
|
if (path.startsWith("/api/")) {
|
||||||
return handleAPI(req, path);
|
return handleAPI(req, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// View serving — /v/:id
|
// View serving
|
||||||
const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/);
|
const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/);
|
||||||
if (viewMatch) {
|
if (viewMatch) {
|
||||||
const view = loadView(viewMatch[1]);
|
const view = loadView(viewMatch[1]);
|
||||||
if (!view) {
|
if (!view) {
|
||||||
return new Response(errorPage("View not found"), {
|
return new Response(errorPage("View not found", 404), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (view.urls.length === 0) {
|
if (view.urls.length === 0) {
|
||||||
return new Response(errorPage("View has no URLs configured"), {
|
return new Response(errorPage("No URLs configured", 500), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
});
|
});
|
||||||
@@ -533,55 +726,57 @@ const server = Bun.serve({
|
|||||||
return serveView(view);
|
return serveView(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check
|
// Health
|
||||||
if (path === "/health") {
|
if (path === "/health") {
|
||||||
return json({ status: "ok", uptime: process.uptime() });
|
return json({ status: "ok", uptime: process.uptime() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static files from public/
|
// Static files
|
||||||
const filePath = path === "/" ? "/index.html" : path;
|
const filePath = path === "/" ? "/index.html" : path;
|
||||||
const fullPath = STATIC_DIR + filePath;
|
const fullPath = STATIC_DIR + filePath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(fullPath);
|
const file = Bun.file(fullPath);
|
||||||
// Check if file exists (will throw if not)
|
await file.slice(0, 0).text();
|
||||||
await file.slice(0, 0).text(); // probe
|
|
||||||
return new Response(file);
|
return new Response(file);
|
||||||
} catch {
|
} catch {
|
||||||
// SPA fallback: serve index.html for non-API routes
|
|
||||||
try {
|
try {
|
||||||
return new Response(Bun.file(STATIC_DIR + "/index.html"));
|
return new Response(Bun.file(STATIC_DIR + "/index.html"));
|
||||||
} catch {
|
} catch {
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
error(err) {
|
error(err) {
|
||||||
console.error("[server]", err);
|
console.error("[server]", err);
|
||||||
return new Response("Internal Server Error", { status: 500 });
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\n🚀 Samsung TV Proxy Server`);
|
console.log(`\n🚀 Samsung TV Proxy Server`);
|
||||||
console.log(` http://localhost:${PORT}`);
|
console.log(` http://localhost:${PORT}`);
|
||||||
console.log(`Static: ${STATIC_DIR}`);
|
|
||||||
console.log(` Views: http://localhost:${PORT}/v/<view-id>\n`);
|
console.log(` Views: http://localhost:${PORT}/v/<view-id>\n`);
|
||||||
|
|
||||||
// ── Graceful shutdown ──
|
// ── Graceful shutdown ──
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
console.log("\n[shutdown] Closing…");
|
console.log("\n[shutdown] Closing…");
|
||||||
// Close all active MJPEG streams
|
|
||||||
for (const [, stream] of activeStreams) {
|
// Wait for in-flight renders
|
||||||
stream.aborted = true;
|
const locks = [...renderLocks.values()];
|
||||||
if (stream.page) await stream.page.close().catch(() => { });
|
if (locks.length) {
|
||||||
|
console.log(`[shutdown] Waiting for ${locks.length} in-flight render(s)…`);
|
||||||
|
await Promise.allSettled(locks);
|
||||||
}
|
}
|
||||||
activeStreams.clear();
|
|
||||||
// Close browser
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close().catch(() => {});
|
await browser.close().catch(() => {});
|
||||||
browser = null;
|
browser = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
server.stop();
|
server.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
Reference in New Issue
Block a user