nandi/frqpublic Fork 0
30d5fae
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

A browser has no socket, and three places that assumed one did

`frq.net` is the second seam: `frq.net.dart` opens a SecureSocket and cannot
exist on the web, `frq.net.web` opens a WebSocket and cannot exist anywhere
else. freeq already speaks it — `freeq-server/src/web.rs` bridges `/irc` to
the same IRC handler a TCP client reaches — so `wss://irc.freeq.at/irc` is
the same server and not a second one.

Simpler than the dart:io backend, because the bridge does the framing: it
splits on CRLF and sends one line per text frame, and appends CRLF to every
frame it receives. So a frame is a line in both directions, there is no
buffer here at all, and `send-line!` must send the line bare — the bridge
appends CRLF to a text frame unconditionally.

Three dart:io assumptions went with it. `Platform.isAndroid` throws
`Unsupported operation` in a browser, and it was being asked inside build,
where what it leaves is the grey of an app that reached its first frame and
no further; `defaultTargetPlatform` answers on all three targets and is the
better question anyway. `frq.debug` catches `Object` and not `Exception`,
because an Error is not an Exception in Dart — that one escaped an event
handler and left the connect screen spinning forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-17T00:56:35-07:00 Browse files
30d5fae parent: f54ca45
modified flutter/src/frq/debug.cljd +15 -2
@@ -28,8 +28,21 @@
2828 (defonce ^:private sink
2929 ;; Read once. An env var does not change under a running app, and this is
3030 ;; called from inside build and post-frame callbacks.
31- (delay (let [p (get (.-environment io/Platform) "FRQ_JOT")]
32- (when (and p (seq p)) p))))
31+ ;; try/catch and not a platform test: a browser has no environment at all,
32+ ;; and `Platform.environment` throws there rather than answering with an
33+ ;; empty map. Nothing is listening on the web, which is the honest answer --
34+ ;; and a diagnostic that took the app down with it would be the worst of the
35+ ;; three targets' outcomes.
36+ ;;
37+ ;; `Object` and not `Exception`, which is the whole of why this needed a
38+ ;; second go: what dart:io throws off-platform is `UnsupportedError`, and in
39+ ;; Dart an Error is not an Exception -- `on Exception catch` does not see it.
40+ ;; It escaped from the WebSocket's open handler, took the handler with it,
41+ ;; and left the connect screen spinning at "Connecting…" forever.
42+ (delay (try
43+ (let [p (get (.-environment io/Platform) "FRQ_JOT")]
44+ (when (and p (seq p)) p))
45+ (catch Object _ nil))))
3346
3447 (defn on?
3548 "Whether anything is listening, for a caller with expensive diagnostics to
@@ -28,8 +28,21 @@
28 (defonce ^:private sink28 (defonce ^:private sink
29 ;; Read once. An env var does not change under a running app, and this is29 ;; Read once. An env var does not change under a running app, and this is
30 ;; called from inside build and post-frame callbacks.30 ;; called from inside build and post-frame callbacks.
31- (delay (let [p (get (.-environment io/Platform) "FRQ_JOT")]31+ ;; try/catch and not a platform test: a browser has no environment at all,
32- (when (and p (seq p)) p))))32+ ;; and `Platform.environment` throws there rather than answering with an
33+ ;; empty map. Nothing is listening on the web, which is the honest answer --
34+ ;; and a diagnostic that took the app down with it would be the worst of the
35+ ;; three targets' outcomes.
36+ ;;
37+ ;; `Object` and not `Exception`, which is the whole of why this needed a
38+ ;; second go: what dart:io throws off-platform is `UnsupportedError`, and in
39+ ;; Dart an Error is not an Exception -- `on Exception catch` does not see it.
40+ ;; It escaped from the WebSocket's open handler, took the handler with it,
41+ ;; and left the connect screen spinning at "Connecting…" forever.
42+ (delay (try
43+ (let [p (get (.-environment io/Platform) "FRQ_JOT")]
44+ (when (and p (seq p)) p))
45+ (catch Object _ nil))))
33 46
34 (defn on?47 (defn on?
35 "Whether anything is listening, for a caller with expensive diagnostics to48 "Whether anything is listening, for a caller with expensive diagnostics to
modified flutter/src/frq/hiccup.cljd +15 -2
@@ -26,6 +26,10 @@
2626 read it fine at this size, and the thing to revisit if a message list ever
2727 feels it."
2828 (:require ["dart:io" :as io]
29+ ;; `defaultTargetPlatform` lives here and is NOT re-exported by
30+ ;; material: widgets.dart re-exports foundation behind a `show`
31+ ;; list that carries `TargetPlatform` and not the getter beside it.
32+ ["package:flutter/foundation.dart" :as fnd]
2933 ["package:flutter/gestures.dart" :as g]
3034 ["package:flutter/material.dart" :as m]
3135 [cljd.flutter :as f]
@@ -54,9 +58,18 @@
5458
5559 `frq.screens.*` asks the host the same question as `actions/desktop?`, and
5660 gets its answer from the same place; this is the renderer's side of it, for
57- the parts of a hover that never reach the shared screens at all."
61+ the parts of a hover that never reach the shared screens at all.
62+
63+ `defaultTargetPlatform` and not `Platform.isAndroid`, since the web target
64+ arrived: `dart:io`'s `Platform` throws `Unsupported operation:
65+ Platform._operatingSystem` in a browser, and it threw here — inside build,
66+ where what it leaves is the grey of an app that got as far as its first
67+ frame and no further. Flutter's own answer works on all three targets and
68+ is the better question anyway: on the web it reports the machine the browser
69+ is running on, so a phone browser gets a finger and a desktop one a mouse,
70+ which is what this is actually asking."
5871 []
59- (not (.-isAndroid io/Platform)))
72+ (not= fnd/TargetPlatform.android fnd/defaultTargetPlatform))
6073
6174 (defn- hovered
6275 "`w`, told when the pointer arrives and when it leaves.
@@ -26,6 +26,10 @@
26 read it fine at this size, and the thing to revisit if a message list ever26 read it fine at this size, and the thing to revisit if a message list ever
27 feels it."27 feels it."
28 (:require ["dart:io" :as io]28 (:require ["dart:io" :as io]
29+ ;; `defaultTargetPlatform` lives here and is NOT re-exported by
30+ ;; material: widgets.dart re-exports foundation behind a `show`
31+ ;; list that carries `TargetPlatform` and not the getter beside it.
32+ ["package:flutter/foundation.dart" :as fnd]
29 ["package:flutter/gestures.dart" :as g]33 ["package:flutter/gestures.dart" :as g]
30 ["package:flutter/material.dart" :as m]34 ["package:flutter/material.dart" :as m]
31 [cljd.flutter :as f]35 [cljd.flutter :as f]
@@ -54,9 +58,18 @@
54 58
55 `frq.screens.*` asks the host the same question as `actions/desktop?`, and59 `frq.screens.*` asks the host the same question as `actions/desktop?`, and
56 gets its answer from the same place; this is the renderer's side of it, for60 gets its answer from the same place; this is the renderer's side of it, for
57- the parts of a hover that never reach the shared screens at all."61+ the parts of a hover that never reach the shared screens at all.
62+
63+ `defaultTargetPlatform` and not `Platform.isAndroid`, since the web target
64+ arrived: `dart:io`'s `Platform` throws `Unsupported operation:
65+ Platform._operatingSystem` in a browser, and it threw here — inside build,
66+ where what it leaves is the grey of an app that got as far as its first
67+ frame and no further. Flutter's own answer works on all three targets and
68+ is the better question anyway: on the web it reports the machine the browser
69+ is running on, so a phone browser gets a finger and a desktop one a mouse,
70+ which is what this is actually asking."
58 []71 []
59- (not (.-isAndroid io/Platform)))72+ (not= fnd/TargetPlatform.android fnd/defaultTargetPlatform))
60 73
61 (defn- hovered74 (defn- hovered
62 "`w`, told when the pointer arrives and when it leaves.75 "`w`, told when the pointer arrives and when it leaves.
added flutter/src/frq/net.cljd +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+(ns frq.net
2+ "What an IRC connection is, named once so both Flutter targets can answer it.
3+
4+ `frq.io`'s shape, for the same reason and one floor down: a browser has no
5+ TCP. `frq.net.dart` opens a `SecureSocket` and cannot exist on the web;
6+ `frq.net.web` opens a `WebSocket` and cannot exist anywhere else, because
7+ `dart:html` is not a library the Android and Linux targets have. So the
8+ caller asks for a connection and the backend that installed itself answers.
9+
10+ Flutter-only, and so under `flutter/src` rather than in `common/`: jolt has
11+ its own socket under `src/frq/irc`, and nothing compiled by both compilers
12+ calls this. `frq.io` is in `common/` because the shared screens ask it for
13+ the filesystem; nothing shared asks anyone for a socket.
14+
15+ What a `sock` is belongs to the backend — a `Socket` on one side, a
16+ `WebSocket` on the other — and the caller only ever hands it back.")
17+
18+(defonce ^:private impl
19+ ;; Keyword fn. Empty until a backend installs, which the entry point does
20+ ;; beside `frq.io`'s: see `frq.main` and `frq.main-web`.
21+ (atom {}))
22+
23+(defn install!
24+ "Register the transport. Called once, by the entry point, before anything
25+ connects."
26+ [m]
27+ (swap! impl merge m)
28+ nil)
29+
30+(defn installed? [] (boolean (seq @impl)))
31+
32+(defn ^:async connect!
33+ "Open a connection and start reading it.
34+
35+ `on-msg` is called with a parsed map per line; `on-close` with a reason, or
36+ nil where the peer simply went away. Returns the socket, which `send-line!`
37+ and `close!` take.
38+
39+ Awaited here rather than handed back as a Future for the caller to await:
40+ what comes out of the map is dynamic, and cljd cannot see that a dynamic
41+ call returns something awaitable. One `^:async` on this side gives the
42+ caller a Future it can see the shape of."
43+ [opts]
44+ (if-let [f (:connect! @impl)]
45+ (await (f opts))
46+ (throw (ex-info "frq.net: no transport installed" {:op :connect!}))))
47+
48+(defn send-line! [sock line]
49+ (if-let [f (:send-line! @impl)]
50+ (f sock line)
51+ (throw (ex-info "frq.net: no transport installed" {:op :send-line!}))))
52+
53+(defn close! [sock]
54+ (when-let [f (:close! @impl)]
55+ (f sock)))
new file mode 100644
@@ -0,0 +1,55 @@
1+(ns frq.net
2+ "What an IRC connection is, named once so both Flutter targets can answer it.
3+
4+ `frq.io`'s shape, for the same reason and one floor down: a browser has no
5+ TCP. `frq.net.dart` opens a `SecureSocket` and cannot exist on the web;
6+ `frq.net.web` opens a `WebSocket` and cannot exist anywhere else, because
7+ `dart:html` is not a library the Android and Linux targets have. So the
8+ caller asks for a connection and the backend that installed itself answers.
9+
10+ Flutter-only, and so under `flutter/src` rather than in `common/`: jolt has
11+ its own socket under `src/frq/irc`, and nothing compiled by both compilers
12+ calls this. `frq.io` is in `common/` because the shared screens ask it for
13+ the filesystem; nothing shared asks anyone for a socket.
14+
15+ What a `sock` is belongs to the backend — a `Socket` on one side, a
16+ `WebSocket` on the other — and the caller only ever hands it back.")
17+
18+(defonce ^:private impl
19+ ;; Keyword fn. Empty until a backend installs, which the entry point does
20+ ;; beside `frq.io`'s: see `frq.main` and `frq.main-web`.
21+ (atom {}))
22+
23+(defn install!
24+ "Register the transport. Called once, by the entry point, before anything
25+ connects."
26+ [m]
27+ (swap! impl merge m)
28+ nil)
29+
30+(defn installed? [] (boolean (seq @impl)))
31+
32+(defn ^:async connect!
33+ "Open a connection and start reading it.
34+
35+ `on-msg` is called with a parsed map per line; `on-close` with a reason, or
36+ nil where the peer simply went away. Returns the socket, which `send-line!`
37+ and `close!` take.
38+
39+ Awaited here rather than handed back as a Future for the caller to await:
40+ what comes out of the map is dynamic, and cljd cannot see that a dynamic
41+ call returns something awaitable. One `^:async` on this side gives the
42+ caller a Future it can see the shape of."
43+ [opts]
44+ (if-let [f (:connect! @impl)]
45+ (await (f opts))
46+ (throw (ex-info "frq.net: no transport installed" {:op :connect!}))))
47+
48+(defn send-line! [sock line]
49+ (if-let [f (:send-line! @impl)]
50+ (f sock line)
51+ (throw (ex-info "frq.net: no transport installed" {:op :send-line!}))))
52+
53+(defn close! [sock]
54+ (when-let [f (:close! @impl)]
55+ (f sock)))
modified flutter/src/frq/net/dart.cljd +12 -1
@@ -18,7 +18,8 @@
1818 ["dart:developer" :as dev]
1919 ["dart:io" :as io]
2020 [frq.debug :as debug]
21- [frq.irc.parse :as parse]))
21+ [frq.irc.parse :as parse]
22+ [frq.net :as net]))
2223
2324 ;; Whether to log every line in and out, under the name `frq.wire`.
2425 ;;
@@ -117,3 +118,13 @@
117118
118119 (defn close! [^io/Socket sock]
119120 (try (.destroy sock) (catch Exception _ nil)))
121+
122+(defn install!
123+ "Register this as the transport. The mirror of `frq.net.web/install!`, and
124+ called from `frq.main` the way `frq.io.dart/install!` is — the entry point
125+ says which host and which socket in the same breath."
126+ []
127+ (net/install!
128+ {:connect! connect!
129+ :send-line! send-line!
130+ :close! close!}))
@@ -18,7 +18,8 @@
18 ["dart:developer" :as dev]18 ["dart:developer" :as dev]
19 ["dart:io" :as io]19 ["dart:io" :as io]
20 [frq.debug :as debug]20 [frq.debug :as debug]
21- [frq.irc.parse :as parse]))21+ [frq.irc.parse :as parse]
22+ [frq.net :as net]))
22 23
23 ;; Whether to log every line in and out, under the name `frq.wire`.24 ;; Whether to log every line in and out, under the name `frq.wire`.
24 ;;25 ;;
@@ -117,3 +118,13 @@
117 118
118 (defn close! [^io/Socket sock]119 (defn close! [^io/Socket sock]
119 (try (.destroy sock) (catch Exception _ nil)))120 (try (.destroy sock) (catch Exception _ nil)))
121+
122+(defn install!
123+ "Register this as the transport. The mirror of `frq.net.web/install!`, and
124+ called from `frq.main` the way `frq.io.dart/install!` is — the entry point
125+ says which host and which socket in the same breath."
126+ []
127+ (net/install!
128+ {:connect! connect!
129+ :send-line! send-line!
130+ :close! close!}))
added flutter/src/frq/net/web.cljd +137 -0
new file mode 100644
@@ -0,0 +1,137 @@
1+(ns frq.net.web
2+ "An IRC connection over a browser WebSocket.
3+
4+ The transport freeq already speaks: `freeq-server/src/web.rs` routes `/irc`
5+ to a WebSocket upgrade and bridges it to the same IRC handler a TCP client
6+ reaches, so `wss://irc.freeq.at/irc` is the same server as
7+ `irc.freeq.at:6697` and not a second one.
8+
9+ Simpler than `frq.net.dart`, and the framing is why. That backend buffers
10+ chunks because a TCP boundary falls wherever TCP puts it, which is never
11+ where IRC put its lines. The bridge does that work on the far side: it
12+ splits the IRC stream on CRLF and sends one line per text frame, and appends
13+ CRLF to every frame it receives. So a frame *is* a line, in both directions,
14+ and there is no buffer here at all.
15+
16+ The one thing that follows from it: `send-line!` sends the line bare. The
17+ bridge appends CRLF to a text frame unconditionally — it only tests for one
18+ on the binary path — so a line sent with its own CRLF arrives as a line
19+ followed by an empty one."
20+ (:require ["dart:async" :as async]
21+ ["dart:html" :as html]
22+ [frq.debug :as debug]
23+ [frq.irc.parse :as parse]
24+ [frq.net :as net]))
25+
26+;; `frq.net.dart`'s flag and its reasoning, kept separate rather than shared:
27+;; that namespace names dart:io and cannot be required from here.
28+(defonce wire-log? (atom true))
29+
30+(defn- wire! [direction line]
31+ (when @wire-log?
32+ ;; `print` and not `dart:developer`'s log: on the web `print` lands in the
33+ ;; browser console, which is the place someone debugging this actually
34+ ;; looks, where `dev/log` goes to the VM service and nowhere visible.
35+ ;; Redacted here rather than only in the file sink, for that reason
36+ ;; `frq.debug` answers nil on the web, since there is no file to name, so
37+ ;; the console is the only sink there is.
38+ (print (str "frq.wire " direction " "
39+ (if (.startsWith (str line) "AUTHENTICATE ")
40+ "AUTHENTICATE <redacted>"
41+ line)))
42+ (when (debug/on?)
43+ (debug/jot! direction " " line))))
44+
45+(defn- ws-url
46+ "The URL to open, from whatever the Server field holds.
47+
48+ A `ws://` or `wss://` URL is taken as given — that is the form the connect
49+ screen now asks for, and someone who wrote a path meant it. Anything else is
50+ a bare host from the days when this field was dialled over TCP, and the best
51+ guess is freeq's own route: `/irc` on that host, TLS unless it was unticked.
52+
53+ The port is not carried over on the guessed form. 6697 is where the IRC
54+ listener is and the WebSocket bridge is not there; it is on the ordinary
55+ https port, which is what leaving it off means."
56+ [host tls?]
57+ (let [h (.trim (str host))]
58+ (cond
59+ (or (.startsWith h "ws://") (.startsWith h "wss://")) h
60+ (.startsWith h "http://") (str "ws://" (.substring h 7))
61+ (.startsWith h "https://") (str "wss://" (.substring h 8))
62+ :else (str (if tls? "wss://" "ws://")
63+ (first (.split h ":"))
64+ "/irc"))))
65+
66+(defn ^:async connect!
67+ "Open the WebSocket and start reading it. Returns once the socket is open,
68+ or throws with what went wrong.
69+
70+ A Completer rather than the Future `WebSocket` does not give: the browser
71+ API is events, and `open` and `error` are the two that decide whether this
72+ call succeeded. After that first decision the same `error` handler means
73+ something else — a connection that has dropped — so it is routed by whether
74+ the completer has already fired."
75+ [{:keys [host tls? on-msg on-close]}]
76+ (let [url (ws-url host tls?)
77+ sock (html/WebSocket. url)
78+ opened (async/Completer.)]
79+ (.addEventListener
80+ sock "open"
81+ (fn [_]
82+ (wire! "*" (str "open " url))
83+ (when-not (.-isCompleted opened) (.complete opened sock))
84+ nil))
85+ (.addEventListener
86+ sock "message"
87+ (fn [^html/MessageEvent e]
88+ ;; One frame is one line, by the bridge's contract. Split anyway: it
89+ ;; costs nothing on a single line and it is the difference between a
90+ ;; wrong assumption showing up as a dropped message and not at all.
91+ (doseq [raw (.split (str (.-data e)) "\n")]
92+ (let [line (.trim (str raw))]
93+ (when (and on-msg (pos? (count line)))
94+ (wire! "<" line)
95+ (let [m (parse/parse-line line)]
96+ ;; Answered here rather than upstairs, exactly as
97+ ;; `frq.net.dart` does: nothing about a PONG is a decision, and
98+ ;; a client that queues it behind the reducer is one queue away
99+ ;; from a timeout.
100+ (when (= "PING" (:command m))
101+ (let [pong (str "PONG :" (first (:params m)))]
102+ (wire! ">" pong)
103+ (.send sock pong)))
104+ (on-msg m)))))
105+ nil))
106+ (.addEventListener
107+ sock "close"
108+ (fn [_]
109+ (if (.-isCompleted opened)
110+ (when on-close (on-close nil))
111+ ;; Closed before it ever opened: the handshake was refused, and the
112+ ;; browser deliberately does not say why. Saying so plainly beats
113+ ;; reporting it as a connection that dropped.
114+ (.completeError opened
115+ (Exception. (str "WebSocket refused by " url))))
116+ nil))
117+ (.addEventListener
118+ sock "error"
119+ (fn [_]
120+ (when (.-isCompleted opened)
121+ (when on-close (on-close "WebSocket error")))
122+ nil))
123+ (await (.-future opened))))
124+
125+(defn send-line! [^html/WebSocket sock line]
126+ (wire! ">" line)
127+ ;; Bare, with no CRLF: the bridge appends one to every text frame.
128+ (.send sock (str line)))
129+
130+(defn close! [^html/WebSocket sock]
131+ (try (.close sock) (catch Object _ nil)))
132+
133+(defn install! []
134+ (net/install!
135+ {:connect! connect!
136+ :send-line! send-line!
137+ :close! close!}))
new file mode 100644
@@ -0,0 +1,137 @@
1+(ns frq.net.web
2+ "An IRC connection over a browser WebSocket.
3+
4+ The transport freeq already speaks: `freeq-server/src/web.rs` routes `/irc`
5+ to a WebSocket upgrade and bridges it to the same IRC handler a TCP client
6+ reaches, so `wss://irc.freeq.at/irc` is the same server as
7+ `irc.freeq.at:6697` and not a second one.
8+
9+ Simpler than `frq.net.dart`, and the framing is why. That backend buffers
10+ chunks because a TCP boundary falls wherever TCP puts it, which is never
11+ where IRC put its lines. The bridge does that work on the far side: it
12+ splits the IRC stream on CRLF and sends one line per text frame, and appends
13+ CRLF to every frame it receives. So a frame *is* a line, in both directions,
14+ and there is no buffer here at all.
15+
16+ The one thing that follows from it: `send-line!` sends the line bare. The
17+ bridge appends CRLF to a text frame unconditionally — it only tests for one
18+ on the binary path — so a line sent with its own CRLF arrives as a line
19+ followed by an empty one."
20+ (:require ["dart:async" :as async]
21+ ["dart:html" :as html]
22+ [frq.debug :as debug]
23+ [frq.irc.parse :as parse]
24+ [frq.net :as net]))
25+
26+;; `frq.net.dart`'s flag and its reasoning, kept separate rather than shared:
27+;; that namespace names dart:io and cannot be required from here.
28+(defonce wire-log? (atom true))
29+
30+(defn- wire! [direction line]
31+ (when @wire-log?
32+ ;; `print` and not `dart:developer`'s log: on the web `print` lands in the
33+ ;; browser console, which is the place someone debugging this actually
34+ ;; looks, where `dev/log` goes to the VM service and nowhere visible.
35+ ;; Redacted here rather than only in the file sink, for that reason
36+ ;; `frq.debug` answers nil on the web, since there is no file to name, so
37+ ;; the console is the only sink there is.
38+ (print (str "frq.wire " direction " "
39+ (if (.startsWith (str line) "AUTHENTICATE ")
40+ "AUTHENTICATE <redacted>"
41+ line)))
42+ (when (debug/on?)
43+ (debug/jot! direction " " line))))
44+
45+(defn- ws-url
46+ "The URL to open, from whatever the Server field holds.
47+
48+ A `ws://` or `wss://` URL is taken as given — that is the form the connect
49+ screen now asks for, and someone who wrote a path meant it. Anything else is
50+ a bare host from the days when this field was dialled over TCP, and the best
51+ guess is freeq's own route: `/irc` on that host, TLS unless it was unticked.
52+
53+ The port is not carried over on the guessed form. 6697 is where the IRC
54+ listener is and the WebSocket bridge is not there; it is on the ordinary
55+ https port, which is what leaving it off means."
56+ [host tls?]
57+ (let [h (.trim (str host))]
58+ (cond
59+ (or (.startsWith h "ws://") (.startsWith h "wss://")) h
60+ (.startsWith h "http://") (str "ws://" (.substring h 7))
61+ (.startsWith h "https://") (str "wss://" (.substring h 8))
62+ :else (str (if tls? "wss://" "ws://")
63+ (first (.split h ":"))
64+ "/irc"))))
65+
66+(defn ^:async connect!
67+ "Open the WebSocket and start reading it. Returns once the socket is open,
68+ or throws with what went wrong.
69+
70+ A Completer rather than the Future `WebSocket` does not give: the browser
71+ API is events, and `open` and `error` are the two that decide whether this
72+ call succeeded. After that first decision the same `error` handler means
73+ something else — a connection that has dropped — so it is routed by whether
74+ the completer has already fired."
75+ [{:keys [host tls? on-msg on-close]}]
76+ (let [url (ws-url host tls?)
77+ sock (html/WebSocket. url)
78+ opened (async/Completer.)]
79+ (.addEventListener
80+ sock "open"
81+ (fn [_]
82+ (wire! "*" (str "open " url))
83+ (when-not (.-isCompleted opened) (.complete opened sock))
84+ nil))
85+ (.addEventListener
86+ sock "message"
87+ (fn [^html/MessageEvent e]
88+ ;; One frame is one line, by the bridge's contract. Split anyway: it
89+ ;; costs nothing on a single line and it is the difference between a
90+ ;; wrong assumption showing up as a dropped message and not at all.
91+ (doseq [raw (.split (str (.-data e)) "\n")]
92+ (let [line (.trim (str raw))]
93+ (when (and on-msg (pos? (count line)))
94+ (wire! "<" line)
95+ (let [m (parse/parse-line line)]
96+ ;; Answered here rather than upstairs, exactly as
97+ ;; `frq.net.dart` does: nothing about a PONG is a decision, and
98+ ;; a client that queues it behind the reducer is one queue away
99+ ;; from a timeout.
100+ (when (= "PING" (:command m))
101+ (let [pong (str "PONG :" (first (:params m)))]
102+ (wire! ">" pong)
103+ (.send sock pong)))
104+ (on-msg m)))))
105+ nil))
106+ (.addEventListener
107+ sock "close"
108+ (fn [_]
109+ (if (.-isCompleted opened)
110+ (when on-close (on-close nil))
111+ ;; Closed before it ever opened: the handshake was refused, and the
112+ ;; browser deliberately does not say why. Saying so plainly beats
113+ ;; reporting it as a connection that dropped.
114+ (.completeError opened
115+ (Exception. (str "WebSocket refused by " url))))
116+ nil))
117+ (.addEventListener
118+ sock "error"
119+ (fn [_]
120+ (when (.-isCompleted opened)
121+ (when on-close (on-close "WebSocket error")))
122+ nil))
123+ (await (.-future opened))))
124+
125+(defn send-line! [^html/WebSocket sock line]
126+ (wire! ">" line)
127+ ;; Bare, with no CRLF: the bridge appends one to every text frame.
128+ (.send sock (str line)))
129+
130+(defn close! [^html/WebSocket sock]
131+ (try (.close sock) (catch Object _ nil)))
132+
133+(defn install! []
134+ (net/install!
135+ {:connect! connect!
136+ :send-line! send-line!
137+ :close! close!}))