///|
fn last_seen_id_to_json(id : Int64?) -> Json {
match id {
Some(n) => Json::number(n.to_double(), repr=n.to_string())
None => Json::null()
}
}
///|
fn id_to_json(id : Int64) -> Json {
Json::number(id.to_double(), repr=id.to_string())
}
///|
fn parse_optional_int64(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> Int64? raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => None
Some(Number(n, ..)) => Some(n.to_int64())
Some(String(s)) =>
Some(
@string.parse_int64(s) catch {
_ => raise JsonDecodeError((path, "invalid int64 for '\{key}'"))
},
)
_ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
fn parse_optional_bool(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> Bool? raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => None
Some(True) => Some(true)
Some(False) => Some(false)
_ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
fn parse_optional_string(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> String? raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => None
Some(String(s)) => Some(s)
_ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
/// Parse an optional integer field, returning `default` when the key
/// is absent or explicitly null. A peer built before the field
/// existed decodes to the default — this is what makes Register /
/// Connect `proto_version` fully backwards-compatible with v0 peers.
fn parse_int_or_default(
m : Map[String, Json],
key : String,
default~ : Int,
path : @json.JsonPath,
) -> Int raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => default
Some(Number(n, ..)) => n.to_int()
Some(String(s)) =>
@string.parse_int(s) catch {
_ => raise JsonDecodeError((path, "invalid int for '\{key}'"))
}
_ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
fn string_array_to_json(values : Array[String]) -> Json {
values.map(fn(value) { Json::string(value) }).to_json()
}
///|
fn parse_string_array_or_empty(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> Array[String] raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => []
Some(Array(items)) => {
let out : Array[String] = []
for item in items {
match item {
String(value) => out.push(value)
_ => raise JsonDecodeError((path, "invalid entry in '\{key}'"))
}
}
out
}
_ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
fn parse_capability_set_or_empty(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> CapabilitySet raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => CapabilitySet::empty()
Some(json) => @json.FromJson::from_json(json, path.add_key(key))
}
}
///|
fn parse_string_or_default(
m : Map[String, Json],
key : String,
default? : String = "",
) -> String {
match m.get(key) {
Some(String(s)) => s
_ => default
}
}
///|
fn parse_bool_or_default(
m : Map[String, Json],
key : String,
default~ : Bool,
path : @json.JsonPath,
) -> Bool raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => default
Some(True) => true
Some(False) => false
_ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
pub impl ToJson for SysMessage with fn to_json(self) {
match self {
Register(task_uuid~, hostname~, cwd~, proto_version~, server_capabilities~) =>
{
"ch": "sys",
"type": "register",
"task_uuid": task_uuid.to_string(),
"hostname": hostname,
"cwd": cwd,
"proto_version": proto_version,
"server_capabilities": server_capabilities.to_json(),
}
Registered(task_uuid~) =>
{ "ch": "sys", "type": "registered", "task_uuid": task_uuid.to_string() }
RegisterRejected(reason~) => {
// `min_version` rides alongside the reason string so the client
// can tell the operator which version to upgrade to. Only the
// ProtoVersionTooOld variant carries it.
let base : Map[String, Json] = {
"ch": "sys",
"type": "register_rejected",
"reason": reason.to_wire(),
}
match reason {
ProtoVersionTooOld(min_version~) =>
base["min_version"] = Json::number(min_version.to_double())
_ => ()
}
Json::object(base)
}
TaskExited(exit_code~) =>
{ "ch": "sys", "type": "task_exited", "exit_code": exit_code }
Title(title~) => {
let obj : Map[String, Json] = { "ch": "sys", "type": "title" }
if title is Some(value) {
obj["title"] = Json::string(value)
}
Json::object(obj)
}
Connect(task_uuid~, last_seen_id~, proto_version~, client_capabilities~) =>
{
"ch": "sys",
"type": "connect",
"task_uuid": task_uuid.to_string(),
"last_seen_id": last_seen_id_to_json(last_seen_id),
"proto_version": proto_version,
"client_capabilities": client_capabilities.to_json(),
}
Connected(server_proto_version~, server_capabilities~) =>
{
"ch": "sys",
"type": "connected",
"server_proto_version": server_proto_version,
"server_capabilities": server_capabilities.to_json(),
}
TaskOnline(server_proto_version~, server_capabilities~) =>
{
"ch": "sys",
"type": "task_online",
"server_proto_version": server_proto_version,
"server_capabilities": server_capabilities.to_json(),
}
NewClient(
client_id~,
last_seen_id~,
client_proto_version~,
client_capabilities~
) =>
{
"ch": "sys",
"type": "new_client",
"client_id": client_id,
"last_seen_id": last_seen_id_to_json(last_seen_id),
"client_proto_version": client_proto_version,
"client_capabilities": client_capabilities.to_json(),
}
ClientDisconnected(client_id~) =>
{ "ch": "sys", "type": "client_disconnected", "client_id": client_id }
Error(code) => {
let base : Map[String, Json] = {
"ch": "sys",
"type": "error",
"code": code.to_wire(),
}
match code {
ProtoVersionTooOld(min_version~) =>
base["min_version"] = Json::number(min_version.to_double())
_ => ()
}
Json::object(base)
}
AuthError(message~) =>
{ "ch": "sys", "type": "auth_error", "message": message }
}
}
///|
pub impl @json.FromJson for SysMessage with fn from_json(json, path) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
let t = match fields.get("type") {
Some(String(s)) => s
_ => raise JsonDecodeError((path, "missing field 'type'"))
}
match t {
"register" => {
guard fields.get("task_uuid") is Some(String(uuid_str)) else {
raise JsonDecodeError((path, "missing field 'task_uuid'"))
}
let task_uuid = @uuid.from_hex(uuid_str) catch {
_ => raise JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
}
let hostname = match fields.get("hostname") {
Some(String(s)) => s
_ => ""
}
let cwd = match fields.get("cwd") {
Some(String(s)) => s
_ => ""
}
// Missing field = v0 (pre-versioning peer).
let proto_version = parse_int_or_default(
fields,
"proto_version",
default=0,
path,
)
let server_capabilities = parse_capability_set_or_empty(
fields, "server_capabilities", path,
)
Register(
task_uuid~,
hostname~,
cwd~,
proto_version~,
server_capabilities~,
)
}
"registered" => {
guard fields.get("task_uuid") is Some(String(uuid_str)) else {
raise JsonDecodeError((path, "missing field 'task_uuid'"))
}
let task_uuid = @uuid.from_hex(uuid_str) catch {
_ => raise JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
}
Registered(task_uuid~)
}
"register_rejected" => {
guard fields.get("reason") is Some(String(reason_str)) else {
raise JsonDecodeError((path, "missing field 'reason'"))
}
let reason : RegisterRejectReason = match reason_str {
"uuid_already_claimed_this_session" => UuidAlreadyClaimedThisSession
"invalid_uuid" => InvalidUuid
"proto_version_too_old" => {
let min_version = parse_int_or_default(
fields,
"min_version",
default=0,
path,
)
ProtoVersionTooOld(min_version~)
}
_ =>
raise JsonDecodeError(
(path, "unknown register_rejected reason: \{reason_str}"),
)
}
RegisterRejected(reason~)
}
"task_exited" => {
guard fields.get("exit_code") is Some(Number(n, ..)) else {
raise JsonDecodeError((path, "missing field 'exit_code'"))
}
TaskExited(exit_code=n.to_int())
}
"title" => Title(title=parse_optional_string(fields, "title", path))
"connect" => {
guard fields.get("task_uuid") is Some(String(uuid_str)) else {
raise JsonDecodeError((path, "missing field 'task_uuid'"))
}
let task_uuid = @uuid.from_hex(uuid_str) catch {
_ => raise JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
}
let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
let proto_version = parse_int_or_default(
fields,
"proto_version",
default=0,
path,
)
let client_capabilities = parse_capability_set_or_empty(
fields, "client_capabilities", path,
)
Connect(task_uuid~, last_seen_id~, proto_version~, client_capabilities~)
}
"connected" => {
let server_proto_version = parse_int_or_default(
fields,
"server_proto_version",
default=0,
path,
)
let server_capabilities = parse_capability_set_or_empty(
fields, "server_capabilities", path,
)
Connected(server_proto_version~, server_capabilities~)
}
"task_online" => {
let server_proto_version = parse_int_or_default(
fields,
"server_proto_version",
default=0,
path,
)
let server_capabilities = parse_capability_set_or_empty(
fields, "server_capabilities", path,
)
TaskOnline(server_proto_version~, server_capabilities~)
}
"new_client" => {
guard fields.get("client_id") is Some(String(id)) else {
raise JsonDecodeError((path, "missing field 'client_id'"))
}
let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
let client_proto_version = parse_int_or_default(
fields,
"client_proto_version",
default=0,
path,
)
let client_capabilities = parse_capability_set_or_empty(
fields, "client_capabilities", path,
)
NewClient(
client_id=id,
last_seen_id~,
client_proto_version~,
client_capabilities~,
)
}
"client_disconnected" => {
guard fields.get("client_id") is Some(String(id)) else {
raise JsonDecodeError((path, "missing field 'client_id'"))
}
ClientDisconnected(client_id=id)
}
"error" => {
guard fields.get("code") is Some(String(code_str)) else {
raise JsonDecodeError((path, "missing field 'code'"))
}
let code : ErrorCode = match code_str {
"server_not_found" => TaskNotFound
"server_offline" => TaskOffline
"internal_error" => InternalError
"proto_version_too_old" => {
let min_version = parse_int_or_default(
fields,
"min_version",
default=0,
path,
)
ProtoVersionTooOld(min_version~)
}
_ => raise JsonDecodeError((path, "unknown error code: \{code_str}"))
}
Error(code)
}
"auth_error" => {
guard fields.get("message") is Some(String(msg)) else {
raise JsonDecodeError((path, "missing field 'message'"))
}
AuthError(message=msg)
}
_ => raise JsonDecodeError((path, "unknown sys message type: \{t}"))
}
}
///|
pub impl ToJson for TermMessage with fn to_json(self) {
match self {
ClientReplayCursor(client_id~, last_seen_id~) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "client_replay_cursor",
"client_id": client_id,
}
obj["last_seen_id"] = last_seen_id_to_json(last_seen_id)
Json::object(obj)
}
Viewport(client_id~, cols~, rows~, active~) =>
{
"ch": "t",
"type": "viewport",
"client_id": client_id,
"cols": cols,
"rows": rows,
"active": active,
}
RenderFrame(
client_id~,
seq~,
cols~,
rows~,
scroll_offset~,
scrollback_rows~,
title~,
mouse~,
data~
) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "render_frame",
"client_id": client_id,
"seq": id_to_json(seq),
"cols": cols,
"rows": rows,
"scroll_offset": id_to_json(scroll_offset),
"scrollback_rows": id_to_json(scrollback_rows),
"data": data,
}
if title is Some(value) {
obj["title"] = Json::string(value)
}
if mouse is Some(value) {
obj["mouse"] = value.to_json()
}
Json::object(obj)
}
RenderState(
client_id~,
seq~,
cols~,
rows~,
scroll_offset~,
scrollback_rows~,
title~,
mouse~,
frame~
) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "render_state",
"client_id": client_id,
"seq": id_to_json(seq),
"cols": cols,
"rows": rows,
"scroll_offset": id_to_json(scroll_offset),
"scrollback_rows": id_to_json(scrollback_rows),
}
if title is Some(value) {
obj["title"] = Json::string(value)
}
if mouse is Some(value) {
obj["mouse"] = value.to_json()
}
if frame.full {
obj["full"] = frame.full.to_json()
}
if frame.styles.length() != 0 {
obj["styles"] = frame.styles.to_json()
}
obj["lines"] = frame.lines.to_json()
if frame.cursor is Some(cursor) {
obj["cursor"] = cursor.to_json()
}
Json::object(obj)
}
InputText(client_id~, text~) =>
{ "ch": "t", "type": "input_text", "client_id": client_id, "text": text }
InputKey(client_id~, key~, mods~, text~) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "input_key",
"client_id": client_id,
"key": key,
"mods": string_array_to_json(mods),
}
if text is Some(value) {
obj["text"] = Json::string(value)
}
Json::object(obj)
}
InputPaste(client_id~, text~) =>
{ "ch": "t", "type": "input_paste", "client_id": client_id, "text": text }
InputMouse(client_id~, x~, y~, button~, action~, mods~) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "input_mouse",
"client_id": client_id,
"x": x,
"y": y,
"action": action,
"mods": string_array_to_json(mods),
}
if button is Some(button) {
obj["button"] = Json::string(button)
}
Json::object(obj)
}
InputScroll(client_id~, x~, y~, delta_rows~, mods~) =>
{
"ch": "t",
"type": "input_scroll",
"client_id": client_id,
"x": x,
"y": y,
"delta_rows": delta_rows,
"mods": string_array_to_json(mods),
}
ScrollToLive(client_id~) =>
{ "ch": "t", "type": "scroll_to_live", "client_id": client_id }
Theme(client_id~, background~, foreground~) =>
{
"ch": "t",
"type": "theme",
"client_id": client_id,
"background": background,
"foreground": foreground,
}
Data(client_id~, id~, created~, replay~, data~) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "data",
"client_id": client_id,
"id": id_to_json(id),
"data": data,
}
// Omit when absent so old decoders still see a compact frame and
// only new decoders observe the field.
if created is Some(ts) {
obj["created"] = id_to_json(ts)
}
if replay is Some(b) {
obj["replay"] = b.to_json()
}
Json::object(obj)
}
Exited(client_id~, id~) =>
{
"ch": "t",
"type": "exited",
"client_id": client_id,
"id": id_to_json(id),
}
}
}
///|
pub impl @json.FromJson for TermMessage with fn from_json(json, path) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
let t = match fields.get("type") {
Some(String(s)) => s
_ => raise JsonDecodeError((path, "missing field 'type'"))
}
match t {
"client_replay_cursor" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
ClientReplayCursor(client_id~, last_seen_id~)
}
"viewport" => {
let client_id = parse_string_or_default(fields, "client_id")
let cols = parse_int_or_default(fields, "cols", default=0, path)
let rows = parse_int_or_default(fields, "rows", default=0, path)
let active = parse_bool_or_default(fields, "active", default=false, path)
Viewport(client_id~, cols~, rows~, active~)
}
"render_frame" => {
let client_id = parse_string_or_default(fields, "client_id")
let seq = parse_optional_int64(fields, "seq", path).unwrap_or(0L)
let cols = parse_int_or_default(fields, "cols", default=0, path)
let rows = parse_int_or_default(fields, "rows", default=0, path)
let scroll_offset = parse_optional_int64(fields, "scroll_offset", path).unwrap_or(
0L,
)
let scrollback_rows = parse_optional_int64(
fields, "scrollback_rows", path,
).unwrap_or(0L)
let title = parse_optional_string(fields, "title", path)
let mouse = parse_optional_bool(fields, "mouse", path)
guard fields.get("data") is Some(String(data)) else {
raise JsonDecodeError((path, "missing field 'data'"))
}
RenderFrame(
client_id~,
seq~,
cols~,
rows~,
scroll_offset~,
scrollback_rows~,
title~,
mouse~,
data~,
)
}
"render_state" => {
let client_id = parse_string_or_default(fields, "client_id")
let seq = parse_optional_int64(fields, "seq", path).unwrap_or(0L)
let cols = parse_int_or_default(fields, "cols", default=0, path)
let rows = parse_int_or_default(fields, "rows", default=0, path)
let scroll_offset = parse_optional_int64(fields, "scroll_offset", path).unwrap_or(
0L,
)
let scrollback_rows = parse_optional_int64(
fields, "scrollback_rows", path,
).unwrap_or(0L)
let title = parse_optional_string(fields, "title", path)
let mouse = parse_optional_bool(fields, "mouse", path)
let full = parse_bool_or_default(fields, "full", default=false, path)
let styles : Array[RunStyle] = match fields.get("styles") {
Some(styles_json) => @json.from_json(styles_json, path~)
_ => []
}
let lines : Array[Line] = match fields.get("lines") {
Some(lines_json) => @json.from_json(lines_json, path~)
_ => []
}
let cursor : Cursor? = match fields.get("cursor") {
Some(cursor_json) => Some(@json.from_json(cursor_json, path~))
_ => None
}
RenderState(
client_id~,
seq~,
cols~,
rows~,
scroll_offset~,
scrollback_rows~,
title~,
mouse~,
frame={ full, styles, lines, cursor },
)
}
"input_text" => {
let client_id = parse_string_or_default(fields, "client_id")
let text = parse_string_or_default(fields, "text")
InputText(client_id~, text~)
}
"input_key" => {
let client_id = parse_string_or_default(fields, "client_id")
let key = parse_string_or_default(fields, "key")
let mods = parse_string_array_or_empty(fields, "mods", path)
let text = parse_optional_string(fields, "text", path)
InputKey(client_id~, key~, mods~, text~)
}
"input_paste" => {
let client_id = parse_string_or_default(fields, "client_id")
let text = parse_string_or_default(fields, "text")
InputPaste(client_id~, text~)
}
"input_mouse" => {
let client_id = parse_string_or_default(fields, "client_id")
let x = parse_int_or_default(fields, "x", default=0, path)
let y = parse_int_or_default(fields, "y", default=0, path)
// Legacy senders spelled "no button" as an empty string; normalize it
// so the typed message never carries the sentinel.
let button = parse_optional_string(fields, "button", path).filter(value => {
value != ""
})
let action = parse_string_or_default(fields, "action")
let mods = parse_string_array_or_empty(fields, "mods", path)
InputMouse(client_id~, x~, y~, button~, action~, mods~)
}
"input_scroll" => {
let client_id = parse_string_or_default(fields, "client_id")
let x = parse_int_or_default(fields, "x", default=0, path)
let y = parse_int_or_default(fields, "y", default=0, path)
let delta_rows = parse_int_or_default(
fields,
"delta_rows",
default=0,
path,
)
let mods = parse_string_array_or_empty(fields, "mods", path)
InputScroll(client_id~, x~, y~, delta_rows~, mods~)
}
"scroll_to_live" => {
let client_id = parse_string_or_default(fields, "client_id")
ScrollToLive(client_id~)
}
"theme" => {
let client_id = parse_string_or_default(fields, "client_id")
let background = parse_string_or_default(fields, "background")
let foreground = parse_string_or_default(fields, "foreground")
Theme(client_id~, background~, foreground~)
}
"data" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
let id = parse_optional_int64(fields, "id", path).unwrap_or(0L)
let created = parse_optional_int64(fields, "created", path)
let replay = parse_optional_bool(fields, "replay", path)
guard fields.get("data") is Some(String(data)) else {
raise JsonDecodeError((path, "missing field 'data'"))
}
Data(client_id~, id~, created~, replay~, data~)
}
"exited" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
let id = parse_optional_int64(fields, "id", path).unwrap_or(0L)
Exited(client_id~, id~)
}
_ => raise JsonDecodeError((path, "unknown term message type: \{t}"))
}
}
///|
pub impl ToJson for Message with fn to_json(self) {
match self {
Sys(msg) => msg.to_json()
Fs(msg) => msg.to_json()
Ide(msg) => msg.to_json()
Term(msg) => msg.to_json()
Codex(client_id~, data~) =>
{ "ch": "codex", "client_id": client_id, "data": data }
}
}
///|
pub impl @json.FromJson for Message with fn from_json(json, path) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
let ch = match fields.get("ch") {
Some(String(c)) => c
_ => "sys"
}
match ch {
"sys" => Sys(@json.from_json(json, path~))
"t" => Term(@json.from_json(json, path~))
"fs" => Fs(@json.from_json(json, path~))
"ide" => Ide(@json.from_json(json, path~))
"codex" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
guard fields.get("data") is Some(String(data)) else {
raise JsonDecodeError((path, "missing field 'data'"))
}
Codex(client_id~, data~)
}
_ => raise JsonDecodeError((path, "unknown channel: \{ch}"))
}
}