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>
30d5fae parent: f54ca45 modified
flutter/src/frq/debug.cljd +15 -2 | @@ -28,8 +28,21 @@ | ||
| 28 | 28 | (defonce ^:private sink |
| 29 | 29 | ;; Read once. An env var does not change under a running app, and this is |
| 30 | 30 | ;; 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)))) | |
| 33 | 46 | |
| 34 | 47 | (defn on? |
| 35 | 48 | "Whether anything is listening, for a caller with expensive diagnostics to |
| @@ -28,8 +28,21 @@ | |||
| 28 | (defonce ^:private sink | 28 | (defonce ^:private sink |
| 29 | ;; Read once. An env var does not change under a running app, and this is | 29 | ;; 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 to | 48 | "Whether anything is listening, for a caller with expensive diagnostics to |
modified
flutter/src/frq/hiccup.cljd +15 -2 | @@ -26,6 +26,10 @@ | ||
| 26 | 26 | read it — fine at this size, and the thing to revisit if a message list ever |
| 27 | 27 | feels it." |
| 28 | 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 | 33 | ["package:flutter/gestures.dart" :as g] |
| 30 | 34 | ["package:flutter/material.dart" :as m] |
| 31 | 35 | [cljd.flutter :as f] |
| @@ -54,9 +58,18 @@ | ||
| 54 | 58 | |
| 55 | 59 | `frq.screens.*` asks the host the same question as `actions/desktop?`, and |
| 56 | 60 | 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 | 74 | (defn- hovered |
| 62 | 75 | "`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 ever | 26 | 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?`, and | 59 | `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, for | 60 | 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- hovered | 74 | (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 @@ | ||
| 18 | 18 | ["dart:developer" :as dev] |
| 19 | 19 | ["dart:io" :as io] |
| 20 | 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 | 24 | ;; Whether to log every line in and out, under the name `frq.wire`. |
| 24 | 25 | ;; |
| @@ -117,3 +118,13 @@ | ||
| 117 | 118 | |
| 118 | 119 | (defn close! [^io/Socket sock] |
| 119 | 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!})) | |
| @@ -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!})) | ||