diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90c1bfd..dafa728 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,11 @@ jobs: with: lein: latest + - name: Set up Node (native WebSocket runtime for the integration test) + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Cache Maven dependencies uses: actions/cache@v4 with: @@ -36,5 +41,8 @@ jobs: - name: Run tests run: lein test - - name: Compile ClojureScript client - run: lein cljsbuild once smoke + - name: Compile the ClojureScript client (browser + Node builds) + run: lein cljsbuild once smoke node + + - name: Round-trip the REPL over a native WebSocket + run: lein run -m weasel.integration diff --git a/CHANGES.md b/CHANGES.md index 05095ba..7b7b454 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,11 +6,24 @@ * Bump the minimum dependencies to Clojure 1.12 and ClojureScript 1.12. * Bump `http-kit` to 2.8.1 and (dev-only) `piggieback` to 0.6.0. +* The REPL client now talks over the platform's native `WebSocket` instead of + the legacy `goog.net.WebSocket` / `clojure.browser.net` machinery. As a + result the client runs in any modern JavaScript runtime (browsers, Node 22+, + Deno, Bun, web/service workers), not just the browser. Two consequences worth + calling out: + * The `weasel.impls.websocket` namespace, previously a `goog.net`-based + protocol implementation, is now a small functional wrapper. Code that + depended on its old vars (`websocket-connection`, the `IConnection` + protocol, etc.) needs updating. + * The objects passed to the `:on-error` and `:on-close` callbacks are now + native `WebSocket` events rather than `goog.net.WebSocket` events. ### Enhancements * Ship a `deps.edn` so the library can be consumed via the Clojure CLI / tools.deps. -* Add a GitHub Actions CI pipeline and a basic test suite. +* Add a GitHub Actions CI pipeline and a basic test suite, including a Node + round-trip integration test that exercises the full eval cycle over a real + WebSocket. ## 0.7.0 diff --git a/dev/weasel/integration.clj b/dev/weasel/integration.clj new file mode 100644 index 0000000..3b985e8 --- /dev/null +++ b/dev/weasel/integration.clj @@ -0,0 +1,59 @@ +(ns weasel.integration + "End-to-end check of the native-WebSocket transport. + + Starts a real Weasel server, launches the compiled ClojureScript client + under Node and drives a single round-trip that exercises both the result + and the print paths: the evaluated snippet prints a string and returns a + value, and we assert both travel back over the socket. Run with: + + lein cljsbuild once node + lein run -m weasel.integration" + (:require [clojure.edn :as edn] + [weasel.repl.server :as server])) + +(def ^:private client-js "target/node/weasel_node_client.js") + +(defn- await! + "Derefs promise `p`, throwing if it does not resolve within 10 seconds." + [what p] + (let [v (deref p 10000 ::timeout)] + (when (= v ::timeout) + (throw (ex-info (str "timed out waiting for " what) {}))) + v)) + +(defn -main [& _] + (let [ready (promise) + printed (promise) + result (promise) + port 9009 + ok? (atom false)] + (server/start + (fn [data] + (let [msg (edn/read-string data)] + (case (:op msg) + :ready (deliver ready true) + :print (deliver printed (:value msg)) + :result (deliver result (:value msg)) + nil))) + :ip "127.0.0.1" :port port) + (let [^"[Ljava.lang.String;" cmd (into-array String ["node" client-js (str "ws://127.0.0.1:" port)]) + proc (-> (ProcessBuilder. cmd) (.inheritIO) (.start))] + (try + (server/wait-for-client) + (await! ":ready" ready) + (server/send! + (pr-str {:op :eval-js + :code "(function () { weasel.repl.repl_print('hi from node'); return 40 + 2; })()"})) + (let [value (:value (await! ":result" result)) + out (await! ":print" printed)] + (println "RESULT:" (pr-str value) " PRINT:" (pr-str out)) + (reset! ok? (and (= value "42") + (= out (pr-str "hi from node"))))) + (catch Exception e + (println "FAIL:" (.getMessage e))) + (finally + (.destroy proc) + (server/stop)))) + (if @ok? + (println "PASS") + (do (println "FAIL") (System/exit 1))))) diff --git a/project.clj b/project.clj index 5bf767c..a6a265c 100644 --- a/project.clj +++ b/project.clj @@ -24,7 +24,15 @@ :test-paths ["test/clj"] :profiles {:dev {:dependencies [[cider/piggieback "0.6.0"]] :plugins [[lein-cljsbuild "1.1.8"]] + :source-paths ["dev"] :cljsbuild {:builds [{:id "smoke" :source-paths ["src/cljs"] :compiler {:output-to "target/weasel-smoke.js" - :optimizations :advanced}}]}}}) + :optimizations :advanced}} + {:id "node" + :source-paths ["src/cljs" "test/cljs"] + :compiler {:output-to "target/node/weasel_node_client.js" + :output-dir "target/node" + :target :nodejs + :main weasel.node-client + :optimizations :none}}]}}}) diff --git a/src/cljs/weasel/impls/websocket.cljs b/src/cljs/weasel/impls/websocket.cljs index 3fb0a52..a19bd67 100644 --- a/src/cljs/weasel/impls/websocket.cljs +++ b/src/cljs/weasel/impls/websocket.cljs @@ -1,51 +1,59 @@ -;; Copyright (c) Rich Hickey. All rights reserved. -;; The use and distribution terms for this software are covered by the -;; Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) -;; which can be found in the file epl-v10.html at the root of this distribution. -;; By using this software in any fashion, you are agreeing to be bound by -;; the terms of this license. -;; You must not remove this notice, or any other, from this software. - (ns weasel.impls.websocket - (:require [clojure.browser.net :as net :refer [IConnection connect transmit]] - [clojure.browser.event :as event :refer [event-types]] - [goog.net.WebSocket :as gwebsocket])) - -(defprotocol IWebSocket - (open? [this])) - -(defn websocket-connection - ([] - (websocket-connection nil nil)) - ([auto-reconnect?] - (websocket-connection auto-reconnect? nil)) - ([auto-reconnect? next-reconnect-fn] - (goog.net.WebSocket. auto-reconnect? next-reconnect-fn))) - -(extend-type goog.net.WebSocket - IWebSocket - (open? [this] - (.isOpen this ())) - - net/IConnection - (connect - ([this url] - (connect this url nil)) - ([this url protocol] - (.open this url protocol))) - - (transmit [this message] - (.send this message)) - - (close [this] - (.close this ())) - - event/IEventType - (event-types [this] - (into {} - (map - (fn [[k v]] - [(keyword (. k (toLowerCase))) - v]) - (merge - (js->clj goog.net.WebSocket/EventType)))))) + "A thin wrapper over the platform's native `WebSocket` object. + + Every modern JavaScript runtime - browsers, Node 21+, Deno, Bun, web and + service workers, React Native - exposes a global `WebSocket`, so the REPL + client no longer needs the legacy `goog.net.WebSocket` / + `clojure.browser.net` machinery to talk to the server.") + +(defn available? + "Returns true when the runtime exposes a native `WebSocket` constructor." + [] + (exists? js/WebSocket)) + +(defn open? + "Returns true when `socket` is connected and ready to send." + [socket] + (and (some? socket) + (== (.-readyState socket) (.-OPEN js/WebSocket)))) + +(defn send! + "Sends `message` (a string) over `socket`, but only while the socket is + open. A native `WebSocket` throws when `send` is called in any other state, + so anything emitted before the connection opens or after it closes is + silently dropped." + [socket message] + (when (open? socket) + (.send socket message))) + +(defn close! + "Closes `socket`." + [socket] + (.close socket)) + +(defn connect! + "Opens a `WebSocket` to `url` and wires the supplied handlers. + + `handlers` is a map of optional callbacks: + + :on-open (fn [socket] ...) called once the socket is open + :on-message (fn [socket string] ...) called with each received text frame + :on-error (fn [event] ...) called on a transport error + :on-close (fn [event] ...) called when the socket closes + + The send-side handlers receive the socket itself so they always reply over + the connection that fired the event, never a newer one. Returns the freshly + created `WebSocket`." + [url {:keys [on-open on-message on-error on-close]}] + (when-not (available?) + (throw (js/Error. "This JavaScript runtime does not provide a WebSocket implementation"))) + (let [socket (js/WebSocket. url)] + (when on-open + (set! (.-onopen socket) (fn [_] (on-open socket)))) + (when on-message + (set! (.-onmessage socket) (fn [e] (on-message socket (.-data e))))) + (when on-error + (set! (.-onerror socket) (fn [e] (on-error e)))) + (when on-close + (set! (.-onclose socket) (fn [e] (on-close e)))) + socket)) diff --git a/src/cljs/weasel/repl.cljs b/src/cljs/weasel/repl.cljs index 45ef8e7..c33fe87 100644 --- a/src/cljs/weasel/repl.cljs +++ b/src/cljs/weasel/repl.cljs @@ -1,17 +1,21 @@ (ns weasel.repl - (:require [goog.dom :as gdom] - [clojure.browser.event :as event :refer [event-types]] - [clojure.browser.net :as net] - [clojure.browser.repl :as brepl] + (:require [clojure.browser.repl :as brepl] [cljs.reader :as reader :refer [read-string]] [weasel.impls.websocket :as ws])) (def ^:private ws-connection (atom nil)) -(defn alive? [] +(defn alive? "Returns truthy value if the REPL is attempting to connect or is connected, or falsy value otherwise." - (not (nil? @ws-connection))) + [] + (some? @ws-connection)) + +(defn- browser? + "Returns true when running in a browser-like environment that can load + additional code via Closure's script-tag mechanism." + [] + (exists? js/document)) (defmulti process-message :op) @@ -40,8 +44,8 @@ (defn repl-print [& args] - (if-let [conn @ws-connection] - (net/transmit @ws-connection {:op :print :value (apply pr-str args)}))) + (when-let [conn @ws-connection] + (ws/send! conn (pr-str {:op :print :value (apply pr-str args)})))) (defn console-print [& args] (.apply (.-log js/console) js/console (into-array args))) @@ -56,33 +60,36 @@ (defn connect [repl-server-url & {:keys [verbose on-open on-error on-close print] :or {verbose true, print :repl}}] - (let [repl-connection (ws/websocket-connection)] - (swap! ws-connection (constantly repl-connection)) - (event/listen repl-connection :opened - (fn [evt] - (set-print-fn! (if (fn? print) print (get print-fns print))) - (net/transmit repl-connection (pr-str {:op :ready})) - (when verbose (.info js/console "Opened Websocket REPL connection")) - (when (fn? on-open) (on-open)))) - - (event/listen repl-connection :message - (fn [evt] - (let [{:keys [op] :as message} (read-string (.-message evt)) - response (-> message process-message pr-str)] - (net/transmit repl-connection response)))) - - (event/listen repl-connection :closed - (fn [evt] - (reset! ws-connection nil) - (when verbose (.info js/console "Closed Websocket REPL connection")) - (when (fn? on-close) (on-close)))) - - (event/listen repl-connection :error - (fn [evt] - (when verbose (.error js/console "WebSocket error" evt)) - (when (fn? on-error) (on-error evt)))) - - ;; reusable bootstrap - (brepl/bootstrap) - - (net/connect repl-connection repl-server-url))) + (let [repl-connection + (ws/connect! repl-server-url + {:on-open + (fn [socket] + (set-print-fn! (if (fn? print) print (get print-fns print))) + (ws/send! socket (pr-str {:op :ready})) + (when verbose (.info js/console "Opened Websocket REPL connection")) + (when (fn? on-open) (on-open))) + + :on-message + (fn [socket data] + (let [message (read-string data) + response (-> message process-message pr-str)] + (ws/send! socket response))) + + :on-close + (fn [_] + (reset! ws-connection nil) + (when verbose (.info js/console "Closed Websocket REPL connection")) + (when (fn? on-close) (on-close))) + + :on-error + (fn [evt] + (when verbose (.error js/console "WebSocket error" evt)) + (when (fn? on-error) (on-error evt)))})] + + (reset! ws-connection repl-connection) + + ;; reusable bootstrap - only meaningful in a browser, where new code is + ;; loaded by appending script tags to the document + (when (browser?) (brepl/bootstrap)) + + repl-connection)) diff --git a/test/cljs/weasel/node_client.cljs b/test/cljs/weasel/node_client.cljs new file mode 100644 index 0000000..38c4652 --- /dev/null +++ b/test/cljs/weasel/node_client.cljs @@ -0,0 +1,10 @@ +(ns weasel.node-client + "A tiny Node entry point used by the integration test to prove that the + REPL client works outside the browser, on a native `WebSocket`." + (:require [weasel.repl :as repl])) + +(defn -main [& args] + (let [url (or (first args) "ws://127.0.0.1:9001")] + (repl/connect url :verbose false))) + +(set! *main-cli-fn* -main)