// ── Wire protocol ────────────────────────────────────────────────────────────
//
// Command bridge using webview_bind for JS→MoonBit calls (non-blocking).
//
// JS → MoonBit:
// window.__moonbit_command(name, payload) → webview_bind callback → async task
//
// MoonBit → JS:
// webview.eval(__dispatch([name, payload])) → CustomEvent listener
//
// The bind callback spawns async tasks immediately, so webview_run never blocks.
///|
/// Structured response returned from JS → MoonBit commands.
pub(all) enum CommandResponse {
Ok(Json)
Error(String)
} derive(Eq)
///|
fn[Payload : ToJson] CommandResponse::ok(payload : Payload) -> CommandResponse {
Ok(payload.to_json())
}
///|
fn CommandResponse::error(message : String) -> CommandResponse {
Error(message)
}
///|
fn CommandResponse::to_wire(self : CommandResponse) -> Bytes {
match self {
Ok(payload) => @encoding/utf8.encode(@json.to_json(payload).stringify())
Error(msg) => @encoding/utf8.encode("\{msg}")
}
}
// ── CommandBridge ─────────────────────────────────────────────────────────────
///|
/// High-level command bridge using webview_bind.
///
/// JS API:
/// - `window[global_name].send(name, payload)` → calls MoonBit command
struct CommandBridge[X] {
webview : WebView[X]
binding_name : String
handlers : Map[String, (Json, String, CommandBridge[X]) -> Unit]
}
///|
/// Creates and starts a command bridge for the given webview.
fn[X] CommandBridge::new(
webview : WebView[X],
global_name? : String = "lepusBridge",
binding_name? : String = "__moonbit_command",
) -> CommandBridge[X] {
let bridge = CommandBridge::{ webview, binding_name, handlers: {}, }
bridge.webview.init(make_bridge_script(global_name, binding_name))
bridge.start_dispatch()
bridge
}
///|
fn[X] CommandBridge::destroy(self : CommandBridge[X]) -> Unit {
self.handlers.clear()
}
// ── Dispatch via webview_bind ───────────────────────────────────────────────
///|
fn[X] CommandBridge::start_dispatch(self : CommandBridge[X]) -> Unit {
let binding = webview_bind(
self.webview.handle,
@encoding/utf8.encode(self.binding_name),
fn(raw_id, raw_req, f) { f(raw_id, raw_req) },
fn(raw_id, raw_req) {
let id = webview_copy_cstr(raw_id)
let req = webview_copy_cstr(raw_req)
let id_str = @encoding/utf8.decode(id) catch { Malformed(_) => "" }
let req_str = @encoding/utf8.decode(req) catch { Malformed(_) => "" }
log(1, "[bridge:recv] id=\{id_str} req=\{req_str}")
self.dispatch(id_str, req_str)
},
)
ignore(binding)
}
///|
fn[X] CommandBridge::dispatch(
self : CommandBridge[X],
id : String,
req : String,
) -> Unit {
let parsed = json_parse(req)
match parsed {
[String(name), payload] => {
log(1, "[bridge:dispatch] name=\{name}")
match self.handlers.get(name) {
Some(h) => h(payload, id, self)
None => {
let resp = CommandResponse::error("Unknown command: " + name)
self.send_response(id, resp)
}
}
}
_ => {
let resp = CommandResponse::error("Bad request format")
self.send_response(id, resp)
}
}
}
///|
fn json_parse(data : String) -> Json {
let v = @json.parse(data) catch { _ => Json::null() }
v
}
///|
fn[X] CommandBridge::send_response(
self : CommandBridge[X],
id : String,
resp : CommandResponse,
) -> Unit {
log(1, "[bridge:send] id=\{id}")
let wire = resp.to_wire()
let status = match resp {
Ok(_) => 0
Error(_) => 1
}
ignore(
wm_return_raw(
self.webview.window_id,
@encoding/utf8.encode(id),
status,
wire,
),
)
}
// ── Handler registration ──────────────────────────────────────────────────────
///|
/// Registers an async typed command handler.
///
/// The callback is an async function. It will be spawned as a background task
/// so it doesn't block the webview event loop.
fn[X, Payload : @json.FromJson, Reply : ToJson] CommandBridge::handle_sync(
self : CommandBridge[X],
name : String,
callback : (Payload) -> Reply,
) -> Unit {
self.handlers.set(name, fn(json, id, bridge) {
let payload = @json.from_json(json) catch {
msg => {
bridge.send_response(id, CommandResponse::error("\{msg}"))
return
}
}
bridge.send_response(id, CommandResponse::ok(callback(payload)))
})
}
///|
/// Registers an async typed command handler.
///
/// The callback is an async function. It will be spawned as a background task
/// so it doesn't block the webview event loop.
fn[X, Payload : @json.FromJson, Reply : ToJson] CommandBridge::handle_async(
self : CommandBridge[X],
name : String,
callback : async (Payload) -> Reply,
) -> Unit {
self.handlers.set(name, fn(json, id, bridge) {
let payload = @json.from_json(json) catch {
msg => {
bridge.send_response(id, CommandResponse::error("\{msg}"))
return
}
}
self.webview.task_group.spawn_bg(() => {
let reply = callback(payload)
bridge.send_response(id, CommandResponse::ok(reply))
})
})
}
///|
/// Registers a typed command handler that runs on a detached native thread.
///
/// This is useful when the callback performs blocking work that should not hold
/// the webview UI thread, while still responding asynchronously to JavaScript.
fn[X, Payload : @json.FromJson, Reply : ToJson] CommandBridge::handle_result_bg(
self : CommandBridge[X],
name : String,
callback : (Payload) -> Reply raise,
) -> Unit {
self.handlers.set(name, fn(json, id, bridge) {
let payload = @json.from_json(json) catch {
msg => {
bridge.send_response(id, CommandResponse::error("\{msg}"))
return
}
}
ignore(
run_in_background_thread(fn(task) { task() }, () => {
let reply = callback(payload) catch {
message => {
bridge.send_response(
id,
CommandResponse::error(message.to_string()),
)
return
}
}
bridge.send_response(id, CommandResponse::ok(reply))
}),
)
})
}
///|
/// Registers an async typed command handler that can raise errors.
fn[X, Payload : @json.FromJson, Reply : ToJson] CommandBridge::handle_result_async(
self : CommandBridge[X],
name : String,
callback : async (Payload) -> Reply raise Error,
) -> Unit {
self.handlers.set(name, fn(json, id, bridge) {
let payload = @json.from_json(json) catch {
msg => {
bridge.send_response(id, CommandResponse::error("\{msg}"))
return
}
}
self.webview.task_group.spawn_bg(() => {
let reply = callback(payload) catch {
message => {
bridge.send_response(id, CommandResponse::error(message.to_string()))
return
}
}
bridge.send_response(id, CommandResponse::ok(reply))
})
})
}
// ── JS scripts ────────────────────────────────────────────────────────────────
///|
fn make_bridge_script(global_name : String, binding_name : String) -> String {
let g = Json::string(global_name).stringify()
let b = Json::string(binding_name).stringify()
//Readable form:
// window[G] = {
// send: (n, p) => window[B](n, p),
// }
(
$|!function(){const G=\{g},B=\{b};window[G]={send:(n,p)=>window[B](n,p??null)};}();
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
///|
test "CommandResponse ok encodes as raw JSON payload" {
let r = CommandResponse::ok({ "x": Json::number(1) })
let wire = r.to_wire()
let decoded = @encoding/utf8.decode(wire)
assert_true(decoded == "{\"x\":1}")
}
///|
test "CommandResponse error encodes as error message string" {
let r = CommandResponse::error("oops")
let wire = r.to_wire()
let decoded = @encoding/utf8.decode(wire)
assert_true(decoded == "oops")
}