Files
Flaro/web/server/index.js
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

135 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/* Der Halo-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: HALO_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.HALO_STATIC ?? join(HIER, '..', 'app', 'dist');
const ZIEL = (process.env.HALO_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(`Halo Web läuft auf http://localhost:${PORT}`);
console.log(`Medienserver: ${ZIEL}`);
console.log(`Dateien aus: ${DATEIEN}`);
});