Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
15 changes: 14 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
59 changes: 59 additions & 0 deletions dev/weasel/integration.clj
Original file line number Diff line number Diff line change
@@ -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)))))
10 changes: 9 additions & 1 deletion project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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}}]}}})
108 changes: 58 additions & 50 deletions src/cljs/weasel/impls/websocket.cljs
Original file line number Diff line number Diff line change
@@ -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))
83 changes: 45 additions & 38 deletions src/cljs/weasel/repl.cljs
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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)))
Expand All @@ -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))
10 changes: 10 additions & 0 deletions test/cljs/weasel/node_client.cljs
Original file line number Diff line number Diff line change
@@ -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)
Loading