Die abgelegte Fassung lud bei jedem Öffnen 39 MB neu
· auslieferung · werkzeuge
Eine PWA hat genau eine Zusage: einmal laden, danach startet sie. Till hatte Pipfold auf dem iPhone abgelegt, und bei jedem Öffnen lud das Spiel wieder herunter. Also war die Zusage gebrochen — und mit ihr das Argument, warum es für iOS keine App im Store braucht.
Vier Fehler, unabhängig voneinander. Zwei davon hätten allein gereicht.
1. Der grosse Brocken kam nie in den Cache
Gemessen im Browser: acht Einträge im Cache, usage 2,3 MB statt 39.
Der Service Worker hatte alles ausser index.wasm — 37,7 von 38,9 MB, also
97 % der Ladung. Die index.pck mit 1,2 MB kam durch, die wasm nie.
Godot legt so ab:
cache.put(req, response.clone())
clone() liefert einen Stream-Body. Und Cache.put() lehnt einen Stream
über rund 16 MB ab. Die index.wasm ist 37,7 MB gross und fiel damit jedes
Mal durch; die 1,2 MB der .pck liegen weit unter der Grenze und kamen an.
Es ist also kein Wettlauf und kein sporadischer Fehler, sondern eine harte Schwelle — reproduzierbar, jedes Mal, seit dem ersten Tag.
Und es war unsichtbar. Godot ruft cache.put(...) weder mit await noch
mit catch auf — der Fehler verschwindet als unbehandelte Promise-Ablehnung.
Keine Meldung, keine Warnung. Dazu kommt: console.error aus einem Service
Worker landet nicht in der Seiten-Konsole. Wer dort nachsieht, sieht nichts
und schliesst daraus, dass nichts passiert ist.
Am Entwicklungsrechner fiel es ausserdem nie auf, weil der gewöhnliche HTTP-Cache mit ETag revalidiert: 304 statt 37 MB. Der Worker war leer, und trotzdem startete das Spiel sofort. Auf dem iPhone wirft iOS eine so grosse Einzelressource schnell wieder hinaus — dort war der Worker die einzige Instanz, die die Datei hätte halten können, und genau sie hatte sie nicht.
Behoben in deploy.sh — das bearbeitet den erzeugten Worker ohnehin nach: die
Antwort wird einmal vollständig gepuffert, daraus werden beide bedient,
Cache und Spiel. Kein Klon, kein Wettlauf.
Ein erster Anlauf legte den Klon nur im Hintergrund ab
(event.waitUntil(manaCachePut(...))). Das sieht im Quelltext aus wie die
Lösung: das Ablegen blockiert nicht mehr, der Fehler wird gefangen. Im Browser
blieb der Cache bei 2,2 MB. Am Ergebnis gemessen, nicht am Quelltext —
sonst wäre es als „behoben“ durchgegangen und die Sache hätte weitere zwei
Wochen gelegen.
2. Der Flicken kam gar nicht beim Browser an
Er wirkte zunächst trotzdem nicht.
Grund: /spielen/index-sw-*.js fiel unter keine Regel im Caddyfile. Der
Eintrag /index-sw-*.js daneben trifft nur die Wurzel — Caddys path
vergleicht den ganzen Pfad, nicht das Ende. Ohne Cache-Control setzte
Cloudflare seine Zonen-Vorgabe darauf: max-age=14400, cf-cache-status: HIT.
Am Origin lag der Flicken, im Browser die alte Fassung.
Das ist schlimmer, als es klingt. Der Loader trägt einen Fingerabdruck im
Namen, der Worker aber nur, solange sich die .pck ändert — daraus wird er
gebildet. Wer nur den Worker anfasst und sonst nichts, liefert unter demselben
Namen neu aus. Genau das war hier der Fall: an einem Fehler im Worker arbeiten
heisst, den einzigen Weg zu benutzen, auf dem die Änderung nicht durchkommt.
Behoben: /spielen/index-sw-*.js in den @frisch-Abgleich, Rand einmal
gepurgt.
3. Die Zone überschrieb alle Kopfzeilen
browser_cache_ttl stand für die ganze Zone pipfold.com auf 14400.
Damit war jede sorgfältig gesetzte Kopfzeile wirkungslos: no-cache auf den
Einstiegsseiten wurde zu vier Stunden — und die Download-Pakete bekamen statt
immutable (ein Jahr) ebenfalls nur vier.
Das ist derselbe Fehler wie am 25. Juli, nur auf einer anderen Zone. Eine Falle, die man einmal verstanden hat, schnappt beim nächsten Hostnamen wieder zu, weil die Einstellung je Zone gilt und eine neue Domain sie frisch mitbringt.
Auf 0 gesetzt („Origin respektieren“). Der vorige Wert liegt in
~/.config/mana/cf-browsercachettl-pipfold-vor-20260819.json.
4. Von der Startseite ablegen ergab nur ein Lesezeichen
Der vierte Fehler ist der, den man am ehesten für Bedienfehler hält: Till hatte
von pipfold.com/ abgelegt — von der Landing, nicht vom Spiel.
Und die Landing hatte kein Manifest und keine apple-Meta. Auf dem iPhone wird daraus ein blosser Web-Clip: ohne App-Identität, ohne Geltungsbereich, ohne Anspruch auf den Cache des Spiels.
Behoben: beide Landings (/ und /en/) verweisen jetzt auf dasselbe
Manifest wie das Spiel. Ausdrücklich nicht auf ein eigenes — der Browser leitet
die Kennung einer installierten App aus start_url ab, und ein zweites
Manifest wäre eine zweite App auf demselben Startbildschirm, mit eigenem
Speicher und eigenem Spielstand.
Was daran allgemein ist
Vier Fehler auf einer Strecke, und drei davon melden sich nicht.
Eine unbehandelte Promise-Ablehnung in einem Service Worker, eine Caddy-Regel, die einen Pfad nicht trifft, und eine Zoneneinstellung in einem fremden Bedienfeld haben eines gemeinsam: Sie verhalten sich exakt wie der Normalfall. Nichts ist rot, nichts ist langsam, nichts steht im Log. Es ist nur langsamer, als es sein müsste, und das merkt man erst, wenn jemand es benutzt.
Deshalb steht am Ende tools/pwa_pruefen.mjs. Er prüft dreierlei: dass der
ausgelieferte Worker den Flicken trägt — nicht der im Repo —, dass er
überhaupt mit frischen Kopfzeilen ankommt, und dass Cache.put 40 MB als Blob
annimmt, die Mechanik also trägt.
🔴 Was er nicht kann: den abgelegten Zustand nachstellen. Godot registriert
den Worker erst rund 30 Sekunden nach dem Laden, und headless bleibt
navigator.serviceWorker.controller leer — mit persistentem Profil
durchprobiert, es geht nicht. Der Endzustand gehört einmal von Hand
nachgesehen, und das Rezept dafür druckt der Riegel selbst aus:
await navigator.storage.estimate() // usage muss > 30 MB sein
Ein Riegel, der ehrlich sagt, wo er aufhört, ist mehr wert als einer, der so tut, als decke er alles ab. Der zweite ist genau die Sorte, die diesen Fehler sechs Wochen lang hätte übersehen können.