Initial commit
This commit is contained in:
commit
9c44bc892f
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/target
|
||||
/dist
|
||||
chat_log
|
2404
Cargo.lock
generated
Normal file
2404
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
Cargo.toml
Normal file
7
Cargo.toml
Normal file
@ -0,0 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
members = [
|
||||
"frontend",
|
||||
"server",
|
||||
]
|
7
dev.sh
Executable file
7
dev.sh
Executable 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
17
frontend/Cargo.toml
Normal 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
13
frontend/Trunk.toml
Normal 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
27
frontend/index.html
Normal 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
242
frontend/src/main.rs
Normal 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
9
prod.sh
Executable 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
18
server/Cargo.toml
Normal 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
188
server/src/main.rs
Normal 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;
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user