nREPL im Browser: Wie ein Clojure-Server ohne JVM im Tab läuft

Makroaufnahme einer Festplatte mit Magnetscheibe und Schreib-Lesearm, metallische Reflexe
Deine Reaktion:

Ein Browser-Tab schickt einen bencode-kodierten Request an einen Server, der im selben Tab als Web Worker läuft, und bekommt eine Antwort mit dem Ergebnis eines ausgewerteten Clojure-Ausdrucks zurück. Auf nrepl.org ist das seit dem Relaunch keine Beschreibung einer Idee mehr, sondern ein Knopf. Wer dort die Schaltfläche „Try it“ drückt, sieht einen echten nREPL-Server arbeiten – ohne JVM, ohne Container, ohne Netzwerkverbindung. Damit wird ein Protokoll sichtbar, das sonst zwischen Editor und JVM im Hintergrund läuft.

Die Dokumentation ist nach docs.nrepl.org umgezogen, und nrepl.org selbst wurde zu einer Landingpage, die erklärt, was nREPL ist und wie sein Protokoll funktioniert. Der interessantere Teil sind die „Wire Windows“: kleine Fenster, die echte Ausschnitte aus dem Austausch zwischen Client und Server zeigen, einmal als EDN und einmal als bencode, also in der Form, in der die Bytes über die Leitung gehen. Damit lässt sich nachvollziehen, was ein eval-Request an Payload mitschleppt und was als Antwort zurückkommt. Man kann sich das wie eine Durchreiche zwischen zwei Räumen vorstellen: Auf der einen Seite wird ein Zettel hineingelegt, auf der anderen wird er gelesen und beantwortet, und normalerweise siehst du nur das Ergebnis. Die Wire Windows öffnen die Klappe und lassen dich beiden Seiten zusehen.

Warum ein Playground ohne Server im Netz auskommen muss

Ein interaktiver nREPL Playground braucht einen Server. Ein Server, der Code von beliebigen Leuten aus dem Internet auswertet, ist aber nichts, was man gern hostet. Dazu kostet so etwas Geld, das der Autor lieber anders ausgegeben hätte. Also musste der Server in den Browser wandern. Der Entwickler beschreibt drei Wege, die er prüfte, bevor er sich für einen vierten entschied.

Der erste war das Original: CheerpJ, eine JVM im Browser. nREPL 1.8 und Clojure 1.12 liefen darauf laut seiner Beschreibung gut, inklusive stdin, und jeder Op, den er ausprobierte, funktionierte. Der Preis war das Drumherum – rund acht Sekunden bis zum einsatzbereiten Server auf einer schnellen Maschine, etwa 4,5 MB Download, eine Busy-Loop, die die ganze Seite einfriert, wenn die JVM nicht in einen Web Worker wandert, und ein interrupt, das nur bei schlafendem Code greift. Für jemanden, der gerade erst eine Landingpage geöffnet hat, ist das zu viel.

Der zweite Kandidat war scittle, das SCI im Browser ausführt und mit sci.nrepl einen nREPL-Server mitbringt. Allerdings löst es ein anderes Problem: Es erlaubt einem Editor, Code in eine Seite zu schicken, und zwar über einen Relay, der in babashka oder auf der JVM läuft. Seine Antworten weichen an einigen Stellen von nREPL ab, dazu hat der Autor Issues angelegt. nbb wiederum hat einen recht vollständigen nREPL-Server, läuft aber nur unter Node. Keiner der drei Wege passte, also schrieb er einen kleinen nREPL-Server für SCI, der vollständig in einer Webseite läuft und die Seite selbst als Client behandelt: nrepl-sci.

SCI übernimmt die Arbeit, ClojureScript den Rest

Die eigentliche Auswertung musste niemand neu erfinden. SCI, Michiel Borkents Clojure-Interpreter, erledigt das, und der nREPL-Server darüber ist klein: Auswertung, Completion und Doku-Lookup zusammen kamen auf ein paar hundert Zeilen ClojureScript. Das Gesamtpaket liegt bei etwa 230 KB gzipped und ist in Bruchteilen einer Sekunde bereit. Wenn du Clojure-Code im Browser auswerten willst, reicht ein Interpreter im Tab und eine dünne Schicht, die das Protokoll spricht.

Ein einfacher nREPL Server ist denn auch schnell gebaut. Die Schwierigkeit steckt in zwei Ops, über die die meisten nie nachdenken: interrupt und stdin. JavaScript gibt dir einen Thread, und SCI wertet Code in einem Zug aus. Während (loop [] (recur)) läuft, läuft nichts anderes – der Server kann nicht einmal den interrupt-Request lesen, der den Loop stoppen würde. Und Code, der read-line aufruft, kann nicht auf Eingabe warten, ohne genau den Thread zu blockieren, der diese Eingabe entgegennehmen müsste.

Zwei Web Worker, ein Briefkasten und ein Hook alle 256 Aufrufe

Die Lösung von nrepl-sci ist eine Aufteilung in zwei Web Worker. Ein Supervisor nimmt die Requests an, stellt sie in eine Queue und beantwortet interrupt und stdin selbst, während ein Evaluator SCI ausführt. Die beiden teilen sich etwas Speicher in Form eines SharedArrayBuffer, der als Briefkasten dient – die Durchreiche aus dem ersten Abschnitt, nur diesmal zwischen zwei Threads. Der Evaluator wartet mit Atomics.wait auf Post, und das blockiert einen Worker, ohne zu drehen, sodass read-line auf seine Eingabe warten kann, ähnlich wie auf der JVM.

Damit der Evaluator überhaupt merkt, dass jemand anklopft, nutzt nrepl-sci einen Hook, den SCI bei jedem Funktionsaufruf und jedem recur auslöst. Der Server schaut dabei in den Briefkasten: Wurde ich unterbrochen? Liegt ein Request einer anderen Session vor, den ich zwischendurch erledigen könnte? Bei jedem Aufruf nachzusehen machte den Code spürbar langsamer, deshalb prüft er nur bei jedem 256. Aufruf – was immer noch alle paar Mikrosekunden bedeutet. Die Benchmarks liegen laut dem Autor etwa auf dem Niveau ohne diese Mechanik. Unendliche Sequenzen wie (range) oder (iterate inc 0) prüfen beim Durchlaufen mit, weshalb (count (range)) stehen bleibt, wenn du es unterbrichst.

Bleibt Code, der nie wieder in SCI zurückruft – ein regulärer Ausdruck, der endlos backtrackt, zum Beispiel. Für diesen Fall gibt der Supervisor eine Sekunde Gnadenfrist und killt dann den Evaluator, um einen neuen zu starten. Die Sessions überleben das, aber alles, was darin definiert wurde, ist weg. Supervisor, Evaluator und Briefkasten brauchten mehr Code als der nREPL-Server selbst, was zeigt, wo die Komplexität in diesem Projekt liegt.

Wichtig ist eine Randbedingung: All das funktioniert nur, wenn die Seite cross-origin isoliert ist, weil Browser SharedArrayBuffer nur an Seiten herausgeben, die mit den Headern Cross-Origin-Opener-Policy und Cross-Origin-Embedder-Policy ausgeliefert werden. GitHub Pages, wo sowohl nrepl.org als auch dieser Blog liegen, kann diese Header nicht setzen. Ohne sie fällt nrepl-sci auf Nacheinander zurück: read-line wirft einen Fehler, und ein Interrupt bedeutet Neustart. Genau das passiert, wenn du im Playground (range) ausprobierst. Auswertung, Ausgabe, Fehler, Completion und doc verhalten sich in beiden Modi gleich.

proof: Protokolltests statt Vermutungen

Im Jahr 2023 hatte der Autor das Fehlen einer Testsuite für Protokollkompatibilität unter den Dingen aufgelistet, die Leute an nREPL stören, und gefragt, ob sich jemand für ein kleines OSS-Projekt erwärmen könnte. Am Ende hat er es selbst gemacht und proof gestartet. nrepl-sci ist der erste Server, den er von Anfang an mit proof geschrieben hat, und das habe einen großen Unterschied gemacht. Früher bedeutete es viel Raten, einen nREPL-Server zu bauen: Das Protokoll ist klein, aber was Clients tatsächlich von einem Server erwarten, verteilt sich über deren Quellcode.

Ein proof-Profil ist eine kurze TOML-Datei, die proof sagt, wie der Server zu starten ist und wie man ein paar Dinge in seiner Sprache ausdrückt – ein Wert, etwas Output, eine Exception, das Lesen einer Zeile, Code, der läuft, bis man ihn stoppt. proof run erledigt den Rest, mit Prüfungen für das Protokoll und für die konkreten Requests, die CIDER, Calva, Conjure, vim-fireplace, REPLy und neorepl senden. Wenn eine Prüfung fehlschlägt, erklärt proof auch, warum sie relevant ist – etwa dass ein Client, der eine lange Auswertung abbricht, eine benutzbare Session zurückerwartet und keine, in der noch Code hängt. Heute besteht nrepl-sci alle 73 Prüfungen, die für es gelten; die übrigen fünf betreffen Dinge wie vim-fireplace, das Java braucht. Ohne Shared Memory fallen genau zwei durch: stdin.need-input und robust.sessions-at-once – eine gute Zusammenfassung dessen, was man ohne Cross-Origin-Isolation aufgibt.

Was das Ganze kostet – und wie du es selbst einbaust

Nichts davon ist umsonst. Es ist SCI und nicht Clojure, also ClojureScript in einem Interpreter: keine Java-Klassen, keine JVM-Bibliotheken, und einige Ecken der Sprache verhalten sich anders. Für einen Playground ist das in Ordnung, für dein echtes Projekt nicht. Auch JavaScript-Interop fehlt, SCI blockiert sie, und nrepl-sci erlaubt sie nicht – (js/alert "hi") und selbst (.toUpperCase "hi") scheitern, während clojure.string/upper-case funktioniert. Das ist etwas lästig, macht den Server aber sicher einbettbar: Der getippte Code kommt weder an die Seite noch an den Worker heran.

Dazu kommen feinere Einschränkungen. Ein Request aus einer anderen Session läuft oben auf dem unterbrochenen Code, der kann also nicht stoppen, bis der Request durch ist, und ein zwischendurch genommenes read-line hält ihn fest, bis die Eingabe kommt. Das fällt nur auf, wenn zwei Clients um denselben Server streiten. Ein Neustart verwirft alle Definitionen, was ohne Cross-Origin-Isolation am meisten schmerzt, weil dort jeder Interrupt neu startet. Und es sind rund 1 MB JavaScript, 230 KB gzipped, geladen beim ersten Aufruf des REPL.

Einbauen lässt sich nrepl-sci trotzdem einfach, und zwar unabhängig von nrepl.org. Der Worker nimmt das bencode jedes Requests als Uint8Array entgegen und schickt das bencode jeder Antwort zurück; encode und decode sind ein paar Dutzend Zeilen. Das REPL im Beitrag des Autors umfasst rund 170 Zeilen inklusive Styles. Du holst dir nrepl-sci-worker.js aus dem letzten Release und servierst es von deiner eigenen Domain, weil Browser Worker nur vom selben Origin starten. Wenn du die Isolations-Header senden kannst, bekommst du stdin und saubere Interrupts dazu. Für Client-Tests gibt es außerdem einen Node-Build, der als normaler nREPL Server auf einem Socket läuft (node nrepl-sci.js --port 7888) und in etwa einer Zehntelsekunde bereit ist – praktisch für die Testsuites von Editoren und Plugins. Und wenn du einen nREPL Server für eine andere Single-Thread-Sprache baust, sind Supervisor und Briefkasten ein übertragbares Muster: Der Server selbst weiß nichts von Threads oder Sockets, und der Trick funktioniert mit jedem Interpreter, der ab und zu zurückruft.

Was nREPL im Browser konkret bedeutet

Als Nächstes will der Autor Cross-Origin-Isolation auf nrepl.org bringen. Ein Service Worker kann die Header nachrüsten, die GitHub Pages nicht sendet, um den Preis eines Reloads bei der ersten Installation – vielleicht probiert er das auf einer eigenen Seite aus. Außerdem möchte er etwas harmlose Interop erlauben, damit (.toUpperCase "hi") wie erwartet funktioniert, und nrepl-sci auf npm veröffentlichen. Das sind kleine Schritte.

Du kannst einen nREPL Server im Browser ausprobieren, ohne eine JVM zu installieren, und du kannst das Protokoll Stück für Stück verstehen, indem du zuschaust, was tatsächlich über die Durchreiche geschoben wird. Ein Playground bleibt ein Playground – SCI ersetzt keine Clojure-Umgebung, und ein Interrupt, der alles verwirft, ist kein Ersatz für echte Sessions. Als Einstieg, als Lehrmittel und als Testumgebung für Clients ist das trotzdem brauchbar. Der Autor erinnert selbst daran, dass er 2018 von einem großen Traum schrieb, nREPL nach ClojureScript zu portieren; nrepl-sci ist nicht dieser Traum, sondern ein kleinerer Server für SCI – aber ein Schritt in die Richtung. Der Playground steht auf nrepl.org.

Quelle: metaredux.com

Deine Reaktion:
Artikel teilen:
Krötzsch-Check0 — 100
Fakten 85
Relevanz 70
Hype 25
Einschätzung 75
Redaktion 50 Stand 50 · noch keine Stimmen
Ist das Hype?
Sebastian Krötzsch
Autor

Sebastian Krötzsch

Sebastian Krötzsch schreibt auf sebask.de über Künstliche Intelligenz, Automatisierung, digitale Systeme und die Frage, was davon im Alltag wirklich nützlich ist. Ohne Buzzword-Nebel, dafür mit klarem Blick auf Praxis, Tools und echte Wirkung.