Start writing client code, reconnecting websocket

This commit is contained in:
Eric Zhang 2021-06-02 14:03:41 -05:00
parent cbb03fa6db
commit a886e17cf4
8 changed files with 97 additions and 71 deletions

2
package-lock.json generated
View file

@ -21214,7 +21214,7 @@
}
},
"rustpad-wasm/pkg": {
"name": "rustpad-core",
"name": "rustpad-wasm",
"version": "0.1.0"
}
},

View file

@ -1,4 +1,4 @@
//! Server backend for the Rustpad collaborative text editor
//! Server backend for the Rustpad collaborative text editor.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
@ -10,19 +10,19 @@ use warp::{filters::BoxedFilter, ws::Ws, Filter, Reply};
mod rustpad;
/// A combined filter handling all server routes
/// A combined filter handling all server routes.
pub fn server() -> BoxedFilter<(impl Reply,)> {
warp::path("api").and(backend()).or(frontend()).boxed()
}
/// Construct routes for static files from React
/// Construct routes for static files from React.
fn frontend() -> BoxedFilter<(impl Reply,)> {
warp::fs::dir("build")
.or(warp::get().and(warp::fs::file("build/index.html")))
.boxed()
}
/// Construct backend routes, including WebSocket handlers
/// Construct backend routes, including WebSocket handlers.
fn backend() -> BoxedFilter<(impl Reply,)> {
let rustpad = Arc::new(Rustpad::new());
let rustpad = warp::any().map(move || Arc::clone(&rustpad));

View file

@ -1,4 +1,4 @@
//! Asynchronous systems logic for Rustpad
//! Asynchronous systems logic for Rustpad.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use tokio::{sync::Notify, time};
use warp::ws::{Message, WebSocket};
/// The main object for a collaborative session
/// The main object for a collaborative session.
#[derive(Default)]
pub struct Rustpad {
state: RwLock<State>,
@ -20,7 +20,7 @@ pub struct Rustpad {
notify: Notify,
}
/// Shared state involving multiple users, protected by a lock
/// Shared state involving multiple users, protected by a lock.
#[derive(Default)]
struct State {
operations: Vec<UserOperation>,
@ -33,22 +33,22 @@ struct UserOperation {
operation: OperationSeq,
}
/// A message received from the client over WebSocket
/// A message received from the client over WebSocket.
#[derive(Clone, Debug, Serialize, Deserialize)]
enum ClientMsg {
/// Represents a sequence of local edits from the user
/// Represents a sequence of local edits from the user.
Edit {
revision: usize,
operation: OperationSeq,
},
}
/// A message sent to the client over WebSocket
/// A message sent to the client over WebSocket.
#[derive(Clone, Debug, Serialize, Deserialize)]
enum ServerMsg {
/// Informs the client of their unique socket ID
/// Informs the client of their unique socket ID.
Identity(u64),
/// Broadcasts text operations to all clients
/// Broadcasts text operations to all clients.
History {
start: usize,
operations: Vec<UserOperation>,
@ -63,12 +63,12 @@ impl From<ServerMsg> for Message {
}
impl Rustpad {
/// Construct a new, empty Rustpad object
/// Construct a new, empty Rustpad object.
pub fn new() -> Self {
Default::default()
}
/// Handle a connection from a WebSocket
/// Handle a connection from a WebSocket.
pub async fn on_connection(&self, socket: WebSocket) {
let id = self.count.fetch_add(1, Ordering::Relaxed);
info!("connection! id = {}", id);
@ -78,13 +78,13 @@ impl Rustpad {
info!("disconnection, id = {}", id);
}
/// Returns a snapshot of the latest text
/// Returns a snapshot of the latest text.
pub fn text(&self) -> String {
let state = self.state.read();
state.text.clone()
}
/// Returns the current revision
/// Returns the current revision.
pub fn revision(&self) -> usize {
let state = self.state.read();
state.operations.len()

View file

@ -8,7 +8,7 @@ use serde_json::{json, Value};
use tokio::time;
use warp::{filters::BoxedFilter, test::WsClient, Reply};
/// A test WebSocket client that sends and receives JSON messages
/// A test WebSocket client that sends and receives JSON messages.
struct JsonSocket(WsClient);
impl JsonSocket {
@ -27,7 +27,7 @@ impl JsonSocket {
}
}
/// Connect a new test client WebSocket
/// Connect a new test client WebSocket.
async fn connect(filter: &BoxedFilter<(impl Reply + 'static,)>) -> Result<JsonSocket> {
let client = warp::test::ws()
.path("/api/socket")
@ -36,7 +36,7 @@ async fn connect(filter: &BoxedFilter<(impl Reply + 'static,)>) -> Result<JsonSo
Ok(JsonSocket(client))
}
/// Check the text route
/// Check the text route.
async fn expect_text(filter: &BoxedFilter<(impl Reply + 'static,)>, text: &str) {
let resp = warp::test::request().path("/api/text").reply(filter).await;
assert_eq!(resp.status(), 200);

View file

@ -1,4 +1,4 @@
//! Core logic for Rustpad, shared with the client through WebAssembly
//! Core logic for Rustpad, shared with the client through WebAssembly.
#![warn(missing_docs)]

View file

@ -1,4 +1,4 @@
//! Utility functions
//! Utility functions.
use wasm_bindgen::prelude::*;

View file

@ -1,33 +1,22 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { set_panic_hook } from "rustpad-wasm";
import Rustpad from "./rustpad";
set_panic_hook();
const WS_URI =
(window.location.origin.startsWith("https") ? "wss://" : "ws://") +
window.location.host +
"/api/socket";
function App() {
const [input, setInput] = useState("");
const [socket, setSocket] = useState<WebSocket>();
const [messages, setMessages] = useState<[number, string][]>([]);
useEffect(() => {
set_panic_hook();
const uri =
(window.location.origin.startsWith("https") ? "wss://" : "ws://") +
window.location.host +
"/api/socket";
const ws = new WebSocket(uri);
console.log("connecting...");
ws.onopen = () => {
console.log("connected!");
setSocket(ws);
};
ws.onmessage = ({ data }) => {
console.log("message:", data);
setMessages((messages) => [...messages, ...JSON.parse(data)]);
};
ws.onclose = () => {
console.log("disconnected!");
setSocket(undefined);
};
return () => ws.close();
const rustpad = new Rustpad({
uri: WS_URI,
onConnected: () => console.log("connected!"),
onDisconnected: () => console.log("disconnected!"),
});
return () => rustpad.dispose();
}, []);
return (
@ -36,29 +25,6 @@ function App() {
<div className="one-half column" style={{ marginTop: "25%" }}>
<h4>Chat Application</h4>
<p>Let's send some messages!</p>
<ul>
{messages.map(([sender, message], key) => (
<li key={key}>
<strong>User #{sender}:</strong> {message}
</li>
))}
</ul>
<form
onSubmit={(event) => {
event.preventDefault();
socket?.send(input);
setInput("");
}}
>
<input
className="u-full-width"
required
placeholder="Hello!"
value={input}
onChange={(event) => setInput(event.target.value)}
/>
<input className="button-primary" type="submit" />
</form>
</div>
</div>
</div>

60
src/rustpad.ts Normal file
View file

@ -0,0 +1,60 @@
/** Options passed in to the Rustpad constructor. */
type RustpadOptions = {
readonly uri: string;
readonly onConnected?: () => unknown;
readonly onDisconnected?: () => unknown;
readonly reconnectInterval?: number;
};
/** Browser client for Rustpad. */
class Rustpad {
private ws?: WebSocket;
private connecting?: boolean;
private readonly intervalId: number;
constructor(readonly options: RustpadOptions) {
this.tryConnect();
this.intervalId = window.setInterval(
() => this.tryConnect(),
options.reconnectInterval ?? 1000
);
}
/** Destroy this Rustpad instance and close any sockets. */
dispose() {
window.clearInterval(this.intervalId);
if (this.ws) this.ws.close();
}
/**
* Attempts a WebSocket connection.
*
* Safety Invariant: Until this WebSocket connection is closed, no other
* connections will be attempted because either `this.ws` or
* `this.connecting` will be set to a truthy value.
*
* Liveness Invariant: After this WebSocket connection closes, either through
* error or successful end, both `this.connecting` and `this.ws` will be set
* to falsy values.
*/
private tryConnect() {
if (this.connecting || this.ws) return;
this.connecting = true;
const ws = new WebSocket(this.options.uri);
ws.onopen = () => {
this.connecting = false;
this.ws = ws;
this.options.onConnected?.();
};
ws.onclose = () => {
if (this.ws) {
this.ws = undefined;
this.options.onDisconnected?.();
} else {
this.connecting = false;
}
};
}
}
export default Rustpad;