aboutsummaryrefslogtreecommitdiff
path: root/src/wasm/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-15 10:51:11 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-15 10:51:11 +0200
commit2b1841e17533543d1cb15220ca86a2a37c853daf (patch)
tree01b940cb06c5abc36704d2d4853343ee7b38ce02 /src/wasm/java
parent5700bbcb89186e432c5c7dd232b61c052de8e784 (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/java')
-rw-r--r--src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java155
-rw-r--r--src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java146
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();
+ }
+}