Skip to main content

lex_runtime/
ws.rs

1//! WebSocket server + chat-broadcast registry.
2//!
3//! `net.serve_ws(port, on_message)` blocks on a TCP listener, upgrades
4//! each incoming connection to WebSocket, and runs a per-connection
5//! worker thread that polls both inbound (calls Lex's `on_message`)
6//! and outbound (drains broadcasts from a channel into the socket).
7//!
8//! `chat.broadcast(room, body)` looks up every connection in `room`
9//! and pushes `body` onto its outbound channel. `chat.send(conn_id,
10//! body)` is the same but to a single connection.
11//!
12//! The registry is an `Arc<Mutex<…>>` because Lex's immutability means
13//! shared mutable state has to live in the host runtime. Lex code
14//! stays pure: it receives an event, returns Nil, and any side
15//! effects go through `chat.*` which is gated by the policy.
16
17// tungstenite's `accept_hdr` callback takes/returns a tungstenite
18// `ErrorResponse` which is large; we only ever return Ok so the
19// large-Err warning is noise.
20#![allow(clippy::result_large_err)]
21
22use crate::policy::Policy;
23use indexmap::IndexMap;
24use lex_bytecode::vm::Vm;
25use lex_bytecode::{Program, Value};
26use std::net::TcpListener;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::mpsc;
29use std::sync::{Arc, Mutex};
30use std::thread;
31use std::time::Duration;
32
33/// Per-connection state held in the global registry.
34struct Conn {
35    room: String,
36    /// Channel writer end. The connection's worker thread reads from
37    /// the corresponding Receiver and writes each message to the
38    /// WebSocket. Broadcasts push here.
39    outbound: mpsc::Sender<String>,
40}
41
42/// Global chat registry. One per `net.serve_ws` invocation.
43#[derive(Default)]
44pub struct ChatRegistry {
45    conns: Mutex<IndexMap<u64, Conn>>,
46}
47
48impl ChatRegistry {
49    fn register(&self, room: String, outbound: mpsc::Sender<String>) -> u64 {
50        static NEXT_ID: AtomicU64 = AtomicU64::new(1);
51        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
52        self.conns.lock().unwrap().insert(id, Conn { room, outbound });
53        id
54    }
55    fn unregister(&self, id: u64) {
56        self.conns.lock().unwrap().shift_remove(&id);
57    }
58    fn broadcast(&self, room: &str, body: &str) {
59        let conns = self.conns.lock().unwrap();
60        for c in conns.values() {
61            if c.room == room {
62                let _ = c.outbound.send(body.to_string());
63            }
64        }
65    }
66    fn send_to(&self, id: u64, body: &str) -> bool {
67        if let Some(c) = self.conns.lock().unwrap().get(&id) {
68            let _ = c.outbound.send(body.to_string());
69            true
70        } else {
71            false
72        }
73    }
74}
75
76/// `chat.broadcast(room, body)` — looked up at runtime by the
77/// effect handler; called from inside the Lex VM.
78pub fn chat_broadcast(reg: &Arc<ChatRegistry>, room: &str, body: &str) {
79    reg.broadcast(room, body);
80}
81
82pub fn chat_send(reg: &Arc<ChatRegistry>, conn_id: u64, body: &str) -> bool {
83    reg.send_to(conn_id, body)
84}
85
86/// Bind a WebSocket server. Blocks; returns Unit on shutdown (the
87/// process is normally killed before that).
88pub fn serve_ws(
89    port: u16,
90    handler_name: String,
91    program: Arc<Program>,
92    policy: Policy,
93    registry: Arc<ChatRegistry>,
94) -> Result<Value, String> {
95    let listener = TcpListener::bind(("127.0.0.1", port))
96        .map_err(|e| format!("net.serve_ws bind {port}: {e}"))?;
97    eprintln!("net.serve_ws: listening on ws://127.0.0.1:{port}");
98    for stream in listener.incoming() {
99        let stream = match stream {
100            Ok(s) => s,
101            Err(e) => { eprintln!("net.serve_ws accept: {e}"); continue; }
102        };
103        let program = Arc::clone(&program);
104        let policy = policy.clone();
105        let handler_name = handler_name.clone();
106        let registry = Arc::clone(&registry);
107        thread::spawn(move || {
108            if let Err(e) = handle_connection(stream, program, policy, handler_name, registry) {
109                eprintln!("net.serve_ws connection error: {e}");
110            }
111        });
112    }
113    Ok(Value::Unit)
114}
115
116fn handle_connection(
117    stream: std::net::TcpStream,
118    program: Arc<Program>,
119    policy: Policy,
120    handler_name: String,
121    registry: Arc<ChatRegistry>,
122) -> Result<(), String> {
123    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
124
125    // Capture the request path during the handshake — used as the room name.
126    let mut path = String::new();
127    let path_ref = &mut path;
128    let mut ws = accept_hdr(stream, |req: &Request, resp: Response| {
129        *path_ref = req.uri().path().to_string();
130        Ok(resp)
131    }).map_err(|e| format!("ws handshake: {e}"))?;
132
133    let room = path.trim_start_matches('/').to_string();
134
135    // Outbound channel: broadcast/send pushes here, this thread writes
136    // each message into the WebSocket.
137    let (tx, rx) = mpsc::channel::<String>();
138    let conn_id = registry.register(room.clone(), tx);
139
140    // Make WS reads non-blocking-ish so the same thread can also drain
141    // the outbound channel. tungstenite reads through the underlying
142    // TcpStream; setting a short read timeout lets us multiplex.
143    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
144
145    let result = run_loop(&mut ws, &rx, conn_id, &room, &program, &policy, &handler_name, &registry);
146    registry.unregister(conn_id);
147    let _ = ws.close(None);
148    result
149}
150
151#[allow(clippy::too_many_arguments)]
152fn run_loop(
153    ws: &mut tungstenite::WebSocket<std::net::TcpStream>,
154    rx: &mpsc::Receiver<String>,
155    conn_id: u64,
156    room: &str,
157    program: &Arc<Program>,
158    policy: &Policy,
159    handler_name: &str,
160    registry: &Arc<ChatRegistry>,
161) -> Result<(), String> {
162    use tungstenite::Message;
163    use std::io::ErrorKind;
164    loop {
165        // 1) Try to read one inbound message. WouldBlock = no data yet.
166        match ws.read() {
167            Ok(Message::Text(body)) => {
168                let ev = build_ws_event(conn_id, room, &body);
169                let handler = crate::handler::DefaultHandler::new(policy.clone())
170                    .with_program(Arc::clone(program))
171                    .with_chat_registry(Arc::clone(registry));
172                let mut vm = Vm::with_handler(program, Box::new(handler));
173                if let Err(e) = vm.call(handler_name, vec![ev]) {
174                    eprintln!("on_message {conn_id}: {e}");
175                }
176            }
177            Ok(Message::Binary(_)) => { /* binary frames ignored in v1 */ }
178            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => break,
179            Ok(_) => {} // ping/pong/frame
180            Err(tungstenite::Error::Io(ref e)) if e.kind() == ErrorKind::WouldBlock
181                || e.kind() == ErrorKind::TimedOut => {}
182            Err(e) => return Err(format!("ws read: {e}")),
183        }
184        // 2) Drain outbound channel. Doesn't block.
185        loop {
186            match rx.try_recv() {
187                Ok(msg) => {
188                    if let Err(e) = ws.send(Message::Text(msg.into())) {
189                        return Err(format!("ws send: {e}"));
190                    }
191                }
192                Err(mpsc::TryRecvError::Empty) => break,
193                Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
194            }
195        }
196    }
197    Ok(())
198}
199
200fn build_ws_event(conn_id: u64, room: &str, body: &str) -> Value {
201    let mut rec = IndexMap::new();
202    rec.insert("body".into(), Value::Str(body.into()));
203    rec.insert("conn_id".into(), Value::Int(conn_id as i64));
204    rec.insert("room".into(), Value::Str(room.into()));
205    Value::record_dynamic(rec)
206}
207
208// ── Closure-based WebSocket server (#359) ────────────────────────────────────
209
210/// Build a `WsConn` record value for the typed closure-based handler.
211fn build_ws_conn(conn_id: u64, path: &str, subprotocol: &str) -> Value {
212    let mut rec = IndexMap::new();
213    rec.insert("id".into(), Value::Str(conn_id.to_string().into()));
214    rec.insert("path".into(), Value::Str(path.into()));
215    rec.insert("subprotocol".into(), Value::Str(subprotocol.into()));
216    Value::record_dynamic(rec)
217}
218
219/// Build a `WsMessage` variant value.
220fn build_ws_message_text(body: &str) -> Value {
221    Value::Variant { name: "WsText".into(), args: vec![Value::Str(body.into())] }
222}
223
224fn build_ws_message_close() -> Value {
225    Value::Variant { name: "WsClose".into(), args: vec![] }
226}
227
228fn build_ws_message_ping() -> Value {
229    Value::Variant { name: "WsPing".into(), args: vec![] }
230}
231
232fn build_ws_message_binary(payload: &[u8]) -> Value {
233    let bytes = payload.iter().map(|b| Value::Int(*b as i64)).collect();
234    Value::Variant { name: "WsBinary".into(), args: vec![Value::List(bytes)] }
235}
236
237/// Interpret a `WsAction` variant and send the appropriate frame.
238/// Generic over the stream so this serves both the plaintext-only
239/// server path (`TcpStream`) and the dial path that may sit on top
240/// of a TLS-wrapped stream (`MaybeTlsStream<TcpStream>`).
241fn apply_ws_action<S: std::io::Read + std::io::Write>(
242    action: &Value,
243    ws: &mut tungstenite::WebSocket<S>,
244) -> Result<(), String> {
245    use tungstenite::Message;
246    match action {
247        Value::Variant { name, args } if name == "WsSend" => {
248            let text = match args.first() {
249                Some(Value::Str(s)) => s.clone(),
250                _ => return Err("WsSend payload must be Str".into()),
251            };
252            ws.send(Message::Text(text.to_string().into()))
253                .map_err(|e| format!("ws send: {e}"))
254        }
255        Value::Variant { name, args } if name == "WsSendBinary" => {
256            let bytes: Vec<u8> = match args.first() {
257                Some(Value::List(elems)) => elems
258                    .iter()
259                    .map(|v| match v {
260                        Value::Int(n) => Ok(*n as u8),
261                        _ => Err("WsSendBinary payload must be List[Int]".into()),
262                    })
263                    .collect::<Result<Vec<_>, String>>()?,
264                _ => return Err("WsSendBinary payload must be List[Int]".into()),
265            };
266            ws.send(Message::Binary(bytes.into()))
267                .map_err(|e| format!("ws send binary: {e}"))
268        }
269        Value::Variant { name, .. } if name == "WsNoOp" => Ok(()),
270        other => Err(format!("unexpected WsAction: {other:?}")),
271    }
272}
273
274/// Closure-based WebSocket server. Accepts a `Value::Closure` as the handler.
275pub fn serve_ws_fn(
276    port: u16,
277    subprotocol: String,
278    closure: Value,
279    program: Arc<Program>,
280    policy: Policy,
281    registry: Arc<ChatRegistry>,
282) -> Result<Value, String> {
283    // Fail fast: a configured subprotocol that can't be a valid HTTP
284    // header value would silently break every handshake later (the
285    // accept_hdr callback's `HeaderValue::from_str` would always
286    // return Err). Reject at startup with a clear message instead.
287    if !subprotocol.is_empty() {
288        if let Err(e) =
289            tungstenite::http::HeaderValue::from_str(&subprotocol)
290        {
291            return Err(format!(
292                "net.serve_ws_fn: subprotocol {subprotocol:?} is not a valid \
293                 HTTP header value: {e}"
294            ));
295        }
296    }
297    let listener = TcpListener::bind(("127.0.0.1", port))
298        .map_err(|e| format!("net.serve_ws_fn bind {port}: {e}"))?;
299    eprintln!("net.serve_ws_fn: listening on ws://127.0.0.1:{port}");
300    for stream in listener.incoming() {
301        let stream = match stream {
302            Ok(s) => s,
303            Err(e) => { eprintln!("net.serve_ws_fn accept: {e}"); continue; }
304        };
305        let program = Arc::clone(&program);
306        let policy = policy.clone();
307        let closure = closure.clone();
308        let subprotocol = subprotocol.clone();
309        let registry = Arc::clone(&registry);
310        thread::spawn(move || {
311            if let Err(e) = handle_connection_fn(
312                stream, program, policy, closure, subprotocol, registry,
313            ) {
314                eprintln!("net.serve_ws_fn connection error: {e}");
315            }
316        });
317    }
318    Ok(Value::Unit)
319}
320
321fn handle_connection_fn(
322    stream: std::net::TcpStream,
323    program: Arc<Program>,
324    policy: Policy,
325    closure: Value,
326    subprotocol: String,
327    registry: Arc<ChatRegistry>,
328) -> Result<(), String> {
329    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
330
331    let mut path = String::new();
332    let path_ref = &mut path;
333    let subproto_for_handshake = subprotocol.clone();
334    let mut ws = accept_hdr(stream, |req: &Request, mut resp: Response| {
335        *path_ref = req.uri().path().to_string();
336        maybe_echo_subprotocol(req, &mut resp, &subproto_for_handshake);
337        Ok(resp)
338    }).map_err(|e| format!("ws handshake: {e}"))?;
339
340    let (tx, rx) = mpsc::channel::<String>();
341    let conn_id = registry.register(path.trim_start_matches('/').to_string(), tx);
342    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
343
344    let result = run_loop_fn(
345        &mut ws, &rx, conn_id, &path, &subprotocol,
346        &program, &policy, &closure, &registry,
347    );
348    registry.unregister(conn_id);
349    let _ = ws.close(None);
350    result
351}
352
353/// RFC 6455 §4.1: the server MUST advertise the negotiated
354/// subprotocol back in the handshake response, and MUST select one
355/// of the values the client offered. Echo when (a) the server has
356/// a non-empty subprotocol, (b) the client offered subprotocols,
357/// and (c) the server's configured value is among the client's
358/// offers. Empty server-side configuration → no echo (matches the
359/// dial_ws contract). Shared by `handle_connection_fn` (the
360/// always-accept variant) and `handle_connection_fn_auth` (the
361/// pre-handshake-auth variant).
362fn maybe_echo_subprotocol(
363    req: &tungstenite::handshake::server::Request,
364    resp: &mut tungstenite::handshake::server::Response,
365    subprotocol: &str,
366) {
367    use tungstenite::http::HeaderValue;
368    if subprotocol.is_empty() {
369        return;
370    }
371    let offered = match req.headers().get("Sec-WebSocket-Protocol") {
372        Some(v) => v,
373        None    => return,
374    };
375    let offered_str = match offered.to_str() {
376        Ok(s)  => s,
377        Err(_) => return,
378    };
379    let matches = offered_str
380        .split(',')
381        .map(|p| p.trim())
382        .any(|p| p == subprotocol);
383    if !matches {
384        return;
385    }
386    // from_str cannot fail here: serve_ws_fn[_auth] validated
387    // `subprotocol` upfront. Belt-and-braces: skip silently if it
388    // somehow does.
389    if let Ok(h) = HeaderValue::from_str(subprotocol) {
390        resp.headers_mut().insert("Sec-WebSocket-Protocol", h);
391    }
392}
393
394// ── serve_ws_fn_auth — pre-handshake auth callback (#423) ─────────────────────
395//
396// Variant of `serve_ws_fn` that runs a Lex closure against the
397// upgrade request's path + headers *before* accepting the WS
398// handshake. The closure returns `Result[Unit, Str]`:
399//
400//   Ok(())  → handshake proceeds, on_message handler runs as usual
401//   Err(msg) → respond `401 Unauthorized` with `msg` as the body;
402//              the WS upgrade never completes, on_message is never
403//              called for this connection
404//
405// This is the hook the OCPP Security Profile 2 (Basic Auth) and
406// Profile 3 (Bearer JWT) flows need — both check an `Authorization`
407// header that's present at the HTTP upgrade but not exposed to user
408// code by `serve_ws_fn`. The crypto primitives (`argon2id`,
409// `hs256`) live downstream in lex-crypto / lex-ocpp; this builtin
410// is just the missing transport hook that lets them fire at the
411// right time.
412//
413// Effect-polymorphic in the same row that `serve_ws_fn` is — the
414// auth callback and the on_message handler share `[Eff]`, so a
415// caller can use `[sql]` (look up the CP's password hash) in auth
416// and the same `[sql]` in subsequent message handling without
417// duplicating the row declaration.
418
419/// Closure-based WebSocket server with a pre-handshake auth
420/// callback. Calls `auth_closure(path, headers)` before completing
421/// the WS upgrade; rejects with 401 Unauthorized when the closure
422/// returns `Err(msg)`. See `serve_ws_fn` for the post-handshake
423/// behaviour (identical once auth passes).
424pub fn serve_ws_fn_auth(
425    port: u16,
426    subprotocol: String,
427    auth_closure: Value,
428    handler_closure: Value,
429    program: Arc<Program>,
430    policy: Policy,
431    registry: Arc<ChatRegistry>,
432) -> Result<Value, String> {
433    if !subprotocol.is_empty() {
434        if let Err(e) =
435            tungstenite::http::HeaderValue::from_str(&subprotocol)
436        {
437            return Err(format!(
438                "net.serve_ws_fn_auth: subprotocol {subprotocol:?} is not a \
439                 valid HTTP header value: {e}"
440            ));
441        }
442    }
443    let listener = TcpListener::bind(("127.0.0.1", port))
444        .map_err(|e| format!("net.serve_ws_fn_auth bind {port}: {e}"))?;
445    eprintln!("net.serve_ws_fn_auth: listening on ws://127.0.0.1:{port}");
446    for stream in listener.incoming() {
447        let stream = match stream {
448            Ok(s) => s,
449            Err(e) => {
450                eprintln!("net.serve_ws_fn_auth accept: {e}");
451                continue;
452            }
453        };
454        let program = Arc::clone(&program);
455        let policy = policy.clone();
456        let auth_closure = auth_closure.clone();
457        let handler_closure = handler_closure.clone();
458        let subprotocol = subprotocol.clone();
459        let registry = Arc::clone(&registry);
460        thread::spawn(move || {
461            if let Err(e) = handle_connection_fn_auth(
462                stream, program, policy, auth_closure, handler_closure,
463                subprotocol, registry,
464            ) {
465                eprintln!("net.serve_ws_fn_auth connection error: {e}");
466            }
467        });
468    }
469    Ok(Value::Unit)
470}
471
472#[allow(clippy::too_many_arguments)]
473fn handle_connection_fn_auth(
474    stream: std::net::TcpStream,
475    program: Arc<Program>,
476    policy: Policy,
477    auth_closure: Value,
478    handler_closure: Value,
479    subprotocol: String,
480    registry: Arc<ChatRegistry>,
481) -> Result<(), String> {
482    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
483    use tungstenite::http::StatusCode;
484
485    let mut path = String::new();
486    let path_ref = &mut path;
487    let subproto_for_handshake = subprotocol.clone();
488    // `accept_hdr`'s callback is `FnOnce`, so the closures + program
489    // + policy + registry it needs are moved in directly (no clones
490    // inside the closure body).
491    let auth_program = Arc::clone(&program);
492    let auth_policy = policy.clone();
493    let auth_registry = Arc::clone(&registry);
494    let auth_closure_for_cb = auth_closure;
495
496    let mut ws = accept_hdr(stream, move |req: &Request, mut resp: Response| {
497        *path_ref = req.uri().path().to_string();
498
499        let headers_value = build_headers_value(req);
500        let path_arg = Value::Str(path_ref.clone().into());
501
502        let dh = crate::handler::DefaultHandler::new(auth_policy.clone())
503            .with_program(Arc::clone(&auth_program))
504            .with_chat_registry(Arc::clone(&auth_registry));
505        let mut vm = Vm::with_handler(&auth_program, Box::new(dh));
506        let auth_result = vm.invoke_closure_value(
507            auth_closure_for_cb,
508            vec![path_arg, headers_value],
509        );
510
511        match auth_result {
512            Ok(Value::Variant { name, .. }) if name == "Ok" => {
513                maybe_echo_subprotocol(req, &mut resp, &subproto_for_handshake);
514                Ok(resp)
515            }
516            Ok(Value::Variant { name, args }) if name == "Err" => {
517                let msg = match args.first() {
518                    Some(Value::Str(s)) => s.to_string(),
519                    _                   => "unauthorized".to_string(),
520                };
521                let err = build_unauthorized_response(StatusCode::UNAUTHORIZED, msg);
522                Err(err)
523            }
524            Ok(other) => {
525                let err = build_unauthorized_response(
526                    StatusCode::INTERNAL_SERVER_ERROR,
527                    format!(
528                        "net.serve_ws_fn_auth: auth callback returned \
529                         non-Result value: {other:?}"
530                    ),
531                );
532                Err(err)
533            }
534            Err(e) => {
535                let err = build_unauthorized_response(
536                    StatusCode::INTERNAL_SERVER_ERROR,
537                    format!("net.serve_ws_fn_auth: auth callback error: {e:?}"),
538                );
539                Err(err)
540            }
541        }
542    }).map_err(|e| format!("ws handshake: {e}"))?;
543
544    let (tx, rx) = mpsc::channel::<String>();
545    let conn_id = registry.register(path.trim_start_matches('/').to_string(), tx);
546    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
547
548    let result = run_loop_fn(
549        &mut ws, &rx, conn_id, &path, &subprotocol,
550        &program, &policy, &handler_closure, &registry,
551    );
552    registry.unregister(conn_id);
553    let _ = ws.close(None);
554    result
555}
556
557/// Project the upgrade request headers into a Lex value of type
558/// `List[{ name :: Str, value :: Str }]`. Non-UTF-8 header values
559/// are skipped (the HTTP spec allows them, the Lex `Str` type
560/// doesn't — and OCPP Auth / JWT headers are ASCII by construction
561/// so a strict drop is safe here).
562fn build_headers_value(req: &tungstenite::handshake::server::Request) -> Value {
563    let mut items: std::collections::VecDeque<Value> = std::collections::VecDeque::new();
564    for (name, val) in req.headers().iter() {
565        let v = match val.to_str() {
566            Ok(s)  => s.to_string(),
567            Err(_) => continue,
568        };
569        let mut rec = IndexMap::new();
570        rec.insert("name".into(), Value::Str(name.as_str().into()));
571        rec.insert("value".into(), Value::Str(v.into()));
572        items.push_back(Value::record_dynamic(rec));
573    }
574    Value::List(items)
575}
576
577fn build_unauthorized_response(
578    status: tungstenite::http::StatusCode,
579    msg: String,
580) -> tungstenite::handshake::server::ErrorResponse {
581    tungstenite::http::Response::builder()
582        .status(status)
583        .header("Content-Type", "text/plain; charset=utf-8")
584        .body(Some(msg))
585        .expect("ErrorResponse builder")
586}
587
588#[allow(clippy::too_many_arguments)]
589fn run_loop_fn(
590    ws: &mut tungstenite::WebSocket<std::net::TcpStream>,
591    rx: &mpsc::Receiver<String>,
592    conn_id: u64,
593    path: &str,
594    subprotocol: &str,
595    program: &Arc<Program>,
596    policy: &Policy,
597    closure: &Value,
598    registry: &Arc<ChatRegistry>,
599) -> Result<(), String> {
600    use tungstenite::Message;
601    use std::io::ErrorKind;
602
603    let ws_conn = build_ws_conn(conn_id, path, subprotocol);
604
605    loop {
606        let ws_msg = match ws.read() {
607            Ok(Message::Text(body)) => Some(build_ws_message_text(&body)),
608            Ok(Message::Binary(_)) => None,
609            Ok(Message::Ping(_)) => Some(build_ws_message_ping()),
610            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => {
611                // Notify handler then exit.
612                let handler = crate::handler::DefaultHandler::new(policy.clone())
613                    .with_program(Arc::clone(program))
614                    .with_chat_registry(Arc::clone(registry));
615                let mut vm = Vm::with_handler(program, Box::new(handler));
616                let _ = vm.invoke_closure_value(
617                    closure.clone(),
618                    vec![ws_conn.clone(), build_ws_message_close()],
619                );
620                break;
621            }
622            Ok(_) => None, // pong / frame
623            Err(tungstenite::Error::Io(ref e))
624                if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => None,
625            Err(e) => return Err(format!("ws read: {e}")),
626        };
627
628        if let Some(msg) = ws_msg {
629            let handler = crate::handler::DefaultHandler::new(policy.clone())
630                .with_program(Arc::clone(program))
631                .with_chat_registry(Arc::clone(registry));
632            let mut vm = Vm::with_handler(program, Box::new(handler));
633            match vm.invoke_closure_value(closure.clone(), vec![ws_conn.clone(), msg]) {
634                Ok(action) => {
635                    if let Err(e) = apply_ws_action(&action, ws) {
636                        eprintln!("ws action {conn_id}: {e}");
637                    }
638                }
639                Err(e) => eprintln!("ws handler {conn_id}: {e}"),
640            }
641        }
642
643        // Drain broadcast/send outbound channel.
644        loop {
645            match rx.try_recv() {
646                Ok(msg) => {
647                    if let Err(e) = ws.send(Message::Text(msg.into())) {
648                        return Err(format!("ws send: {e}"));
649                    }
650                }
651                Err(mpsc::TryRecvError::Empty) => break,
652                Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
653            }
654        }
655    }
656    Ok(())
657}
658
659// ── serve_ws_fn_actor — outbound-bridge actor registration (#459) ────────────
660//
661// Variant of `serve_ws_fn` that registers each connection as a named
662// actor in `conc_registry`, so non-WS callers (HTTP webhooks, scheduled
663// tasks, broadcast loops) can push frames into the socket via
664// `conc.lookup(name) |> conc.tell(frame)`.
665//
666// Signature:
667//
668//   net.serve_ws_fn_actor(
669//     port        :: Int,
670//     subprotocol :: Str,
671//     name_of     :: (WsConn) -> Str,                       # per-connection name
672//     on_message  :: (WsConn, WsMessage) -> [E] WsAction    # same as serve_ws_fn
673//   ) -> [net, concurrent, io] Unit
674//
675// On each accepted connection, the runtime:
676//   1. Builds the WsConn record (same shape as serve_ws_fn) and calls
677//      `name_of(conn)`. An empty-string return means "don't register
678//      this connection" — the socket still accepts inbound frames and
679//      runs `on_message`, but no outbound handle is exposed.
680//   2. Builds an `ActorHandler::Native` bridge whose `send` closure
681//      writes the message body to the per-connection `mpsc::Sender<String>`
682//      that already exists for the broadcast path.
683//   3. Registers the bridge actor under the name. Name collisions
684//      (`AlreadyRegistered`) abort the connection — surface the bug at
685//      the source level rather than silently overwriting an existing
686//      session.
687//   4. On disconnect, unregisters the name and tears down the socket.
688//
689// The inbound half (read frame → call `on_message` → apply `WsAction`)
690// is identical to `serve_ws_fn`.
691//
692// Out-of-scope for v1: binary message dispatch from a non-WS task. The
693// native bridge only accepts `Value::Str`; binary frames need a tagged
694// message type (`WsOut::Text(Str) | WsOut::Binary(List[Int])`) that the
695// native handler can match on. Filed as a follow-up.
696pub fn serve_ws_fn_actor(
697    port: u16,
698    subprotocol: String,
699    name_of_closure: Value,
700    on_message_closure: Value,
701    program: Arc<Program>,
702    policy: Policy,
703    registry: Arc<ChatRegistry>,
704) -> Result<Value, String> {
705    if !subprotocol.is_empty() {
706        if let Err(e) =
707            tungstenite::http::HeaderValue::from_str(&subprotocol)
708        {
709            return Err(format!(
710                "net.serve_ws_fn_actor: subprotocol {subprotocol:?} is not a valid \
711                 HTTP header value: {e}"
712            ));
713        }
714    }
715    let listener = TcpListener::bind(("127.0.0.1", port))
716        .map_err(|e| format!("net.serve_ws_fn_actor bind {port}: {e}"))?;
717    eprintln!("net.serve_ws_fn_actor: listening on ws://127.0.0.1:{port}");
718    for stream in listener.incoming() {
719        let stream = match stream {
720            Ok(s) => s,
721            Err(e) => { eprintln!("net.serve_ws_fn_actor accept: {e}"); continue; }
722        };
723        let program = Arc::clone(&program);
724        let policy = policy.clone();
725        let name_of_closure = name_of_closure.clone();
726        let on_message_closure = on_message_closure.clone();
727        let subprotocol = subprotocol.clone();
728        let registry = Arc::clone(&registry);
729        thread::spawn(move || {
730            if let Err(e) = handle_connection_fn_actor(
731                stream, program, policy, name_of_closure, on_message_closure,
732                subprotocol, registry,
733            ) {
734                eprintln!("net.serve_ws_fn_actor connection error: {e}");
735            }
736        });
737    }
738    Ok(Value::Unit)
739}
740
741#[allow(clippy::too_many_arguments)]
742fn handle_connection_fn_actor(
743    stream: std::net::TcpStream,
744    program: Arc<Program>,
745    policy: Policy,
746    name_of_closure: Value,
747    on_message_closure: Value,
748    subprotocol: String,
749    registry: Arc<ChatRegistry>,
750) -> Result<(), String> {
751    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
752
753    let mut path = String::new();
754    let path_ref = &mut path;
755    let subproto_for_handshake = subprotocol.clone();
756    let mut ws = accept_hdr(stream, |req: &Request, mut resp: Response| {
757        *path_ref = req.uri().path().to_string();
758        maybe_echo_subprotocol(req, &mut resp, &subproto_for_handshake);
759        Ok(resp)
760    }).map_err(|e| format!("ws handshake: {e}"))?;
761
762    let (tx, rx) = mpsc::channel::<String>();
763    let conn_id = registry.register(path.trim_start_matches('/').to_string(), tx.clone());
764    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
765
766    // Build the WsConn record and ask the user's name_of closure what
767    // name to register this connection under. Empty string is the
768    // documented opt-out (inbound still works, no outbound handle is
769    // exposed). Type mismatch or runtime error aborts the connection —
770    // the same "surface the bug at the source level" reasoning as a
771    // `conc.register` name collision below; silently degrading to an
772    // unregistered connection would have a non-WS caller's
773    // `conc.lookup` return None and conclude the session is offline.
774    let ws_conn = build_ws_conn(conn_id, &path, &subprotocol);
775    let registered_name: Option<String> = {
776        let handler = crate::handler::DefaultHandler::new(policy.clone())
777            .with_program(Arc::clone(&program))
778            .with_chat_registry(Arc::clone(&registry));
779        let mut vm = Vm::with_handler(&program, Box::new(handler));
780        match vm.invoke_closure_value(name_of_closure.clone(), vec![ws_conn.clone()]) {
781            Ok(Value::Str(s)) if !s.is_empty() => Some(s.to_string()),
782            Ok(Value::Str(_)) => None,
783            Ok(other) => {
784                registry.unregister(conn_id);
785                let _ = ws.close(None);
786                return Err(format!(
787                    "net.serve_ws_fn_actor: name_of must return Str, got {other:?}"
788                ));
789            }
790            Err(e) => {
791                registry.unregister(conn_id);
792                let _ = ws.close(None);
793                return Err(format!(
794                    "net.serve_ws_fn_actor: name_of error: {e:?}"
795                ));
796            }
797        }
798    };
799
800    // Register the native bridge actor in the conc registry. The
801    // bridge captures `tx` and writes any inbound message body to the
802    // outbound channel, which the run loop drains into the socket.
803    if let Some(ref name) = registered_name {
804        let tx_for_bridge = tx.clone();
805        let bridge = lex_bytecode::value::NativeActorHandler {
806            send: Box::new(move |msg: Value| -> Result<Value, String> {
807                match msg {
808                    Value::Str(s) => {
809                        tx_for_bridge.send(s.to_string()).map_err(|e| {
810                            format!("net.serve_ws_fn_actor: outbound channel closed: {e}")
811                        })?;
812                        Ok(Value::Unit)
813                    }
814                    other => Err(format!(
815                        "net.serve_ws_fn_actor: native bridge accepts Str messages only, got {other:?}"
816                    )),
817                }
818            }),
819        };
820        let cell = Value::Actor(Arc::new(Mutex::new(lex_bytecode::value::ActorCell {
821            state: Value::Unit,
822            handler: lex_bytecode::value::ActorHandler::Native(Arc::new(bridge)),
823        })));
824        if let Err(e) = lex_bytecode::conc_registry::register(name, cell) {
825            // Name collision: abort the connection. Surfacing the
826            // duplicate immediately is more useful than silently
827            // overwriting whichever session was registered first.
828            registry.unregister(conn_id);
829            let _ = ws.close(None);
830            return Err(format!(
831                "net.serve_ws_fn_actor: conc.register({name:?}) failed: {e:?}"
832            ));
833        }
834    }
835
836    let result = run_loop_fn(
837        &mut ws, &rx, conn_id, &path, &subprotocol,
838        &program, &policy, &on_message_closure, &registry,
839    );
840
841    // Tear down: unregister from both the conc registry (so a subsequent
842    // `conc.lookup(name)` returns None) and the chat registry.
843    if let Some(ref name) = registered_name {
844        let _ = lex_bytecode::conc_registry::unregister(name);
845    }
846    registry.unregister(conn_id);
847    let _ = ws.close(None);
848    result
849}
850
851// ── Closure-based WebSocket client (#390) ────────────────────────────────────
852//
853// Inverse of `serve_ws_fn`: open a connection to a remote WS server and
854// run two Lex callbacks against it.
855//
856// - `on_open : () -> [E] WsAction` is invoked once after the handshake
857//   completes. The returned `WsAction` (typically `WsSend(boot_frame)`)
858//   is applied to the socket immediately. This is the hook for
859//   protocols like OCPP where the client sends a `BootNotification`
860//   the moment it connects.
861// - `on_message : (WsMessage) -> [E] WsAction` is invoked for every
862//   inbound frame. Same `WsAction` semantics as the server-side
863//   handler. A `WsClose` message is delivered once before the loop
864//   exits so handlers can run shutdown logic.
865//
866// Multi-frame sends from `on_open` (e.g. a charger that wants to
867// also kick off a heartbeat scheduler at connect-time) aren't
868// expressible in v1 — the issue's `send :: (Str) -> [net]
869// Result[Unit, Str]` closure would let users push outbound frames
870// from arbitrary `[net]` code, but that requires representing
871// Rust-native closures as Lex `Value`s, which is a separate
872// runtime change. v1 covers the BootNotification + reactive reply
873// pattern that motivates the issue.
874
875fn build_dial_result(ok: Result<(), String>) -> Value {
876    match ok {
877        Ok(()) => Value::Variant {
878            name: "Ok".into(),
879            args: vec![Value::Unit],
880        },
881        Err(msg) => Value::Variant {
882            name: "Err".into(),
883            args: vec![Value::Str(msg.into())],
884        },
885    }
886}
887
888/// `net.dial_ws(url, subprotocol, on_open, on_message) -> [net, E]
889/// Result[Unit, Str]`. Blocks for the lifetime of the connection;
890/// returns `Ok(())` on a clean close from the server, `Err(reason)`
891/// on dial failure, handshake failure, read error, or write error.
892pub fn dial_ws(
893    url: String,
894    subprotocol: String,
895    on_open: Value,
896    on_message: Value,
897    program: Arc<Program>,
898    policy: Policy,
899) -> Result<Value, String> {
900    use tungstenite::client::IntoClientRequest;
901    use tungstenite::http::HeaderValue;
902
903    // Build the request — when `subprotocol` is non-empty, attach the
904    // Sec-WebSocket-Protocol header so the server's accept-handler
905    // can match on it. Empty subprotocol → header omitted (the same
906    // contract as `serve_ws_fn`'s subprotocol arg).
907    //
908    // Caller-controlled inputs (URL syntax, subprotocol header value)
909    // surface as a Lex `Err(reason)`, not a Rust panic / handler
910    // error, so `match net.dial_ws(...) { Err(_) => ..., Ok(_) => ... }`
911    // works at the Lex level.
912    let mut req = match url.as_str().into_client_request() {
913        Ok(r) => r,
914        Err(e) => {
915            return Ok(build_dial_result(Err(format!(
916                "net.dial_ws: bad URL `{url}`: {e}"
917            ))));
918        }
919    };
920    if !subprotocol.is_empty() {
921        let header = match HeaderValue::from_str(&subprotocol) {
922            Ok(h) => h,
923            Err(e) => {
924                return Ok(build_dial_result(Err(format!(
925                    "net.dial_ws: invalid subprotocol `{subprotocol}`: {e}"
926                ))));
927            }
928        };
929        req.headers_mut().insert("Sec-WebSocket-Protocol", header);
930    }
931
932    let (mut ws, _resp) = match tungstenite::connect(req) {
933        Ok(pair) => pair,
934        Err(e) => {
935            return Ok(build_dial_result(Err(format!(
936                "net.dial_ws: connect to `{url}`: {e}"
937            ))));
938        }
939    };
940
941    // Non-blocking-ish reads so we don't tie up the thread on an idle
942    // socket, mirroring the server's read-timeout multiplexing.
943    if let Some(stream) = stream_for(&mut ws) {
944        let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
945    }
946
947    // 1. Fire on_open once and apply its action.
948    {
949        let handler = crate::handler::DefaultHandler::new(policy.clone())
950            .with_program(Arc::clone(&program));
951        let mut vm = Vm::with_handler(&program, Box::new(handler));
952        match vm.invoke_closure_value(on_open.clone(), vec![]) {
953            Ok(action) => {
954                if let Err(e) = apply_ws_action(&action, &mut ws) {
955                    return Ok(build_dial_result(Err(format!(
956                        "net.dial_ws: on_open action: {e}"
957                    ))));
958                }
959            }
960            Err(e) => {
961                return Ok(build_dial_result(Err(format!(
962                    "net.dial_ws: on_open: {e}"
963                ))));
964            }
965        }
966    }
967
968    // 2. Run the read loop, dispatching each inbound frame to on_message.
969    let loop_result = dial_run_loop(&mut ws, &on_message, &program, &policy);
970    let _ = ws.close(None);
971    Ok(build_dial_result(loop_result))
972}
973
974/// Pull the underlying TCP stream out of a `MaybeTlsStream` so we can
975/// set a read timeout. For plaintext connections this is the
976/// `TcpStream` directly; for `rustls`-wrapped streams it's the inner
977/// socket. Returns `None` if the wrapping is some other variant —
978/// in that case we just skip the timeout and rely on blocking reads.
979fn stream_for(
980    ws: &mut tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
981) -> Option<&mut std::net::TcpStream> {
982    use tungstenite::stream::MaybeTlsStream;
983    match ws.get_mut() {
984        MaybeTlsStream::Plain(s) => Some(s),
985        MaybeTlsStream::Rustls(s) => Some(s.get_mut()),
986        _ => None,
987    }
988}
989
990// ── dial_ws_actor — outbound-bridge actor for WS clients (#dial-actor) ──────
991//
992// Variant of `dial_ws` that registers the outgoing connection as a named
993// actor in the conc registry, enabling `conc.tell(actor, frame_str)` to push
994// frames into the socket from any other Lex actor (heartbeat timers,
995// meter-value loops, etc.).
996//
997//   net.dial_ws_actor(
998//     url         :: Str,
999//     subprotocol :: Str,
1000//     name        :: Str,               — conc registry key ("" to skip)
1001//     on_open     :: () -> [E] WsAction,
1002//     on_message  :: (WsMessage) -> [E] WsAction
1003//   ) -> [net, E] Result[Unit, Str]
1004pub fn dial_ws_actor(
1005    url: String,
1006    subprotocol: String,
1007    name: String,
1008    on_open: Value,
1009    on_message: Value,
1010    program: Arc<Program>,
1011    policy: Policy,
1012) -> Result<Value, String> {
1013    use tungstenite::client::IntoClientRequest;
1014    use tungstenite::http::HeaderValue;
1015
1016    let mut req = match url.as_str().into_client_request() {
1017        Ok(r) => r,
1018        Err(e) => {
1019            return Ok(build_dial_result(Err(format!(
1020                "net.dial_ws_actor: bad URL `{url}`: {e}"
1021            ))));
1022        }
1023    };
1024    if !subprotocol.is_empty() {
1025        let header = match HeaderValue::from_str(&subprotocol) {
1026            Ok(h) => h,
1027            Err(e) => {
1028                return Ok(build_dial_result(Err(format!(
1029                    "net.dial_ws_actor: invalid subprotocol `{subprotocol}`: {e}"
1030                ))));
1031            }
1032        };
1033        req.headers_mut().insert("Sec-WebSocket-Protocol", header);
1034    }
1035
1036    let (mut ws, _resp) = match tungstenite::connect(req) {
1037        Ok(pair) => pair,
1038        Err(e) => {
1039            return Ok(build_dial_result(Err(format!(
1040                "net.dial_ws_actor: connect to `{url}`: {e}"
1041            ))));
1042        }
1043    };
1044    if let Some(stream) = stream_for(&mut ws) {
1045        let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
1046    }
1047
1048    // Set up the actor bridge — mpsc channel whose Sender end is wrapped in a
1049    // native actor cell registered in the conc registry.
1050    let (tx, rx) = mpsc::channel::<String>();
1051    if !name.is_empty() {
1052        let tx_for_bridge = tx.clone();
1053        let bridge = lex_bytecode::value::NativeActorHandler {
1054            send: Box::new(move |msg: Value| -> Result<Value, String> {
1055                match msg {
1056                    Value::Str(s) => tx_for_bridge
1057                        .send(s.to_string())
1058                        .map(|_| Value::Unit)
1059                        .map_err(|e| format!("net.dial_ws_actor: outbound channel closed: {e}")),
1060                    other => Err(format!(
1061                        "net.dial_ws_actor: native bridge accepts Str only, got {other:?}"
1062                    )),
1063                }
1064            }),
1065        };
1066        let cell = Value::Actor(Arc::new(Mutex::new(lex_bytecode::value::ActorCell {
1067            state: Value::Unit,
1068            handler: lex_bytecode::value::ActorHandler::Native(Arc::new(bridge)),
1069        })));
1070        if let Err(e) = lex_bytecode::conc_registry::register(&name, cell) {
1071            return Ok(build_dial_result(Err(format!(
1072                "net.dial_ws_actor: conc.register({name:?}) failed: {e:?}"
1073            ))));
1074        }
1075    }
1076
1077    // Fire on_open once, apply its WsAction to the socket.
1078    {
1079        let handler = crate::handler::DefaultHandler::new(policy.clone())
1080            .with_program(Arc::clone(&program));
1081        let mut vm = Vm::with_handler(&program, Box::new(handler));
1082        match vm.invoke_closure_value(on_open.clone(), vec![]) {
1083            Ok(action) => {
1084                if let Err(e) = apply_ws_action(&action, &mut ws) {
1085                    if !name.is_empty() {
1086                        let _ = lex_bytecode::conc_registry::unregister(&name);
1087                    }
1088                    return Ok(build_dial_result(Err(format!(
1089                        "net.dial_ws_actor: on_open action: {e}"
1090                    ))));
1091                }
1092            }
1093            Err(e) => {
1094                if !name.is_empty() {
1095                    let _ = lex_bytecode::conc_registry::unregister(&name);
1096                }
1097                return Ok(build_dial_result(Err(format!(
1098                    "net.dial_ws_actor: on_open: {e}"
1099                ))));
1100            }
1101        }
1102    }
1103
1104    let loop_result = dial_actor_run_loop(&mut ws, &rx, &on_message, &program, &policy);
1105    if !name.is_empty() {
1106        let _ = lex_bytecode::conc_registry::unregister(&name);
1107    }
1108    let _ = ws.close(None);
1109    Ok(build_dial_result(loop_result))
1110}
1111
1112fn dial_actor_run_loop(
1113    ws: &mut tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
1114    rx: &mpsc::Receiver<String>,
1115    on_message: &Value,
1116    program: &Arc<Program>,
1117    policy: &Policy,
1118) -> Result<(), String> {
1119    use std::io::ErrorKind;
1120    use tungstenite::Message;
1121
1122    loop {
1123        let ws_msg = match ws.read() {
1124            Ok(Message::Text(body)) => Some(build_ws_message_text(&body)),
1125            Ok(Message::Binary(payload)) => Some(build_ws_message_binary(&payload)),
1126            Ok(Message::Ping(_)) => Some(build_ws_message_ping()),
1127            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => {
1128                let handler = crate::handler::DefaultHandler::new(policy.clone())
1129                    .with_program(Arc::clone(program));
1130                let mut vm = Vm::with_handler(program, Box::new(handler));
1131                let _ = vm.invoke_closure_value(
1132                    on_message.clone(),
1133                    vec![build_ws_message_close()],
1134                );
1135                return Ok(());
1136            }
1137            Ok(_) => None,
1138            Err(tungstenite::Error::Io(ref e))
1139                if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut =>
1140            {
1141                None
1142            }
1143            Err(e) => return Err(format!("net.dial_ws_actor: read: {e}")),
1144        };
1145
1146        if let Some(msg) = ws_msg {
1147            let handler = crate::handler::DefaultHandler::new(policy.clone())
1148                .with_program(Arc::clone(program));
1149            let mut vm = Vm::with_handler(program, Box::new(handler));
1150            match vm.invoke_closure_value(on_message.clone(), vec![msg]) {
1151                Ok(action) => {
1152                    if let Err(e) = apply_ws_action(&action, ws) {
1153                        return Err(format!("net.dial_ws_actor: action: {e}"));
1154                    }
1155                }
1156                Err(e) => return Err(format!("net.dial_ws_actor: on_message: {e}")),
1157            }
1158        }
1159
1160        // Drain the actor mailbox — frames enqueued by conc.tell(actor, frame).
1161        loop {
1162            match rx.try_recv() {
1163                Ok(msg) => {
1164                    if let Err(e) = ws.send(Message::Text(msg.into())) {
1165                        return Err(format!("net.dial_ws_actor: send: {e}"));
1166                    }
1167                }
1168                Err(mpsc::TryRecvError::Empty) => break,
1169                Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
1170            }
1171        }
1172    }
1173}
1174
1175fn dial_run_loop(
1176    ws: &mut tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
1177    on_message: &Value,
1178    program: &Arc<Program>,
1179    policy: &Policy,
1180) -> Result<(), String> {
1181    use std::io::ErrorKind;
1182    use tungstenite::Message;
1183
1184    loop {
1185        let ws_msg = match ws.read() {
1186            Ok(Message::Text(body)) => Some(build_ws_message_text(&body)),
1187            Ok(Message::Binary(payload)) => Some(build_ws_message_binary(&payload)),
1188            Ok(Message::Ping(_)) => Some(build_ws_message_ping()),
1189            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => {
1190                // Deliver WsClose so the handler can do shutdown work.
1191                let handler = crate::handler::DefaultHandler::new(policy.clone())
1192                    .with_program(Arc::clone(program));
1193                let mut vm = Vm::with_handler(program, Box::new(handler));
1194                let _ = vm.invoke_closure_value(
1195                    on_message.clone(),
1196                    vec![build_ws_message_close()],
1197                );
1198                return Ok(());
1199            }
1200            Ok(_) => None, // pong / raw frame
1201            Err(tungstenite::Error::Io(ref e))
1202                if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut =>
1203            {
1204                None
1205            }
1206            Err(e) => return Err(format!("net.dial_ws: read: {e}")),
1207        };
1208
1209        if let Some(msg) = ws_msg {
1210            let handler = crate::handler::DefaultHandler::new(policy.clone())
1211                .with_program(Arc::clone(program));
1212            let mut vm = Vm::with_handler(program, Box::new(handler));
1213            match vm.invoke_closure_value(on_message.clone(), vec![msg]) {
1214                Ok(action) => {
1215                    if let Err(e) = apply_ws_action(&action, ws) {
1216                        return Err(format!("net.dial_ws: action: {e}"));
1217                    }
1218                }
1219                Err(e) => return Err(format!("net.dial_ws: on_message: {e}")),
1220            }
1221        }
1222    }
1223}