///|
/// Opaque handle to a browser-native `WebSocket` instance.
#external
pub type BrowserWebSocket
///|
pub extern "js" fn ws_log(text : String) -> Unit =
#| (text) => console.log("[cumulo] " + text)
///|
pub extern "js" fn ws_warn(text : String) -> Unit =
#| (text) => console.warn("[cumulo] " + text)
///|
pub extern "js" fn ws_error(text : String) -> Unit =
#| (text) => console.error("[cumulo] " + text)
///|
/// Initiate a clean close handshake on an existing WebSocket.
pub extern "js" fn ws_close(socket : BrowserWebSocket) -> Unit =
#| (socket) => socket.close()
///|
/// Build a WebSocket URL that works for both dev and production.
///
/// `dev_ports` — comma-separated Vite (or other dev-server) ports,
/// e.g. `"5173,5174,5175,5176"`.
/// `backend_port` — the port the WebSocket backend listens on.
/// `ws_path` — the path, e.g. `"/ws"`.
///
/// When the current page is served from one of `dev_ports`, the WS request
/// is routed to `backend_port` on the same hostname (bypasses the dev proxy).
/// In production the request uses same-origin with `ws_path`.
///
/// No ports or paths are hardcoded — fully caller-configurable.
pub extern "js" fn make_ws_url(
dev_ports : String,
backend_port : Int,
ws_path : String,
) -> String =
#| (devPorts, backendPort, wsPath) => {
#| const protocol = window.location.protocol === "https:" ? "wss" : "ws";
#| const devPortSet = new Set(devPorts.split(","));
#| if (devPortSet.has(window.location.port)) {
#| return `${protocol}://${window.location.hostname}:${backendPort}${wsPath}`;
#| }
#| return `${protocol}://${window.location.host}${wsPath}`;
#| }
///|
extern "js" fn _connect_ws(
url : String,
on_open : () -> Unit,
on_message : (String) -> Unit,
on_close : () -> Unit,
) -> BrowserWebSocket =
#| (url, onOpen, onMessage, onClose) => {
#| const socket = new WebSocket(url);
#| socket.addEventListener("open", () => onOpen());
#| socket.addEventListener("message", (e) => onMessage(String(e.data ?? "")));
#| socket.addEventListener("close", () => onClose());
#| return socket;
#| }
///|
extern "js" fn _ws_send(socket : BrowserWebSocket, text : String) -> Unit =
#| (socket, text) => socket.send(text)
///|
/// Connect to a WebSocket server with fully typed JSON messages.
///
/// - `on_open()` — connection established
/// - `on_message(msg)` — called with the already-parsed `In` value;
/// JSON parse errors are logged via `ws_error` and dropped
/// - `on_close()` — connection closed or errored
///
/// Returns `(socket, send_fn)`:
/// - `socket` — live handle; pass to `ws_close` when needed
/// - `send_fn` — typed `(Out) -> Unit`; serializes to JSON internally;
/// **safe to call immediately** — no-ops until the connection is open
/// (guards against CONNECTING-state race conditions)
///
/// ## Example
///
/// ```moonbit nocheck
/// let url = make_ws_url("5173,5174", 5022, "/ws")
/// let (socket, send) = connect_json(
/// url,
/// fn() { store.update(Connected) },
/// fn(msg : ServerEvent) { store.update(Received(msg)) },
/// fn() { store.update(Disconnected) },
/// )
/// // later: send(ClientOp::Login(...))
/// ```
pub fn[In : @json.FromJson, Out : ToJson] connect_json(
url : String,
on_open : () -> Unit,
on_message : (In) -> Unit,
on_close : () -> Unit,
) -> (BrowserWebSocket, (Out) -> Unit) {
// `ready` guards sends against the CONNECTING state.
// The flag is set inside the open callback (async), so `send_fn` returned
// synchronously is safe to store and call without checking connection state.
let ready : Ref[Bool] = Ref::new(false)
let socket = _connect_ws(
url,
fn() {
ready.val = true
on_open()
},
fn(text) {
try {
let msg : In = @json.from_json(@json.parse(text))
on_message(msg)
} catch {
_ => ws_error("ws parse error: \{text[:120]}")
}
},
fn() {
ready.val = false
on_close()
},
)
let send_fn = fn(msg : Out) {
if ready.val {
_ws_send(socket, msg.to_json().stringify())
}
}
(socket, send_fn)
}