GEFUNDEN BEIM MESSEN, NICHT BEIM LESEN.
Nach dem Ausliefern wollte ich auf dem Server nachweisen, dass die DORT
liegende `helfer-ausliefern.mjs` wirklich Teilanfragen beantwortet. Dafuer
habe ich ihr einen winzigen Server vorgesetzt -- `node:http`, eine
Wegwerfdatei in /tmp, eigener Port. Die volle Datei kam sauber
(`accept-ranges: bytes`, `content-length: 1000`). Beim ersten Abschnitt:
TypeError: res.status is not a function
at liefereDatei (.../helfer-ausliefern.mjs:134:9)
`res.status()` gibt es nur an einer EXPRESS-Antwort. Alle zehn Aufrufer
SIND Express-Handler, im Betrieb lief also alles richtig -- 146 Pruefungen
in pruef-chat-anhaenge und 33 in pruef-wissen-neu haben es bestaetigt, und
sie hatten recht. Trotzdem ist es ein Mangel: Der Helfer hing an Express,
ohne dass irgendwo stand warum, und liess sich nur noch INNERHALB der
ganzen Anwendung pruefen.
GEAENDERT: Die drei Stellen setzen jetzt `res.statusCode = n` und rufen
`res.end()`. Das kennen beide Antwortarten, und Express aendert daran
nichts -- an der ausgelieferten Antwort ist kein Unterschied messbar.
DAZU EINE PRUEFUNG, DIE OHNE SERVER AUSKOMMT (pruef-struktur, +14):
`bereichLesen()` steht ausdruecklich als eigene, ausgefuehrte Funktion da.
Jetzt wird sie auch einzeln befragt -- ohne Browser, ohne Server, ohne
Datenbank:
kein Kopf / leerer Kopf -> volle Datei
bytes=0-9 · bytes=5- · bytes=-8 -> der richtige Abschnitt
bytes=0-5000 -> endet am Dateiende (erlaubt)
bytes=1000- · 9-5 · -0 · leere Datei -> 416
mehrere Bereiche · fremde Einheit · Unsinn -> volle Datei
Die letzte Zeile ist die wichtigste: NICHT VERSTANDEN ist etwas anderes als
UNERFUELLBAR. Auf einen Kopf, den der Server nicht liest, gehoert die ganze
Datei -- nie eine falsche Teilmenge und nie eine Absage.
DIE LEHRE, die ich mir aufschreibe: Durch zehn Express-Handler hindurch
waere das nie aufgefallen. Was sich einzeln pruefen laesst, wird einzeln
geprueft -- und ein Helfer, den man nur mit der ganzen Anwendung messen
kann, ist schwerer zu beweisen als einer, dem eine Antwort genuegt.
GEPRUEFT: pruef-struktur 85 -> 99 ok · pruef-chat-anhaenge 146 ok ·
pruef-wissen-neu 22 ok. Danach dieselbe Messung auf dem Server noch einmal,
gegen die ausgelieferte Datei.
BERICHTIGUNG ZUM COMMIT DAVOR: Dort steht „pruef-wissen-neu 33 ok". Das
war keine Messung, sondern geschaetzt -- nachgezaehlt sind es 22 (16 vorher
plus meine 6). Die Zahl stimmte nicht, der Befund schon. Eine Zahl, die man
nicht gezaehlt hat, gehoert nicht in eine Zusammenfassung.
Co-Authored-By: Claude Opus 5 <[email protected]>
163 lines
6.6 KiB
JavaScript
163 lines
6.6 KiB
JavaScript
/* EINE DATEI AUSLIEFERN — MIT TEILANFRAGEN (02.10.2026)
|
|
=====================================================================
|
|
|
|
WARUM ES DIESE DATEI GIBT
|
|
|
|
Miss, eine Modi, konnte keine Sprachnachrichten hoeren. Alle
|
|
anderen schon. Gemessen hat es sich so aufgeklaert:
|
|
|
|
· An ihrer Rolle lag es nicht — der Anhang-Weg fragt nur
|
|
„gibt es die Nachricht", „ist sie im Raum", „hat sie das
|
|
Gespraech weggeraeumt". Eine Modi bekommt HTTP 200.
|
|
· Sie ist die EINZIGE im Haus mit Safari (iPhone, iOS 18.7).
|
|
Alle anderen: Android-Chrome, Windows-Chrome/Edge, Firefox.
|
|
· Und der Anhang-Weg beantwortete eine Teilanfrage
|
|
(`Range: bytes=0-1`) mit einer vollen 200, ohne
|
|
`Accept-Ranges`, ohne `Content-Length`, als `chunked`.
|
|
|
|
Safari verlangt fuer <audio> und <video> zwingend Teilanfragen und
|
|
verweigert die Wiedergabe bei einer 200. Chrome und Firefox nehmen
|
|
die ganze Datei klaglos — deshalb fiel es sieben Tage lang
|
|
niemandem auf ausser ihr. Bilder brauchen das nicht: Deshalb sah
|
|
sie Fotos und hoerte nichts.
|
|
|
|
WARUM EIN GEMEINSAMER HELFER UND NICHT EINE REPARATUR
|
|
|
|
Im Haus gab es ZEHN Stellen, die eine Datei mit
|
|
`createReadStream(pfad).pipe(res)` hinausgeben: Chat-Anhang,
|
|
Chat-GIF, Dateiablage (ansehen und laden), Material (ansehen und
|
|
laden), Steckbriefbild, Buehnenbild, Supportbild, Wissens-PDF.
|
|
Nur eine davon zu reparieren hiesse, eine Liste zu fuehren, welche
|
|
Stelle schon richtig ist — und die naechste neue Stelle macht es
|
|
wieder falsch. Hier ist es EIN Weg, den alle zehn benutzen.
|
|
|
|
DAMIT ES SO BLEIBT, zaehlt `pruef-struktur` nach: Wer kuenftig
|
|
wieder `createReadStream(...).pipe(res)` schreibt, bekommt einen
|
|
Befund. Ein Kommentar, der vor einem Fehler warnt, verhindert ihn
|
|
nicht — das hat der Spaltenverlust vom 11.09. gezeigt.
|
|
|
|
WAS ES AUSSER SAFARI NOCH BRINGT (gemessen, nicht vermutet)
|
|
|
|
· Eine MP4-Sprachnachricht meldete in Chromium ohne Teilanfragen
|
|
eine Laenge von 0,23 s statt 1,96 s — der Kopf einer MP4 steht
|
|
am ENDE der Datei, und ohne Springen findet der Browser ihn
|
|
nicht.
|
|
· Eine Ogg-Datei meldete „Infinity" statt 2,02 s.
|
|
· In einer PDF springt der Betrachter zu einer Seite, ohne die
|
|
ganze Datei zu holen.
|
|
|
|
WAS HIER ABSICHTLICH NICHT PASSIERT
|
|
|
|
Mehrere Bereiche in einer Anfrage („bytes=0-9,20-29") werden NICHT
|
|
bedient. Das darf ein Server (RFC 9110: er MAY den Kopf
|
|
uebergehen), und die Antwort ist dann die vollstaendige Datei —
|
|
nie eine falsche Teilmenge. Kein Browser im Haus fragt so.
|
|
===================================================================== */
|
|
|
|
import { createReadStream, statSync } from "node:fs";
|
|
|
|
/** Einen `Range`-Kopf lesen.
|
|
*
|
|
* Drei Formen sind erlaubt und werden unterschieden:
|
|
* bytes=100-199 ab 100 bis einschliesslich 199
|
|
* bytes=100- ab 100 bis zum Ende
|
|
* bytes=-500 die letzten 500 Bytes
|
|
*
|
|
* Rueckgabe: null = kein (oder kein verstandener) Bereich, volle Datei
|
|
* false = der Bereich liegt ausserhalb der Datei -> 416
|
|
* {von, bis} = genau dieser Abschnitt
|
|
*
|
|
* Ausgelagert und ausgefuehrt, damit die Pruefung sie einzeln
|
|
* befragen kann, ohne einen Server zu starten. */
|
|
export function bereichLesen(roh, groesse) {
|
|
if (typeof roh !== "string" || !roh) return null;
|
|
const t = /^bytes=(\d*)-(\d*)$/.exec(roh.trim());
|
|
if (!t) return null; /* auch „bytes=0-9,20-29" landet hier */
|
|
const [, a, b] = t;
|
|
if (a === "" && b === "") return null;
|
|
if (groesse <= 0) return false;
|
|
|
|
let von; let bis;
|
|
if (a === "") {
|
|
/* Die letzten n Bytes. „bytes=-0" ist nach RFC unerfuellbar. */
|
|
const n = Number(b);
|
|
if (n <= 0) return false;
|
|
von = Math.max(0, groesse - n);
|
|
bis = groesse - 1;
|
|
} else {
|
|
von = Number(a);
|
|
bis = b === "" ? groesse - 1 : Number(b);
|
|
if (von >= groesse) return false;
|
|
if (bis >= groesse) bis = groesse - 1; /* zu viel verlangt ist erlaubt */
|
|
if (bis < von) return false;
|
|
}
|
|
return { von, bis };
|
|
}
|
|
|
|
/** Eine Datei von der Platte in die Antwort geben.
|
|
*
|
|
* Die Kopfzeilen, die zur SACHE gehoeren (Content-Type, Cache,
|
|
* Content-Disposition, Sicherheitsregeln), setzt der Aufrufer VORHER
|
|
* — die bleiben unberuehrt. Hier kommt nur der Transport dazu.
|
|
*
|
|
* @param {import("express").Request} req
|
|
* @param {import("express").Response} res
|
|
* @param {string} pfad Datei, die es schon gibt (der Aufrufer hat
|
|
* vorher nachgesehen und sonst 410 gemeldet)
|
|
*/
|
|
export function liefereDatei(req, res, pfad) {
|
|
/* ==== WARUM `res.statusCode = n` UND NICHT `res.status(n)` =========
|
|
|
|
`res.status()` gibt es nur an einer EXPRESS-Antwort. Alle zehn
|
|
Aufrufer sind Express-Handler, es lief also -- aber der Helfer
|
|
hing damit an Express, ohne dass irgendwo stand warum.
|
|
|
|
Aufgefallen ist es nicht beim Lesen, sondern beim MESSEN: Die
|
|
Probe auf dem Server reichte eine nackte `http.ServerResponse`
|
|
herein, und die Zeile warf „res.status is not a function". Ein
|
|
Helfer, den man nur innerhalb der ganzen Anwendung pruefen kann,
|
|
ist schwerer zu beweisen als einer, dem eine Antwort genuegt.
|
|
`statusCode` kennen beide, und Express aendert daran nichts. */
|
|
let groesse;
|
|
try { groesse = statSync(pfad).size; } catch {
|
|
/* Zwischen dem Nachsehen des Aufrufers und hier ist die Datei
|
|
verschwunden. Nichts gesendet, also darf noch ein Fehler kommen. */
|
|
if (!res.headersSent) { res.statusCode = 410; res.end(); }
|
|
return;
|
|
}
|
|
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
const bereich = bereichLesen(req.headers?.range, groesse);
|
|
|
|
if (bereich === false) {
|
|
/* UNERFUELLBAR. Die Groesse mitzugeben ist Pflicht, sonst weiss
|
|
der Browser nicht, was er stattdessen fragen soll. */
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", `bytes */${groesse}`);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const strom = bereich
|
|
? createReadStream(pfad, { start: bereich.von, end: bereich.bis })
|
|
: createReadStream(pfad);
|
|
|
|
if (bereich) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${bereich.von}-${bereich.bis}/${groesse}`);
|
|
res.setHeader("Content-Length", String(bereich.bis - bereich.von + 1));
|
|
} else {
|
|
res.setHeader("Content-Length", String(groesse));
|
|
}
|
|
|
|
/* Bricht der Empfaenger ab (Finger weg vom Bildschirm, Seite zu),
|
|
wird nicht weiter von der Platte gelesen. Ohne das haengt bei
|
|
jedem Abbruch ein offener Lesestrom. */
|
|
res.on("close", () => { if (!strom.destroyed) strom.destroy(); });
|
|
strom.on("error", () => {
|
|
if (!res.headersSent) { res.statusCode = 500; res.end(); }
|
|
else res.destroy();
|
|
});
|
|
strom.pipe(res);
|
|
}
|