|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// BoJ Server — Unified WebSocket Adapter |
| 5 | +// |
| 6 | +// A generic, high-performance WebSocket host that exposes cartridge |
| 7 | +// capabilities over bidirectional JSON-RPC streams. |
| 8 | +// |
| 9 | +// Features: |
| 10 | +// - Unified Broker: Manages rooms (one per cartridge) |
| 11 | +// - JSON-RPC 2.0: Standardized message format |
| 12 | +// - Broadcast: Cartridges can push events to room members |
| 13 | +// - Lazy Rooms: Rooms are created only when a client joins |
| 14 | +// |
| 15 | +// Port: 7704 (default) |
| 16 | + |
| 17 | +module main |
| 18 | + |
| 19 | +import json |
| 20 | +import net |
| 21 | +import sync |
| 22 | +import time |
| 23 | + |
| 24 | +// ═══════════════════════════════════════════════════════════════════════ |
| 25 | +// WebSocket Protocol Types |
| 26 | +// ═══════════════════════════════════════════════════════════════════════ |
| 27 | + |
| 28 | +struct WsRequest { |
| 29 | + jsonrpc string = "2.0" |
| 30 | + id string |
| 31 | + method string |
| 32 | + params string // JSON-encoded parameters |
| 33 | +} |
| 34 | + |
| 35 | +struct WsResponse { |
| 36 | + jsonrpc string = "2.0" |
| 37 | + id string |
| 38 | + result string // JSON-encoded result |
| 39 | + error WsError |
| 40 | +} |
| 41 | + |
| 42 | +struct WsError { |
| 43 | + code int |
| 44 | + message string |
| 45 | +} |
| 46 | + |
| 47 | +struct WsNotification { |
| 48 | + jsonrpc string = "2.0" |
| 49 | + method string |
| 50 | + params string // JSON-encoded parameters |
| 51 | +} |
| 52 | + |
| 53 | +// ═══════════════════════════════════════════════════════════════════════ |
| 54 | +// Broker — Unified Client Management |
| 55 | +// ═══════════════════════════════════════════════════════════════════════ |
| 56 | + |
| 57 | +struct WsClient { |
| 58 | + id int |
| 59 | +mut: |
| 60 | + conn &net.TcpConn |
| 61 | +} |
| 62 | + |
| 63 | +struct UnifiedBroker { |
| 64 | +mut: |
| 65 | + mu sync.Mutex |
| 66 | + rooms map[string][]&WsClient // cartridge name -> list of clients |
| 67 | + next_id int |
| 68 | +} |
| 69 | + |
| 70 | +fn UnifiedBroker.new() UnifiedBroker { |
| 71 | + return UnifiedBroker{ |
| 72 | + rooms: map[string][]&WsClient{} |
| 73 | + next_id: 1 |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +fn (mut b UnifiedBroker) join(cartridge string, conn &net.TcpConn) int { |
| 78 | + b.mu.@lock() |
| 79 | + defer { b.mu.unlock() } |
| 80 | + |
| 81 | + id := b.next_id |
| 82 | + b.next_id++ |
| 83 | + |
| 84 | + client := &WsClient{ |
| 85 | + id: id |
| 86 | + conn: conn |
| 87 | + } |
| 88 | + |
| 89 | + if cartridge in b.rooms { |
| 90 | + b.rooms[cartridge] << client |
| 91 | + } else { |
| 92 | + b.rooms[cartridge] = [client] |
| 93 | + } |
| 94 | + return id |
| 95 | +} |
| 96 | + |
| 97 | +fn (mut b UnifiedBroker) leave(cartridge string, client_id int) { |
| 98 | + b.mu.@lock() |
| 99 | + defer { b.mu.unlock() } |
| 100 | + |
| 101 | + if cartridge in b.rooms { |
| 102 | + b.rooms[cartridge] = b.rooms[cartridge].filter(it.id != client_id) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +fn (mut b UnifiedBroker) broadcast(cartridge string, method string, params string) { |
| 107 | + msg := json.encode(WsNotification{ |
| 108 | + method: method |
| 109 | + params: params |
| 110 | + }) |
| 111 | + |
| 112 | + b.mu.@lock() |
| 113 | + clients := if cartridge in b.rooms { b.rooms[cartridge].clone() } else { []&WsClient{} } |
| 114 | + b.mu.unlock() |
| 115 | + |
| 116 | + for c in clients { |
| 117 | + c.conn.write(msg.bytes()) or { continue } |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +// ═══════════════════════════════════════════════════════════════════════ |
| 122 | +// Server Implementation |
| 123 | +// ═══════════════════════════════════════════════════════════════════════ |
| 124 | + |
| 125 | +fn (mut app BojApp) start_websocket_server(port int) ! { |
| 126 | + mut broker := UnifiedBroker.new() |
| 127 | + mut listener := net.listen_tcp(.ip, ':${port}') or { |
| 128 | + return error('failed to bind WebSocket server on port ${port}: ${err}') |
| 129 | + } |
| 130 | + |
| 131 | + for { |
| 132 | + mut conn := listener.accept() or { continue } |
| 133 | + |
| 134 | + // Initial handshake: Client must send a JOIN frame |
| 135 | + // Expected: {"jsonrpc": "2.0", "method": "join", "params": {"cartridge": "nesy-mcp"}} |
| 136 | + mut buf := []u8{len: 1024} |
| 137 | + n := conn.read(mut buf) or { |
| 138 | + conn.close() or {} |
| 139 | + continue |
| 140 | + } |
| 141 | + |
| 142 | + join_req := json.decode(WsRequest, buf[..n].bytestr()) or { |
| 143 | + conn.write('{"error": "invalid join frame"}'.bytes()) or {} |
| 144 | + conn.close() or {} |
| 145 | + continue |
| 146 | + } |
| 147 | + |
| 148 | + if join_req.method != "join" { |
| 149 | + conn.write('{"error": "must join a cartridge room first"}'.bytes()) or {} |
| 150 | + conn.close() or {} |
| 151 | + continue |
| 152 | + } |
| 153 | + |
| 154 | + cartridge_params := json.decode(map[string]string, join_req.params) or { |
| 155 | + conn.write('{"error": "invalid join params"}'.bytes()) or {} |
| 156 | + conn.close() or {} |
| 157 | + continue |
| 158 | + } |
| 159 | + |
| 160 | + cname := cartridge_params["cartridge"] or { "" } |
| 161 | + if cname == "" { |
| 162 | + conn.write('{"error": "missing cartridge name"}'.bytes()) or {} |
| 163 | + conn.close() or {} |
| 164 | + continue |
| 165 | + } |
| 166 | + |
| 167 | + // Verify cartridge is mounted in BoJ catalogue |
| 168 | + mut mounted := false |
| 169 | + for c in app.cartridges { |
| 170 | + if c.name == cname { |
| 171 | + if C.boj_catalogue_is_mounted(c.index) == 1 { |
| 172 | + mounted = true |
| 173 | + } |
| 174 | + break |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + if !mounted { |
| 179 | + conn.write('{"error": "cartridge not mounted"}'.bytes()) or {} |
| 180 | + conn.close() or {} |
| 181 | + continue |
| 182 | + } |
| 183 | + |
| 184 | + client_id := broker.join(cname, &conn) |
| 185 | + go handle_ws_client(mut app, mut &broker, mut conn, cname, client_id) |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +fn handle_ws_client(mut app &BojApp, mut broker &UnifiedBroker, mut conn net.TcpConn, cname string, client_id int) { |
| 190 | + defer { |
| 191 | + broker.leave(cname, client_id) |
| 192 | + conn.close() or {} |
| 193 | + } |
| 194 | + |
| 195 | + mut buf := []u8{len: 4096} |
| 196 | + for { |
| 197 | + n := conn.read(mut buf) or { break } |
| 198 | + if n == 0 { break } |
| 199 | + |
| 200 | + req := json.decode(WsRequest, buf[..n].bytestr()) or { |
| 201 | + conn.write('{"error": "invalid json-rpc"}'.bytes()) or {} |
| 202 | + continue |
| 203 | + } |
| 204 | + |
| 205 | + // Generic Dispatch: Use existing handle_cartridge_invoke logic |
| 206 | + // We re-use the REST invoke handler to process the tool call |
| 207 | + resp_body := handle_cartridge_invoke(app, cname, json.encode({ |
| 208 | + "tool": req.method, |
| 209 | + "args": req.params |
| 210 | + })) |
| 211 | + |
| 212 | + ws_resp := WsResponse{ |
| 213 | + id: req.id |
| 214 | + result: resp_body.body |
| 215 | + } |
| 216 | + |
| 217 | + conn.write(json.encode(ws_resp).bytes()) or { break } |
| 218 | + } |
| 219 | +} |
0 commit comments