use crate::events::{self, KickEvent};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::{BTreeSet, HashMap};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionOptions {
pub channels: Vec<String>,
pub client_name: String,
pub silence_grace_ms: u64,
pub refresh_lead_ms: u64,
}
impl SessionOptions {
pub fn new(channels: Vec<String>) -> Self {
Self { channels, client_name: "js".into(), silence_grace_ms: 20_000, refresh_lead_ms: 120_000 }
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "action", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Action {
Send {
text: String,
},
Close {
reason: String,
},
RefreshToken,
Connected {
client: Option<String>,
},
Subscribed {
channel: String,
},
Unsubscribed {
channel: String,
code: i64,
reason: String,
},
SubscribeFailed {
channel: String,
code: i64,
message: String,
},
Event {
channel: String,
name: String,
data: Value,
event: KickEvent,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Idle,
Connecting,
Connected,
Closing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Pending {
Connect,
Subscribe(String),
Refresh,
}
#[derive(Debug, Clone)]
pub struct Session {
options: SessionOptions,
phase: Phase,
next_id: u64,
pending: HashMap<u64, Pending>,
subscribed: BTreeSet<String>,
ping_ms: u64,
last_traffic: u64,
refresh_at: Option<u64>,
refresh_asked: bool,
}
impl Session {
pub fn new(options: SessionOptions) -> Self {
Self {
options,
phase: Phase::Idle,
next_id: 0,
pending: HashMap::new(),
subscribed: BTreeSet::new(),
ping_ms: 25_000,
last_traffic: 0,
refresh_at: None,
refresh_asked: false,
}
}
pub fn is_subscribed(&self, channel: &str) -> bool {
self.subscribed.contains(channel)
}
pub fn is_connected(&self) -> bool {
self.phase == Phase::Connected
}
fn command(&mut self, pending: Pending, body: Value) -> String {
self.next_id += 1;
let id = self.next_id;
self.pending.insert(id, pending);
let mut frame = json!({ "id": id });
if let (Value::Object(f), Value::Object(b)) = (&mut frame, body) {
f.extend(b);
}
frame.to_string()
}
pub fn open(&mut self, token: &str, now: u64) -> Vec<Action> {
self.reset();
self.phase = Phase::Connecting;
self.last_traffic = now;
let name = self.options.client_name.clone();
let text = self.command(Pending::Connect, json!({ "connect": { "token": token, "name": name } }));
vec![Action::Send { text }]
}
pub fn refresh(&mut self, token: &str, _now: u64) -> Vec<Action> {
if self.phase != Phase::Connected {
return vec![];
}
let text = self.command(Pending::Refresh, json!({ "refresh": { "token": token } }));
vec![Action::Send { text }]
}
pub fn receive(&mut self, text: &str, now: u64) -> Vec<Action> {
let mut out = Vec::new();
if matches!(self.phase, Phase::Idle | Phase::Closing) {
return out;
}
self.last_traffic = now;
for line in text.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "{}" {
out.push(Action::Send { text: "{}".into() });
continue;
}
let Ok(Value::Object(msg)) = serde_json::from_str::<Value>(line) else { continue };
let msg = Value::Object(msg);
match msg.get("id").and_then(Value::as_u64) {
Some(id) if id > 0 => self.reply(id, &msg, now, &mut out),
_ => {
if let Some(push) = msg.get("push") {
self.push(push, &mut out);
}
}
}
if self.phase == Phase::Closing {
break;
}
}
out
}
fn reply(&mut self, id: u64, msg: &Value, now: u64, out: &mut Vec<Action>) {
let Some(pending) = self.pending.remove(&id) else { return };
let error = msg.get("error").map(|e| {
(
e.get("code").and_then(Value::as_i64).unwrap_or(0),
e.get("message").and_then(Value::as_str).unwrap_or("").to_owned(),
)
});
match (pending, error) {
(Pending::Connect, Some((code, message))) => {
self.close(format!("Kick refused the connection: {code} {message}"), out);
}
(Pending::Connect, None) => {
let reply = msg.get("connect").cloned().unwrap_or_default();
self.phase = Phase::Connected;
self.schedule(&reply, now);
out.push(Action::Connected { client: reply.get("client").and_then(Value::as_str).map(str::to_owned) });
let frames: Vec<String> = self
.options
.channels
.clone()
.into_iter()
.map(|channel| {
self.command(
Pending::Subscribe(channel.clone()),
json!({ "subscribe": { "channel": channel } }),
)
})
.collect();
if !frames.is_empty() {
out.push(Action::Send { text: frames.join("\n") });
}
}
(Pending::Subscribe(channel), Some((code, message))) => {
out.push(Action::SubscribeFailed { channel, code, message });
}
(Pending::Subscribe(channel), None) => {
self.subscribed.insert(channel.clone());
out.push(Action::Subscribed { channel });
}
(Pending::Refresh, Some((code, message))) => {
self.close(format!("Kick refused the new token: {code} {message}"), out);
}
(Pending::Refresh, None) => {
let reply = msg.get("refresh").cloned().unwrap_or_default();
self.schedule(&reply, now);
}
}
}
fn push(&mut self, push: &Value, out: &mut Vec<Action>) {
let channel = push.get("channel").and_then(Value::as_str).unwrap_or("").to_owned();
if let Some(d) = push.get("disconnect") {
let code = d.get("code").and_then(Value::as_i64).unwrap_or(0);
let reason = d.get("reason").and_then(Value::as_str).unwrap_or("");
self.close(format!("Kick disconnected: {code} {reason}"), out);
} else if let Some(u) = push.get("unsub") {
if self.subscribed.remove(&channel) {
out.push(Action::Unsubscribed {
channel,
code: u.get("code").and_then(Value::as_i64).unwrap_or(0),
reason: u.get("reason").and_then(Value::as_str).unwrap_or("").to_owned(),
});
}
} else if let Some(envelope) = push.get("pub").and_then(|p| p.get("data")) {
if let Some(name) = envelope.get("event").and_then(Value::as_str) {
let data = envelope.get("data").cloned().unwrap_or(Value::Null);
let event = events::parse(name, &data);
out.push(Action::Event { channel, name: name.to_owned(), data, event });
}
}
}
fn schedule(&mut self, reply: &Value, now: u64) {
if let Some(ping) = reply.get("ping").and_then(Value::as_u64).filter(|p| *p > 0) {
self.ping_ms = ping * 1000;
}
let expires = reply.get("expires").and_then(Value::as_bool).unwrap_or(false);
let ttl_ms = reply.get("ttl").and_then(Value::as_u64).unwrap_or(0) * 1000;
self.refresh_asked = false;
self.refresh_at =
(expires && ttl_ms > 0).then(|| now + ttl_ms.saturating_sub(self.options.refresh_lead_ms).max(ttl_ms / 2));
}
fn close(&mut self, reason: String, out: &mut Vec<Action>) {
if self.phase != Phase::Closing {
self.phase = Phase::Closing;
out.push(Action::Close { reason });
}
}
pub fn tick(&mut self, now: u64) -> Vec<Action> {
let mut out = Vec::new();
if matches!(self.phase, Phase::Idle | Phase::Closing) {
return out;
}
if now >= self.silence_deadline() {
self.close("Kick went quiet".into(), &mut out);
return out;
}
if let Some(at) = self.refresh_at
&& !self.refresh_asked
&& now >= at
{
self.refresh_asked = true;
out.push(Action::RefreshToken);
}
out
}
fn silence_deadline(&self) -> u64 {
self.last_traffic + self.ping_ms + self.options.silence_grace_ms
}
pub fn deadline(&self) -> Option<u64> {
if matches!(self.phase, Phase::Idle | Phase::Closing) {
return None;
}
let refresh = self.refresh_at.filter(|_| !self.refresh_asked);
Some(refresh.map_or(self.silence_deadline(), |r| r.min(self.silence_deadline())))
}
pub fn closed(&mut self) -> Vec<Action> {
let out = std::mem::take(&mut self.subscribed)
.into_iter()
.map(|channel| Action::Unsubscribed { channel, code: 0, reason: "socket closed".into() })
.collect();
self.reset();
out
}
fn reset(&mut self) {
self.phase = Phase::Idle;
self.next_id = 0;
self.pending.clear();
self.subscribed.clear();
self.refresh_at = None;
self.refresh_asked = false;
}
}