Start writing client code, reconnecting websocket
This commit is contained in:
parent
cbb03fa6db
commit
a886e17cf4
8 changed files with 97 additions and 71 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -21214,7 +21214,7 @@
|
|||
}
|
||||
},
|
||||
"rustpad-wasm/pkg": {
|
||||
"name": "rustpad-core",
|
||||
"name": "rustpad-wasm",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)]
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
//! Utility functions
|
||||
//! Utility functions.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
|
|
64
src/App.tsx
64
src/App.tsx
|
@ -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
60
src/rustpad.ts
Normal 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;
|
Loading…
Add table
Reference in a new issue