Initial commit

This commit is contained in:
pjht 2024-10-08 19:54:34 -05:00
commit 9c44bc892f
Signed by: pjht
GPG Key ID: 7B5F6AFBEC7EE78E
12 changed files with 2936 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
export RUSTFMT=yew-fmt

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/dist
chat_log

2404
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

7
Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = [
"frontend",
"server",
]

7
dev.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
(trap 'kill 0' SIGINT; \
bash -c 'cd frontend; trunk serve' & \
bash -c 'cargo watch -i chat_log -- cargo run --bin server -- --port 8081')

17
frontend/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[dependencies]
console_error_panic_hook = "0.1.7"
gloo-console = "0.3.0"
gloo-net = "0.6.0"
gloo-timers = "0.3.0"
itertools = "0.13.0"
log = "0.4.22"
wasm-logger = "0.2.0"
web-sys = { version = "0.3.70", features = ["Navigator"] }
yew = { version = "0.21.0", features = ["csr"] }
yew-router = "0.18.0"
yew-websocket = "1.21.0"

13
frontend/Trunk.toml Normal file
View File

@ -0,0 +1,13 @@
[build]
target = "index.html"
dist = "../dist"
[[proxy]]
backend = "http://localhost:8081/api"
[[proxy]]
backend = "ws://localhost:8081/api/chat_ws"
ws = true
[serve]
addresses = ["::"]

27
frontend/index.html Normal file
View File

@ -0,0 +1,27 @@
<!doctype html>
<html data-bs-theme="dark" style = "max-height: 100%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Yew App</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script>
function adjustHeight() {
document.documentElement.style.setProperty('--vh', `${window.innerHeight}px`);
}
// Call the function initially
adjustHeight();
// Update the height when the window is resized (e.g., when the address bar disappears)
window.addEventListener('resize', adjustHeight);
</script>
<style>
.myvh-100 {
height: calc(var(--vh, 1vh));
}
</style>
</head>
<body style = "max-height: 100%"></body>
</html>

242
frontend/src/main.rs Normal file
View File

@ -0,0 +1,242 @@
use std::time::Duration;
use gloo_console::log;
use gloo_timers::future::sleep;
use yew::prelude::*;
use yew_router::prelude::*;
use yew_websocket::{
format::Text,
websocket::{WebSocketService, WebSocketStatus, WebSocketTask},
};
#[derive(Clone, Routable, PartialEq)]
enum Route {
#[at("/")]
Home,
#[not_found]
#[at("/404")]
NotFound,
}
fn switch(routes: Route) -> Html {
match routes {
Route::Home => html! { <Home /> },
Route::NotFound => html! {
<>
<Nav />
<h1>{ "Page Not Found" }</h1>
</>
},
}
}
enum HomeMessage {
SubmittedMessage(String),
RecievedMessage(String),
WsStateChange(WebSocketStatus),
WsReconnect,
}
struct Home {
messages: Vec<String>,
chat_ws: WebSocketTask,
ws_state: WebSocketStatus,
ws_reconnecting: bool,
message_container_ref: NodeRef,
}
impl Home {
fn connect_ws(ctx: &Context<Self>) -> WebSocketTask {
let location = web_sys::window().unwrap().location();
let ws_proto = if location.protocol().unwrap() == "https:" {
"wss"
} else {
"ws"
};
let api_url = format!("{}://{}/api/chat_ws", ws_proto, location.host().unwrap());
log!("Connecting to ", &api_url);
WebSocketService::connect_text(
&api_url,
ctx.link()
.callback(|msg: Text| HomeMessage::RecievedMessage(msg.unwrap())),
ctx.link()
.callback(HomeMessage::WsStateChange),
)
.unwrap()
}
}
fn on_mobile() -> bool {
let window = web_sys::window().unwrap();
let navigator = window.navigator();
navigator.max_touch_points() > 0 || window.inner_width().unwrap().as_f64().unwrap() < 768.0
}
impl Component for Home {
type Message = HomeMessage;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
let chat_ws = Self::connect_ws(ctx);
Self {
messages: Vec::new(),
chat_ws,
ws_state: WebSocketStatus::Closed,
ws_reconnecting: false,
message_container_ref: NodeRef::default(),
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let msg_keypress = {
ctx.link().batch_callback(|press: KeyboardEvent| {
if press.key() == "Enter" {
let input = press
.target_dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
let msg = input.value();
input.set_value("");
if on_mobile() {
input.blur().unwrap();
}
Some(HomeMessage::SubmittedMessage(msg))
} else {
None
}
})
};
let disable_input = self.ws_state != WebSocketStatus::Opened;
html! {
<div class="myvh-100 d-flex flex-column">
<Nav />
<div class="container-fluid d-flex flex-column flex-grow-1 mt-3">
if disable_input {
<div class="alert alert-warning" role="alert">
{ "Connection to backend lost, trying to reconnect" }
</div>
}
<div ref={self.message_container_ref.clone()} class="border rounded flex-grow-1 overflow-auto mb-3" style = "flex-basis: 0">
{ self.messages.iter().map(|msg| html!{<div>{msg}</div>}).collect::<Vec<_>>() }
</div>
<div class="bottom-0 mb-3 mt-3">
<input
onkeydown={msg_keypress}
disabled={disable_input}
type="text"
id="message"
class="w-100 form-control"
/>
</div>
</div>
</div>
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
HomeMessage::SubmittedMessage(msg) => {
self.messages.push(msg.clone());
self.chat_ws.send(msg);
true
}
HomeMessage::RecievedMessage(msg) => {
self.messages.push(msg.clone());
true
}
HomeMessage::WsStateChange(state) => {
if state != self.ws_state {
if state != WebSocketStatus::Opened {
log!("WS connection closed");
if self.ws_state != WebSocketStatus::Opened {
log!("Already closed");
if !self.ws_reconnecting {
log!("Reconnecting in 5s");
self.ws_reconnecting = true;
ctx.link().send_future(async move {
sleep(Duration::from_secs(5)).await;
HomeMessage::WsReconnect
});
}
} else {
log!("Reconnecting");
self.chat_ws = Self::connect_ws(ctx);
}
} else {
log!("WS connection opened");
self.messages.clear();
}
self.ws_state = state.clone();
true
} else {
if state != WebSocketStatus::Opened {
log!("Close/error state while closed/errored, marking closed and reconnecting");
self.ws_state = WebSocketStatus::Closed;
self.chat_ws = Self::connect_ws(ctx);
}
false
}
}
HomeMessage::WsReconnect => {
if self.ws_state != WebSocketStatus::Opened {
log!("Reconnecting");
self.ws_reconnecting = false;
self.chat_ws = Self::connect_ws(ctx);
}
false
}
}
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
if let Some(message_container) = self.message_container_ref.cast::<web_sys::Element>() {
message_container.set_scroll_top(message_container.scroll_height());
}
}
}
#[function_component]
fn Nav() -> Html {
html! {
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">{ "Chat" }</a>
<div class="nav-item dropdown navbar-nav">
<a
class="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{ "PJHT" }
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="#">{ "Options" }</a>
</li>
<li>
<a class="dropdown-item" href="#">{ "Logout" }</a>
</li>
</ul>
</div>
</div>
</nav>
}
}
#[function_component]
fn App() -> Html {
html! {
<BrowserRouter>
<Switch<Route> render={switch} />
</BrowserRouter>
}
}
fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
console_error_panic_hook::set_once();
yew::Renderer::<App>::new().render();
}

9
prod.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
pushd frontend
trunk build
popd
cargo run --bin server --release -- --port 8080 --static-dir ./dist

18
server/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7.7", features = ["ws"] }
axum-client-ip = "0.6.1"
axum_static = "1.7.1"
clap = { version = "4.5.19", features = ["derive"] }
futures = "0.3.31"
log = "0.4.22"
slab = "0.4.9"
tokio = { version = "1.40.0", features = ["full"] }
tower = "0.5.1"
tower-http = { version = "0.6.1", features = ["full"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"

188
server/src/main.rs Normal file
View File

@ -0,0 +1,188 @@
use axum::body::Body;
use axum::extract::ws::{self, CloseFrame, Message, WebSocket};
use axum::extract::{Request, State, WebSocketUpgrade};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::Response;
use axum::{routing::get, Router};
use clap::Parser;
use futures::stream::SplitSink;
use futures::{SinkExt, StreamExt};
use slab::Slab;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use tokio::fs::{self, OpenOptions};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use tower::{ServiceBuilder, ServiceExt};
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing::debug;
// Setup the command line interface with clap.
#[derive(Parser, Debug)]
#[clap(name = "server")]
struct Opt {
/// set the log level
#[clap(short = 'l', long = "log", default_value = "debug")]
log_level: String,
/// set the listen addr
#[clap(short = 'a', long = "addr", default_value = "::1")]
addr: String,
/// set the listen port
#[clap(short = 'p', long = "port", default_value = "8080")]
port: u16,
/// set the directory where static files are to be found
#[clap(long = "static-dir", default_value = "./dist")]
static_dir: PathBuf,
}
//#[derive(Clone)]
struct ServState {
client_sends: Slab<SplitSink<WebSocket, Message>>,
chat_log: Vec<String>,
}
impl ServState {
fn new() -> Self {
let chat_log = std::fs::read_to_string("chat_log")
.unwrap_or_default()
.split('\n')
.filter(|x| !x.is_empty())
.map(|x| x.to_string())
.collect::<Vec<_>>();
debug!("{:?}", chat_log);
Self {
client_sends: Slab::new(),
chat_log,
}
}
}
#[tokio::main]
async fn main() {
let opt = Opt::parse();
// Setup logging & RUST_LOG from args
if std::env::var("RUST_LOG").is_err() {
// nothing we can do but hope and pray. env vars are very thread-unsafe. we shouldn't have
// any other threads yet but who knows?
unsafe { std::env::set_var("RUST_LOG", format!("{},hyper=info,mio=info", opt.log_level)) }
}
// enable console logging
tracing_subscriber::fmt::init();
let api = Router::new().route("/chat_ws", get(chat_ws));
let app = Router::new()
.nest("/api", api)
.fallback(|req: Request| async move {
if req.uri().path().starts_with("/api") {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(""))
.unwrap();
}
match ServeDir::new(&opt.static_dir)
.append_index_html_on_directories(false)
.oneshot(req)
.await
{
Ok(res) => {
if res.status() == StatusCode::NOT_FOUND {
let index_path = PathBuf::from(&opt.static_dir).join("index.html");
let index_content = match fs::read_to_string(index_path).await {
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("index file not found"))
.unwrap()
}
Ok(index_content) => index_content,
};
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "text/html")
.body(Body::from(index_content))
.unwrap()
} else {
res.map(Body::new)
}
}
Err(err) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!("error: {err}")))
.expect("error response"),
}
})
.with_state(Arc::new(Mutex::new(ServState::new())))
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
let sock_addr = SocketAddr::from((
IpAddr::from_str(opt.addr.as_str()).unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
opt.port,
));
let listener = tokio::net::TcpListener::bind(sock_addr).await.unwrap();
log::info!("listening on http://{}", sock_addr);
axum::serve(listener, app)
.await
.expect("Unable to start server");
}
async fn chat_ws(State(state): State<Arc<Mutex<ServState>>>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| async move {
let (mut tx, mut rx) = socket.split();
debug!("Client connected");
for msg in &state.lock().await.chat_log {
tx.send(Message::Text(msg.clone())).await.unwrap();
}
let tx_idx = state.lock().await.client_sends.insert(tx);
let mut close_code = ws::close_code::NORMAL;
while let Some(msg) = rx.next().await {
if let Ok(msg) = msg {
let msg = match msg {
Message::Text(msg) => msg,
_ => {
close_code = ws::close_code::UNSUPPORTED;
break;
}
};
state.lock().await.chat_log.push(msg.clone());
let mut log_file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open("chat_log")
.await
.unwrap();
log_file.write_all(msg.as_bytes()).await.unwrap();
log_file.write_all(&[b'\n']).await.unwrap();
for (i, client_tx) in state.lock().await.client_sends.iter_mut() {
if i == tx_idx {
continue;
}
let _ = client_tx.send(Message::Text(msg.clone())).await;
}
} else {
close_code = ws::close_code::PROTOCOL;
break;
};
}
debug!("Client disconnected");
let mut tx = state.lock().await.client_sends.remove(tx_idx);
let _ = tx
.send(Message::Close(Some(CloseFrame {
code: close_code,
reason: "".into(),
})))
.await;
})
}