diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-15 10:51:11 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-15 10:51:11 +0200 |
| commit | 2b1841e17533543d1cb15220ca86a2a37c853daf (patch) | |
| tree | 01b940cb06c5abc36704d2d4853343ee7b38ce02 /src/wasm | |
| parent | 5700bbcb89186e432c5c7dd232b61c052de8e784 (diff) | |
Die Web-App rechnet im Browser statt auf dem Server
Bisher lief die Weboberfläche auf einem JDK-HttpServer: Uploads landeten als
temporäre Dateien auf dem Server, die Synopse entstand dort. Das kostete
Betrieb (systemd, Reverse Proxy, Rate-Limiting, Timeouts) und verlangte ein
Datenschutzversprechen, das nur zusicherbar, nicht nachprüfbar war — gerade
Entwurfstexte verließen den Rechner der Nutzer:innen.
Neu übersetzt `./mvnw -Pwasm package` dieselbe Pipeline mit GraalVM Web Image
(`native-image --tool:svm-wasm`) nach WebAssembly, PDFBox eingeschlossen.
Ausgeliefert werden nur noch statische Dateien; gerechnet wird im Browser.
Die erzeugte Synopse ist byteweise identisch mit der der Befehlszeile
(SHA-256 verglichen für IfSG 48/27/21 und BayJG 151/3/54).
Die Befehlszeile bleibt unberührt: `./mvnw package` erzeugt unverändert das
JAR, alle Optionen und Meldungstexte sind gleich, das Wasm-Profil ist rein
additiv und verlangt Oracle GraalVM 25.1+ (die CE hat kein Web Image).
Portabilitätsschnitt (nützt beiden Fassungen):
* `Quelle` (Name + Bytes) ersetzt `Path` in der Pipeline; nur die
Befehlszeile kennt noch ein Dateisystem. Der Name trägt genau den
bisherigen `getFileName()`-Text, damit Warnungen und Quellenzeile
wortgleich bleiben.
* `DateiTyp` erkennt PDF/XML/Klartext an den Signaturbytes. Tika entfällt —
eine schwergewichtige Abhängigkeit samt ServiceLoader- und
XML-Konfiguration weniger, was der Wasm-Übersetzung unmittelbar zugutekommt.
Vier Eigenheiten von Web Image, die der Quelltext jeweils an Ort und Stelle
vermerkt:
* `java.util.zip.Inflater` ist nicht angebunden (GR-65205), ohne Inflate ist
kein PDF lesbar. `InflaterErsatz` substituiert ihn durch jzlib.
* Typisierte JS-Felder lassen sich nicht nach `byte[]` umsetzen
(„byteArrayHub is not defined“); der Dateiinhalt wandert als Base64.
* JULs Standardformatter ruft `StackWalker`, den es dort nicht gibt.
* Im Worker fehlt `document.currentScript`, worauf die Laufzeit das
Wasm-Modul neben `worker.js` sucht; die VM wird deshalb mit ausdrücklichem
Pfad ein zweites Mal gestartet.
Die Reachability-Metadaten stammen aus einem Lauf des Tracing-Agents über die
Pipeline; die PDFBox- und FontBox-Ressourcen sind als Globs ergänzt, sonst
scheitern PDFs an „Could not find referenced cmap stream Identity-H“.
Entfallen: WebMain, UploadHandler, StaticHandler, Multipart und die
systemd-Unit. Die nginx-Vorlage liefert jetzt statische Dateien aus, und die
Datenschutzseite sagt, was nun stimmt: Die Dateien verlassen den Rechner
nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I38faf2ac0f764d601f080d4276babe4747773683
Diffstat (limited to 'src/wasm')
| -rw-r--r-- | src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java | 155 | ||||
| -rw-r--r-- | src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java | 146 |
2 files changed, 301 insertions, 0 deletions
diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java new file mode 100644 index 0000000..c4f5a9a --- /dev/null +++ b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java @@ -0,0 +1,155 @@ +package eu.mulk.aendggner.wasm; + +import eu.mulk.aendggner.Pipeline; +import eu.mulk.aendggner.Quelle; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.function.Function; +import java.util.logging.LogManager; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.graalvm.webimage.api.JS; +import org.graalvm.webimage.api.JSNumber; +import org.graalvm.webimage.api.JSObject; +import org.graalvm.webimage.api.JSString; +import org.graalvm.webimage.api.JSValue; + +/** + * Einstiegspunkt der Browserfassung: stellt {@code globalThis.aendggnerSynopse} bereit und ruft + * damit dieselbe {@link Pipeline} auf wie Befehlszeile und Tests. + * + * <p>Erwartet ein JS-Objekt <code>{stamm: {name, base64}, patches: [{name, base64}], artikel, + * vollstaendig}</code> und liefert <code>{html, angewandt, manuell, normen}</code> oder + * <code>{fehler}</code> zurück. Geworfen wird nichts: Eine Ausnahme im Wasm hinterlässt auf der + * JS-Seite nur einen unlesbaren Stapel, also wird jeder Fehler als Text zurückgereicht. + * + * <p>Der Dateiinhalt wandert als Base64-Text über die Grenze, nicht als {@code Uint8Array}: Die + * Umsetzung typisierter Felder nach {@code byte[]} ist in Web Image derzeit defekt + * („byteArrayHub is not defined“). Zeichenketten überqueren die Grenze zuverlässig, und die + * Base64-Dekodierung ist reines Java. + */ +public final class BrowserMain { + + private BrowserMain() {} + + public static void main(String... args) { + // JULs Standardformatter ermittelt den Aufrufer über StackWalker, den Web Image nicht kennt; + // im Browser gibt es ohnehin kein Logdatei-Ziel. + LogManager.getLogManager().reset(); + Logger.getLogger("").setLevel(Level.OFF); + + exportiere(BrowserMain::synopse); + + // Die Erreichbarkeitsanalyse sieht nur Aufrufe aus Java; dass JavaScript die exportierte + // Funktion aufruft, weiß sie nicht — ohne diesen (nie durchlaufenen) Zweig bliebe die + // gesamte Pipeline aus dem Image heraus und der erste Aufruf endete in einem + // NoClassDefFoundError. + melde(); + } + + @JS(args = "fn", value = "globalThis.aendggnerSynopse = fn;") + private static native void exportiere(Function<JSObject, JSObject> fn); + + @JS( + value = + "if (typeof globalThis.aendggnerBereit === 'function') { globalThis.aendggnerBereit(); }") + private static native void melde(); + + private static JSObject synopse(JSObject eingabe) { + var antwort = JSObject.create(); + try { + var stamm = quelle(eingabe.get("stamm")); + var patches = new ArrayList<Quelle>(); + var patchListe = eingabe.get("patches"); + for (int i = 0; i < anzahl(patchListe); i++) { + patches.add(quelle(element(patchListe, i))); + } + + var artikel = text(eingabe.get("artikel")); + var vollstaendig = Boolean.TRUE.equals(wahrheitswert(eingabe.get("vollstaendig"))); + + var ergebnis = + Pipeline.erzeugeSynopse( + stamm, List.copyOf(patches), artikel == null || artikel.isBlank() ? null : artikel, + vollstaendig); + + // Java-Werte kämen auf der JS-Seite als undurchsichtige Proxys an; JSString/JSNumber + // erzeugen echte JS-Werte. + antwort.set("html", JSString.of(ergebnis.html())); + antwort.set("angewandt", JSNumber.of(ergebnis.anzahlAngewandt())); + antwort.set("manuell", JSNumber.of(ergebnis.anzahlManuell())); + antwort.set("normen", JSNumber.of(ergebnis.anzahlGeaenderteNormen())); + } catch (Throwable e) { + e.printStackTrace(); + var meldung = e.getMessage(); + antwort.set( + "fehler", + JSString.of( + e.getClass().getSimpleName() + + (meldung == null || meldung.isBlank() ? "" : ": " + meldung))); + } + return antwort; + } + + private static Quelle quelle(Object datei) { + if (!(datei instanceof JSObject objekt)) { + throw new IllegalArgumentException("Datei fehlt oder ist kein Objekt."); + } + var name = text(objekt.get("name")); + var base64 = text(objekt.get("base64")); + if (base64 == null || base64.isEmpty()) { + throw new IllegalArgumentException( + "Datei „" + (name == null ? "?" : name) + "“ enthält keine Daten."); + } + return new Quelle(name == null ? "Datei" : name, Base64.getDecoder().decode(base64)); + } + + /** + * Die Interop reicht JS-Werte je nach Typ als {@link JSValue} oder als bereits umgesetztes + * Java-Objekt herüber; die folgenden Helfer nehmen beides an, damit sich die Browserfassung nicht + * an einer Fassung der experimentellen Web-Image-API festmacht. + */ + private static String text(Object wert) { + if (wert == null) { + return null; + } + if (wert instanceof String s) { + return s; + } + if (wert instanceof JSValue v) { + return "undefined".equals(v.typeof()) ? null : v.asString(); + } + return wert.toString(); + } + + private static Boolean wahrheitswert(Object wert) { + if (wert instanceof Boolean b) { + return b; + } + if (wert instanceof JSValue v) { + return v.asBoolean(); + } + return Boolean.FALSE; + } + + private static int anzahl(Object liste) { + if (!(liste instanceof JSObject objekt)) { + return 0; + } + var laenge = objekt.get("length"); + if (laenge instanceof Number n) { + return n.intValue(); + } + if (laenge instanceof JSValue v) { + return v.asInt(); + } + return 0; + } + + private static Object element(Object liste, int index) { + var objekt = (JSObject) liste; + var wert = objekt.get(Integer.valueOf(index)); + return wert != null ? wert : objekt.get(String.valueOf(index)); + } +} diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java b/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java new file mode 100644 index 0000000..8817e4d --- /dev/null +++ b/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java @@ -0,0 +1,146 @@ +package eu.mulk.aendggner.wasm; + +import com.jcraft.jzlib.JZlib; +import com.oracle.svm.core.annotate.Inject; +import com.oracle.svm.core.annotate.RecomputeFieldValue; +import com.oracle.svm.core.annotate.Substitute; +import com.oracle.svm.core.annotate.TargetClass; +import java.util.zip.DataFormatException; + +/** + * Ersetzt java.util.zip.Inflater durch die reine Java-Umsetzung von jzlib. + * + * <p>Web Image kennt die nativen zlib-Bindungen des JDK nicht (GR-65205); ohne Inflate ist kein + * PDF lesbar, denn nahezu jeder Inhaltsstrom ist FlateDecode-komprimiert. + */ +@TargetClass(java.util.zip.Inflater.class) +final class Target_java_util_zip_Inflater { + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + com.jcraft.jzlib.Inflater impl; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean nowrap; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean fertig; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean braucheWoerterbuch; + + @Substitute + Target_java_util_zip_Inflater(boolean nowrap) { + this.nowrap = nowrap; + this.impl = new com.jcraft.jzlib.Inflater(); + this.impl.init(nowrap); + } + + @Substitute + Target_java_util_zip_Inflater() { + this(false); + } + + @Substitute + public void setInput(byte[] input, int off, int len) { + impl.next_in = input; + impl.next_in_index = off; + impl.avail_in = len; + } + + @Substitute + public void setInput(byte[] input) { + setInput(input, 0, input.length); + } + + @Substitute + public int inflate(byte[] output, int off, int len) throws DataFormatException { + impl.next_out = output; + impl.next_out_index = off; + impl.avail_out = len; + int err = impl.inflate(JZlib.Z_NO_FLUSH); + int erzeugt = len - impl.avail_out; + switch (err) { + case JZlib.Z_STREAM_END: + fertig = true; + return erzeugt; + case JZlib.Z_NEED_DICT: + braucheWoerterbuch = true; + return erzeugt; + case JZlib.Z_OK: + case JZlib.Z_BUF_ERROR: + return erzeugt; + default: + throw new DataFormatException(impl.msg == null ? "Inflate-Fehler " + err : impl.msg); + } + } + + @Substitute + public int inflate(byte[] output) throws DataFormatException { + return inflate(output, 0, output.length); + } + + @Substitute + public boolean needsInput() { + return impl.avail_in <= 0; + } + + @Substitute + public boolean needsDictionary() { + return braucheWoerterbuch; + } + + @Substitute + public boolean finished() { + return fertig; + } + + @Substitute + public int getRemaining() { + return Math.max(impl.avail_in, 0); + } + + @Substitute + public long getBytesRead() { + return impl.total_in; + } + + @Substitute + public long getBytesWritten() { + return impl.total_out; + } + + @Substitute + public int getTotalIn() { + return (int) impl.total_in; + } + + @Substitute + public int getTotalOut() { + return (int) impl.total_out; + } + + @Substitute + public void setDictionary(byte[] dictionary, int off, int len) { + var kopie = new byte[len]; + System.arraycopy(dictionary, off, kopie, 0, len); + impl.setDictionary(kopie, len); + braucheWoerterbuch = false; + } + + @Substitute + public void setDictionary(byte[] dictionary) { + setDictionary(dictionary, 0, dictionary.length); + } + + @Substitute + public void reset() { + impl.init(nowrap); + fertig = false; + braucheWoerterbuch = false; + } + + @Substitute + public void end() { + impl.end(); + } +} |
