Die Auslieferung trägt nur, was der Browser wirklich braucht

Die Browserfassung soll künftig über Cloudflare Workers gehen, und dort gilt
eine Grenze von 25 MiB je Datei, unkomprimiert gemessen. Zwei der
ausgelieferten Dateien standen dem entgegen: Das Quelltextarchiv maß 29,7 MB
und war damit schon nicht mehr hochzuladen, das Wasm-Modul mit 24,5 MB zu
achtundneunzig Hundertsteln der Grenze und ohne jeden Spielraum.

Das Archiv war ein ungefiltertes „git archive“ des Repositoriums, und über
fünfundneunzig Hundertstel davon waren die Beispiel-PDFs. Der Quelltext selbst
misst keine dreihunderttausend Zeichen. Die Gesetzes- und Drucksachentexte sind
fremde Werke, an denen allein die Tests messen; sie sind nicht der Quelltext,
aus dem sich das Ausgelieferte herstellen ließe, und die Auflage des AGPLv3 § 13
verlangt sie deshalb nicht. Sie sind aus dem Archiv genommen, und
„quelltext-fassung.txt“ sagt fortan, dass sie fehlen, warum sie fehlen und wo
sie vollständig liegen. Damit dies nicht stillschweigend zurückfällt, bricht
„webpaket.sh“ ab, sobald das Archiv acht Mebibyte überschreitet, und ebenso,
wenn irgendeine ausgelieferte Datei die Grenze der Zielplattform reißt: Lieber
hier auffallen als beim Hochladen.

Der Korpus lag überdies unter „src/main/resources“ und wanderte deshalb in
jedes Erzeugnis: in das Klassenverzeichnis, in die ausführbare Archivdatei —
achtundzwanzig Megabyte für ein Werk von sechshundert Kilobyte Java — und auf
den Klassenpfad des Übersetzers, dessen Ressourcentabelle die Verzeichnisnamen
bis ins Wasm trug. Er liegt nunmehr unter „src/test/resources“, wo er hingehört,
und ist von der Kopie nach „target/test-classes“ ausgenommen: Die Tests lesen
ihn über Dateisystempfade, eine Kopie wäre bei jedem Bau fünfunddreißig
Megabyte umsonst.

Das Modul wiegt statt 24,5 nunmehr 17,2 Megabyte; komprimiert geht es mit 5,5
statt 7,1 Megabyte über die Leitung. Vier Ursachen liegen dem zugrunde. Der
Picocli-Annotationsprozessor meldete die Befehlszeilenklasse mit sämtlichen
Methoden zur Reflexion an, worauf die Erreichbarkeitsanalyse die ganze
Befehlszeilenfassung samt Dateizugriffen in ein Bild zog, in dem es keine
Befehlszeile gibt; im Profil „wasm“ läuft er nicht mehr, und eine von einem
früheren Lauf liegengebliebene Konfiguration wird vor dem Übersetzen entfernt,
damit die Größe nicht davon abhängt, was vorher lief. Der Ressourcen-Glob
„org/apache/fontbox/**“ bettete 3,3 Megabyte ostasiatischer CMaps, die
Schrifttabelle „Scripts.txt“ und — weil zwei Sterne auch Klassendateien treffen
— 0,7 Megabyte „.class“-Dateien ein, von denen ein deutsches Gesetzes-PDF nichts
braucht; geblieben sind die beiden Identity-CMaps. Die Metriken unter
„org/apache/pdfbox/resources“ bleiben vollständig, denn an ihnen hängt die
Breitenberechnung bei nicht eingebetteten Schriften. Übersetzt wird mit „-Os“,
und den Rest holt „wasm-opt -Oz“ im Webpaket.

Dessen Merkmale sind einzeln aufgezählt und nicht als „--all-features“ erteilt.
Der bequeme Weg war gangbar und führte doch ins Leere: Binaryen nutzte
daraufhin Vorschläge, die noch kein Browser annimmt, und das Modul scheiterte
erst beim Instanziieren — erst an einem exakten Heap-Typ aus den „custom
descriptors“, nach dessen Abschaltung an der kompakten Importsektion. Zugelassen
ist nunmehr, was ausgeliefert in den Browsern steht und was Web Image braucht.

Die Vorkompression nach „.gz“ und „.br“ entfällt als Regelfall, denn Cloudflare
komprimiert selbst und jede Beilage wäre dort eine weitere Datei; wer mit nginx
selbst ausliefert, fordert sie mit VORKOMPRIMIEREN=1 an. Das
Auslieferungsverzeichnis fällt damit von achtundsechzig auf siebzehn Megabyte,
und ein liegengebliebenes „.DS_Store“ geht nicht mehr mit hoch.

Dreihundertfünfundzwanzig Testfälle bestehen. Das Modul ist im Browser
gelaufen: Vier Dokumentenpaare — IfSG, UWG, AGG und das Bayerische Jagdgesetz,
also gii-XML wie PDF als Stammfassung, Bundesgesetzblatt, Drucksache und
Gesetz- und Verordnungsblatt als Änderungsdokument — ergeben Synopsen, deren
SHA-256-Summen denen der Befehlszeilenfassung gleichen. Der Ressourcenbeschnitt
kostet also keine Zeichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I31a47d1dcf3b27ffa4ae891947b1c4e083107a71
diff --git a/deploy/webpaket.sh b/deploy/webpaket.sh
index 2003134..75ad471 100755
--- a/deploy/webpaket.sh
+++ b/deploy/webpaket.sh
@@ -3,11 +3,19 @@
 # „package“ des Profils -Pwasm, unmittelbar nach native-image.
 #
 #   1. Der Textzwischenschritt aendggner.js.wat (mehrere hundert Megabyte) wird entfernt.
-#   2. Der Quelltext der gebauten Fassung wird als Tarball beigelegt — AGPLv3 §13 verlangt
+#   2. Das Modul geht durch wasm-opt -Oz, sofern Binaryen zur Hand ist.
+#   3. Der Quelltext der gebauten Fassung wird als Tarball beigelegt — AGPLv3 §13 verlangt
 #      beim Netzwerkbetrieb einen Quellcode-Zugang für die Nutzer:innen, und der Footer der
 #      Startseite verweist darauf.
-#   3. Die großen Dateien werden vorkomprimiert, damit nginx sie mit gzip_static bzw.
-#      brotli_static ausliefern kann, statt 24 MB je Abruf neu zu packen.
+#   4. Nur die vom Bau erzeugten Dateien bleiben stehen; alles hier Liegende wird
+#      hochgeladen.
+#   5. Auf Wunsch (VORKOMPRIMIEREN=1) werden die großen Dateien vorkomprimiert.
+#
+# Zielplattform ist Cloudflare Workers: dort gilt eine Grenze von 25 MiB je Datei
+# (unkomprimiert), und komprimiert wird beim Ausliefern ohnehin. Deshalb ist die
+# Vorkompression abgeschaltet und die Größe jeder Datei wird geprüft. Wer stattdessen
+# selbst mit nginx ausliefert (deploy/nginx-aendggner.conf, gzip_static/brotli_static),
+# ruft das Skript mit VORKOMPRIMIEREN=1 auf.
 #
 # Aufruf: deploy/webpaket.sh <ausgabeverzeichnis>   (Standard: target/web)
 
@@ -16,15 +24,56 @@
 ziel="${1:-target/web}"
 quelle="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
 
+# Cloudflare Workers nimmt keine Datei über 25 MiB an.
+grenze_datei=26214400
+# Der Quelltext misst ein Viertel Megabyte. Wächst das Archiv über diese Schranke, sind
+# wieder Massendaten ins Repository geraten, die dort nicht hingehören (siehe
+# .gitattributes) — dann lieber der Bau ab als ein unbrauchbares Paket.
+grenze_archiv=8388608
+
 if [ ! -d "$ziel" ]; then
   echo "webpaket: $ziel gibt es nicht — erst -Pwasm package laufen lassen." >&2
   exit 1
 fi
 
+groesse() {
+  wc -c <"$1" | tr -d ' '
+}
+
 # 1. Zwischenschritt fort.
 rm -f "$ziel/aendggner.js.wat"
 
-# 2. Quelltext der gebauten Fassung.
+# 2. Nachoptimierung. -Oz zieht die Codegröße noch einmal spürbar zusammen.
+#
+# Die Merkmale sind einzeln aufgezählt, nicht als --all-features: Binaryen darf ausgeben,
+# was es darf, und mit --all-features nutzt es auch Vorschläge, die noch kein Browser
+# annimmt — das Modul scheitert dann erst beim Instanziieren („invalid heap type 'exact'“
+# aus den custom descriptors, „invalid import kind 127“ aus der kompakten
+# Importsektion). Aufgeführt ist deshalb nur, was ausgeliefert in den Browsern steht und
+# was Web Image braucht: WasmGC samt Referenztypen, das Ausnahmen-Proposal, endständige
+# Aufrufe. Fehlt wasm-opt, bleibt das Modul, wie es ist — der Bau soll daran nicht
+# scheitern.
+wasm_merkmale="--enable-gc --enable-reference-types --enable-exception-handling
+               --enable-tail-call --enable-bulk-memory --enable-bulk-memory-opt
+               --enable-nontrapping-float-to-int --enable-sign-ext
+               --enable-mutable-globals --enable-multivalue --enable-extended-const
+               --enable-simd --enable-call-indirect-overlong"
+
+if [ -f "$ziel/aendggner.js.wasm" ]; then
+  vorher="$(groesse "$ziel/aendggner.js.wasm")"
+  if command -v wasm-opt >/dev/null 2>&1; then
+    # shellcheck disable=SC2086  # die Merkmalsliste soll in Wörter zerfallen
+    wasm-opt $wasm_merkmale -Oz \
+      -o "$ziel/aendggner.js.wasm.neu" "$ziel/aendggner.js.wasm"
+    mv "$ziel/aendggner.js.wasm.neu" "$ziel/aendggner.js.wasm"
+    echo "webpaket: wasm-opt: $vorher -> $(groesse "$ziel/aendggner.js.wasm") Bytes."
+  else
+    echo "webpaket: wasm-opt nicht gefunden — Modul bleibt unnachoptimiert" \
+         "($vorher Bytes)." >&2
+  fi
+fi
+
+# 3. Quelltext der gebauten Fassung.
 #
 # Ein veränderter Arbeitsbaum bricht den Bau ab: Der angebotene Quelltext muss der
 # ausgelieferten Fassung entsprechen, sonst ist die Auflage aus §13 verfehlt. Wer nur
@@ -43,8 +92,16 @@
 fi
 
 fassung="$(git -C "$quelle" rev-parse HEAD)"
-git -C "$quelle" archive --format=tar.gz --prefix=aendggner/ HEAD \
-  -o "$(CDPATH='' cd -- "$ziel" && pwd)/aendggner-quelltext.tar.gz"
+archiv="$(CDPATH='' cd -- "$ziel" && pwd)/aendggner-quelltext.tar.gz"
+git -C "$quelle" archive --format=tar.gz --prefix=aendggner/ HEAD -o "$archiv"
+
+archivgroesse="$(groesse "$archiv")"
+if [ "$archivgroesse" -gt "$grenze_archiv" ]; then
+  echo "webpaket: Das Quelltextarchiv misst $archivgroesse Bytes und überschreitet damit" >&2
+  echo "          die Schranke von $grenze_archiv. Vermutlich sind Massendaten ins" >&2
+  echo "          Repository geraten; sie gehören in .gitattributes (export-ignore)." >&2
+  exit 1
+fi
 
 {
   echo "ÄndGgner — Quelltext der ausgelieferten Fassung"
@@ -54,20 +111,46 @@
   echo
   echo "Vollständig in aendggner-quelltext.tar.gz; Bauanleitung darin in README.adoc."
   echo "Fortlaufend: https://gerrit.benkard.de/plugins/gitiles/aendggner"
+  echo
+  echo "Nicht im Archiv liegt der Beispielkorpus (src/test/resources/sampledata): Gesetzes-"
+  echo "und Drucksachentexte fremder Urheberschaft, an denen die Tests messen. Gebaut wird"
+  echo "ohne ihn; nur die Tests verlangen danach. Wer sie laufen lassen will, holt das"
+  echo "Repository von der oben genannten Adresse, wo der Korpus vollständig liegt."
 } > "$ziel/quelltext-fassung.txt"
 
-# 3. Vorkompression. Der Tarball ist bereits gepackt und bleibt außen vor.
-for datei in "$ziel"/*.wasm "$ziel"/*.js "$ziel"/*.css "$ziel"/*.html "$ziel"/*.svg \
-             "$ziel"/*.txt; do
+# 4. Nur das Gebaute bleibt liegen — was hier steht, geht hoch.
+rm -f "$ziel/.DS_Store"
+
+# 5. Vorkompression, nur auf Anforderung (siehe Kopf). Der Tarball ist bereits gepackt und
+# bleibt außen vor.
+if [ "${VORKOMPRIMIEREN:-}" = 1 ]; then
+  for datei in "$ziel"/*.wasm "$ziel"/*.js "$ziel"/*.css "$ziel"/*.html "$ziel"/*.svg \
+               "$ziel"/*.txt; do
+    [ -f "$datei" ] || continue
+    gzip -9 -f -k -- "$datei"
+    if command -v brotli >/dev/null 2>&1; then
+      brotli -f -- "$datei"
+    fi
+  done
+
+  if ! command -v brotli >/dev/null 2>&1; then
+    echo "webpaket: brotli nicht gefunden — nur .gz angelegt." >&2
+  fi
+fi
+
+# Was zu groß ist, nimmt Cloudflare nicht an. Lieber hier auffallen als beim Hochladen.
+zugross=""
+for datei in "$ziel"/*; do
   [ -f "$datei" ] || continue
-  gzip -9 -f -k -- "$datei"
-  if command -v brotli >/dev/null 2>&1; then
-    brotli -f -- "$datei"
+  if [ "$(groesse "$datei")" -gt "$grenze_datei" ]; then
+    zugross="$zugross  $(basename "$datei") ($(groesse "$datei") Bytes)
+"
   fi
 done
-
-if ! command -v brotli >/dev/null 2>&1; then
-  echo "webpaket: brotli nicht gefunden — nur .gz angelegt." >&2
+if [ -n "$zugross" ]; then
+  echo "webpaket: Diese Dateien überschreiten die 25-MiB-Grenze von Cloudflare Workers:" >&2
+  printf '%s' "$zugross" >&2
+  exit 1
 fi
 
 echo "webpaket: $ziel ist auslieferbar (Fassung $(git -C "$quelle" rev-parse --short HEAD))."