Vier stille Fehler beim Ausliefern einer PWA
Vier stille Fehler beim Ausliefern einer PWA
Inhalt
Die Ausgangslage
Das Spiel ist eine Progressive Web App: eine einzelne HTML-Datei mit Spiellogik, dazu Sprites und Musik, zusammen rund 44 MB. Es lässt sich als App installieren und läuft offline. Genau diese beiden Eigenschaften haben später die meisten Probleme verursacht, denn beide hängen am Service Worker.
Der Spielcode liegt in einem eigenen Repository, die Auslieferung in einem anderen. Das Spiel wird also nicht dort entwickelt, wo es ausgeliefert wird. Diese Trennung ist praktisch, weil sich beides unabhängig ändern lässt, und sie hat bei jedem einzelnen Problem geholfen: Man kann die Auslieferung reparieren, ohne den Spielcode anzufassen.
Warum ein eigener Container
Unter einer anderen Adresse laufen bereits acht kleine Browserspiele in einem nginx-Container. Der naheliegende Weg wäre gewesen, das neue Spiel dort mit hineinzulegen und die neue Domain auf denselben Container zu leiten. Die Vorgabe war aber, dass das Spiel ausschließlich unter der neuen Adresse erreichbar sein soll.
In einem gemeinsamen Container hätte diese Trennung an einer nginx-Regel gehangen.
Ein nginx mit einem Wurzelverzeichnis liefert jede Datei unter jedem Hostnamen aus,
der auf ihn zeigt. Man kann das mit einem eigenen server_name-Block
einschränken, aber dann ist die Trennung eine Konfigurationszeile, die jemand
versehentlich löschen kann.
Ein zweiter Container trennt dagegen bauartbedingt. Das kostet ein Image von etwa 45 MB und spart die Frage, ob die Regel noch stimmt. Zwei Nebeneffekte kamen dazu: Der Service Worker des Spiels bekommt einen sauberen eigenen Geltungsbereich, und die 44 MB Spieldaten blähen nicht das Image der anderen Spiele auf, das sonst bei jeder kleinen Änderung dort neu gebaut würde.
Der Header, der lautlos verschwindet
Die erste Fassung der nginx-Konfiguration setzte die Sicherheits-Header im
server-Block und die Cache-Steuerung in mehreren
location-Blöcken. Das sieht sauber aus und ist trotzdem falsch.
nginx vererbt add_header aus einem übergeordneten Block nur so lange,
wie der passende location-Block kein eigenes add_header
enthält. Sobald dort eines steht, verwirft nginx alle geerbten Header. Das ist
dokumentiert, überrascht aber zuverlässig.
Dazu kommt eine Eigenheit, die den Fehler erst richtig gut versteckt: Die Direktive
index index.html schreibt einen Aufruf des Wurzelpfads intern auf
/index.html um. Damit greift die Location für HTML-Dateien, und die
hatte ein eigenes add_header für die Cache-Steuerung. Ergebnis: Auf
jeder HTML-Seite fehlten Content-Security-Policy, X-Frame-Options,
X-Content-Type-Options und Referrer-Policy vollständig.
Die Lösung war nicht, das Header-Paket in jede Location zu kopieren. Stattdessen
berechnet eine map den Wert für Cache-Control aus der angefragten URL,
und alle Header stehen genau einmal im server-Block. Damit braucht
keine Location mehr ein eigenes add_header, und die Falle kann nicht
wiederkommen.
map $uri $cache_wert {
default "no-cache, no-store, must-revalidate";
~^/play/[^/]+/assets/ "public, max-age=2592000, immutable";
}
server {
add_header Content-Security-Policy "..." always;
add_header X-Frame-Options "DENY" always;
add_header Cache-Control $cache_wert always;
# in location-Bloecken steht bewusst kein add_header
}Die Weiterleitung ins Leere
Später zog das Spiel von einem Pfad auf einen anderen um. Damit alte Links weiter funktionieren, kam eine Weiterleitung dazu. Im Container getestet sah sie richtig aus. Von außen geprüft lautete das Ziel dann so:
location: http://spiel.beispiel.de:8080/neuer/pfad/
Gleich drei Probleme in einer Zeile. Das Ziel ist unverschlüsselt, es nennt den internen Port des Anwendungscontainers, und dieser Port ist von außen gar nicht erreichbar. Die Weiterleitung lief also ins Leere, und nebenbei stand ein Stück interner Aufbau im Antwortkopf.
Der Grund ist eine Voreinstellung: nginx baut Weiterleitungsziele absolut aus Hostnamen und dem Port, auf dem es selbst lauscht. Hinter einem Reverse Proxy ist das immer falsch, weil der Proxy auf 443 hört und nginx auf etwas anderem. Zwei Zeilen stellen es ab, und das Ziel ist danach ein reiner Pfad, den der Browser gegen die aufgerufene Adresse auflöst.
absolute_redirect off; port_in_redirect off;
Betroffen war nicht nur die neue Weiterleitung, sondern auch die automatische
Verzeichnis-Weiterleitung, die nginx anlegt, wenn jemand den abschließenden
Schrägstrich vergisst. Die gab es schon vorher, und sie hatte denselben Fehler.
Innerhalb des Containers fiel nichts davon auf, weil dort localhost
und der interne Port beide stimmen. Sichtbar wurde es erst beim Aufruf von außen.
Ein Pfad, der Platz für das zweite Spiel lässt
Zuerst lag das Spiel direkt unter /play/. Das funktioniert, solange es
ein Spiel gibt. Beim zweiten müsste das erste umziehen, und ein Umzug bedeutet tote
Links und, wie sich zeigen sollte, eine installierte App, die am alten Ort hängt.
Das Schema lautet jetzt /play/<kurzname>/, mit
/play/ als reinem Sammelpfad. Ein zweites Spiel braucht damit einen
Kopierbefehl im Dockerfile, eine Kachel auf der Startseite und eine Zeile im
Überwachungsskript. Die Cache-Regeln sind schemabasiert geschrieben und müssen nicht
angefasst werden.
Wichtiger als die Ordnung ist ein technischer Punkt: Jedes Spiel bekommt so seinen
eigenen Geltungsbereich für den Service Worker. Lägen zwei Spiele gemeinsam unter
/play/, teilten sie sich einen Worker und einen Cache und würden sich
gegenseitig die Daten überschreiben.
Die alte Adresse leitet auf die neue weiter, bewusst mit Status 302 und nicht 301.
Ein 301 bleibt dauerhaft im Browser gespeichert und stünde im Weg, falls unter
/play/ irgendwann eine Übersicht aller Spiele stehen soll.
Reichweitenmessung mit Einwilligung
Für die Reichweitenmessung läuft Umami auf demselben Server. Auf der Hauptdomain wird das Messskript nur geladen, wenn der Besucher zugestimmt hat, und die Datenschutzerklärung nennt als Rechtsgrundlage die Einwilligung. Ein Skript, das auf einer weiteren Domain bedingungslos lädt, würde dieser Erklärung widersprechen.
Die neue Seite liest deshalb dasselbe Einwilligungs-Cookie, das auf der übergeordneten Domain gesetzt wird und auf allen Unterdomains lesbar ist. Ohne Zustimmung lädt nichts. Einen zweiten Cookie-Banner gibt es nicht: Wer direkt auf der Spieleseite einsteigt und nie auf der Hauptdomain zugestimmt hat, wird einfach nicht gezählt. Das kostet ein paar Zahlen und erspart einen weiteren Banner.
Die Datenschutzerklärung zählt die messenden Domains einzeln auf. Sie musste also mitgeändert werden, sonst wäre sie mit der dritten Domain schlicht unvollständig gewesen.
Bei der Content-Security-Policy gab es eine Abwägung. Das Spiel lädt nichts von außen, und genau das ist eine Eigenschaft, die man nicht leichtfertig aufgibt. Statt die Ausnahme für den Analytics-Host pauschal für die ganze Domain zu erlauben, hat die Konfiguration zwei Stufen: eine strenge als Normalfall und eine gelockerte für die Pfade, die tatsächlich messen. Wer später einen Pfad ergänzt, trifft damit eine bewusste Entscheidung.
Zwei Fallen mit Service Workern
Nachdem das Spiel die Messaufrufe eingebaut hatte, war serverseitig alles grün. Das
Messskript kam mit Status 200 und korrektem Inhaltstyp, die Policy erlaubte den
Host, die Überwachung meldete nichts. Im Browser war window.umami
trotzdem nicht vorhanden. Keine Fehlermeldung, kein roter Eintrag in der Konsole.
Ein Abruf des Skripts aus der geöffneten Seite heraus brachte die Erklärung: Statt
der erwarteten 4,7 KB JavaScript kamen 137 KB HTML zurück, beginnend mit
<!DOCTYPE html>. Der Browser bekam die Startseite des Spiels als
Skript geliefert und versuchte, HTML auszuführen.
Erste Falle: der Worker aus dem alten Pfad
Der Service Worker des Spiels hat, wie viele, eine Rückfallebene für den Offline-Betrieb. Schlägt ein Abruf fehl, liefert er die zwischengespeicherte Startseite aus. Für eine Seitennavigation ist das richtig. Für eine Bilddatei, eine Audiodatei oder ein Skript ist es nie richtig, weil der Aufrufer dann Inhalt eines völlig falschen Typs bekommt und der eigentliche Fehler unsichtbar wird.
Warum schlug der Abruf überhaupt fehl? Der kontrollierende Worker stammte noch vom
alten Pfad. Vor dem Umzug lag das Spiel unter /play/ und registrierte
seinen Worker dort, mit Geltungsbereich /play/. Dieser Bereich umfasst
den neuen Pfad gleich mit. Bei jedem Besucher, der die Seite vor dem Umzug einmal
geöffnet hatte, fing also weiterhin der alte Worker sämtliche Abrufe ab und
beantwortete sie aus einem veralteten Cache.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
await self.registration.unregister();
const fenster = await self.clients.matchAll({ type: 'window' });
for (const c of fenster) c.navigate(c.url);
})());
});
// Solange dieser Worker noch lebt: nichts abfangen.
self.addEventListener('fetch', () => {});
Der leere fetch-Listener ist kein Versehen. Ohne
respondWith holt der Browser normal aus dem Netz, und genau dieses
Verhalten hatte vorher gefehlt.
Zweite Falle: die eingefrorene Policy
Nach dem Aufräumen war der alte Worker weg, und der richtige Worker kontrollierte die Seite. Das Skript kam trotzdem weiterhin als HTML zurück.
Ein Service Worker merkt sich die Content-Security-Policy, mit der sein eigenes Skript ausgeliefert wurde, zum Zeitpunkt der Installation. Sie gilt dann für alles, was er selbst aus dem Netz holt. Die Policy war zwar um den Analytics-Host erweitert worden, aber der Worker war vorher installiert worden und arbeitete weiter mit der alten, strengen Fassung. Sein Abruf wurde blockiert, und die Rückfallebene lieferte wieder die Startseite.
Entscheidend ist dabei: Der Browser ersetzt einen Worker nur, wenn sich die Bytes seines Skripts ändern. Eine reine Änderung an den Antwort-Headern löst keine Neuinstallation aus. Die Datei war seit Wochen unverändert, also blieb alles, wie es war.
Die Lösung liegt beim Bauen des Images: An das Worker-Skript wird eine Zeile mit der ausgelieferten Version angehängt. Bei jedem Deploy mit neuem Spielstand ändern sich damit die Bytes, der Worker wird neu installiert und übernimmt die aktuellen Header. Der Spielcode selbst bleibt unangetastet, die Zeile entsteht erst beim Bauen.
RUN printf '\n// deployed: %s\n' "${REVISION}" >> /pfad/zum/sw.jsDas ist auch unabhängig von der Policy sinnvoll. Ein neuer Stand der Anwendung sollte keinen Service Worker von vorgestern behalten, der weiterhin alte Dateien aus seinem Cache beantwortet.
Das gemeinsame Muster
Alle vier Fehler waren mit curl unsichtbar. Der Statuscode stimmte
jedes Mal, die Antwortgröße war plausibel, die Überwachung meldete nichts. Drei
davon wären erst Wochen später aufgefallen, und zwar Besuchern, nicht mir.
Zwei Prüfungen haben jeweils den Unterschied gemacht. Die erste ist der Blick von außen statt aus dem Container: Der fehlende Header und der falsche Weiterleitungs-Port zeigten sich beide erst, als der Aufruf denselben Weg nahm wie der eines Besuchers. Die zweite ist der Blick in den Browser statt auf die Antwort: Die Service-Worker-Probleme waren auf keiner Protokollebene sichtbar, weil auf dem Server nichts falsch lief.
Bei einer Progressive Web App kommt erschwerend hinzu, dass ein Teil der Anwendung im Browser des Besuchers weiterlebt und dort eigene Regeln mitbringt. Wer den Pfad einer PWA ändert, sollte den alten Geltungsbereich aufräumen, sonst schleppt jeder bisherige Besucher den alten Worker mit. Und wer Header ändert, von denen ein Worker abhängt, muss dafür sorgen, dass sich auch dessen Skript ändert.