#!/usr/bin/env node /* Der Flaro-Web-Server. ---------------------------------------------------------------------------- Zwei Aufgaben, und beide lösen etwas, woran die einzelne HTML-Datei gescheitert ist: 1. **Er reicht die Anfragen weiter.** Damit haben Seite und Daten dieselbe Herkunft. Kein CORS — auch nicht beim Streamen, wo es am ehesten weh tut. Und kein Ärger mit gemischtem HTTP und HTTPS: Läuft der Medienserver im Klartext und die Seite verschlüsselt, blockiert der Browser den Strom, und keine Zeile im Frontend kann etwas dagegen tun. 2. **Er liefert die gebauten Dateien aus.** Ohne Abhängigkeiten, nur mit dem, was Node mitbringt — ein Server, der auf einem kleinen Container läuft, soll nicht sein halbes Verzeichnis mitschleppen. Aufruf: FLARO_SERVER=http://jellyfin:8096 node index.js */ import { createServer } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import { join, extname, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; const HIER = fileURLToPath(new URL('.', import.meta.url)); const DATEIEN = process.env.FLARO_STATIC ?? join(HIER, '..', 'app', 'dist'); const ZIEL = (process.env.FLARO_SERVER ?? 'http://localhost:8096').replace(/\/+$/, ''); const PORT = Number(process.env.PORT ?? 8080); const TYPEN = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.woff2': 'font/woff2', '.map': 'application/json', }; /* Reicht eine Anfrage an den Medienserver weiter. ---------------------------------------------------------------------------- Der Körper wird **durchgereicht, nicht gepuffert**: ein Film ist ein Strom, und ihn erst vollständig einzulesen hieße, ihn zwischenzuspeichern. Deshalb `duplex: 'half'` und das Weiterleiten des Leseendes. */ async function weiterreichen(req, res) { const pfad = req.url.slice('/api'.length) || '/'; const ziel = ZIEL + pfad; // Die Kopfzeilen des Browsers gehen mit — bis auf die, die nur für die // Verbindung hierher galten. `host` insbesondere: mit ihm meldete sich der // Medienserver unter der falschen Adresse. const kopf = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (['host', 'connection', 'content-length'].includes(k)) continue; if (Array.isArray(v)) v.forEach((x) => kopf.append(k, x)); else if (v != null) kopf.set(k, v); } try { const antwort = await fetch(ziel, { method: req.method, headers: kopf, body: ['GET', 'HEAD'].includes(req.method) ? undefined : req, duplex: 'half', redirect: 'manual', }); const raus = Object.fromEntries(antwort.headers); // Die Länge stimmt nach dem Durchreichen nicht mehr zwingend, und eine // falsche Angabe bricht den Strom mitten im Film ab. delete raus['content-encoding']; delete raus['content-length']; res.writeHead(antwort.status, raus); if (!antwort.body) return res.end(); for await (const stueck of antwort.body) res.write(stueck); res.end(); } catch (fehler) { // Eine abgebrochene Verbindung ist kein Serverfehler — der Zuschauer hat // nur weitergeklickt. if (res.headersSent) return res.destroy(); res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ fehler: 'Der Medienserver ist nicht erreichbar.', ziel: ZIEL, ursache: String(fehler?.cause?.code ?? fehler?.message ?? fehler), })); } } /** Liefert eine gebaute Datei aus — oder die Seite selbst, denn die * Navigation läuft im Browser und jeder Weg führt auf dieselbe Datei. */ async function ausliefern(req, res) { const angefragt = decodeURIComponent(new URL(req.url, 'http://x').pathname); // `normalize` allein genügt nicht — erst das Abschneiden führender Punkte // verhindert, dass jemand mit „../" aus dem Verzeichnis herausspaziert. const sicher = normalize(angefragt).replace(/^(\.\.[/\\])+/, ''); let datei = join(DATEIEN, sicher); try { const info = await stat(datei); if (info.isDirectory()) datei = join(datei, 'index.html'); } catch { datei = join(DATEIEN, 'index.html'); } try { const inhalt = await readFile(datei); res.writeHead(200, { 'content-type': TYPEN[extname(datei)] ?? 'application/octet-stream', // Die Seite selbst nie zwischenspeichern, alles andere gern: gebaute // Dateien tragen ihren Fingerabdruck im Namen. 'cache-control': datei.endsWith('index.html') ? 'no-cache' : 'max-age=31536000, immutable', }); res.end(inhalt); } catch { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Nicht gefunden. Wurde das Frontend gebaut? (npm run build in web/app)'); } } createServer((req, res) => { if (req.url?.startsWith('/api')) return weiterreichen(req, res); return ausliefern(req, res); }).listen(PORT, () => { console.log(`Flaro Web läuft auf http://localhost:${PORT}`); console.log(`Medienserver: ${ZIEL}`); console.log(`Dateien aus: ${DATEIEN}`); });