add proper error and disconnect handling
error handling shows you the error and offers the ability to reload; disconnect handling shows you that the page will reload in a few seconds. it uses exponential backoff with some random sprinkled into it to prevent overwhelming the server once people's clients decide to reconnect.
This commit is contained in:
parent
84abba3e0b
commit
0d831698e2
|
@ -4,12 +4,17 @@ use axum::{routing::get, Router};
|
|||
use tokio::time::sleep;
|
||||
|
||||
pub fn router<S>() -> Router<S> {
|
||||
Router::new()
|
||||
.route("/stall", get(stall))
|
||||
.route("/back-up", get(back_up))
|
||||
.with_state(())
|
||||
let router = Router::new().route("/back-up", get(back_up));
|
||||
|
||||
// The endpoint for immediate reload is only enabled on debug builds.
|
||||
// Release builds use the exponential backoff system that detects is the WebSocket is closed.
|
||||
#[cfg(debug_assertions)]
|
||||
let router = router.route("/stall", get(stall));
|
||||
|
||||
router.with_state(())
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
async fn stall() -> String {
|
||||
loop {
|
||||
// Sleep for a day, I guess. Just to uphold the connection forever without really using any
|
||||
|
|
|
@ -18,7 +18,6 @@ mod api;
|
|||
mod config;
|
||||
mod haku;
|
||||
mod id;
|
||||
#[cfg(debug_assertions)]
|
||||
mod live_reload;
|
||||
mod login;
|
||||
pub mod schema;
|
||||
|
@ -102,8 +101,7 @@ async fn fallible_main() -> eyre::Result<()> {
|
|||
.nest_service("/static", ServeDir::new(paths.target_dir.join("static")))
|
||||
.nest("/api", api::router(api));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let app = app.nest("/dev/live-reload", live_reload::router());
|
||||
let app = app.nest("/auto-reload", live_reload::router());
|
||||
|
||||
let port: u16 = std::env::var("RKGK_PORT")
|
||||
.unwrap_or("8080".into())
|
||||
|
|
|
@ -5,6 +5,25 @@ export class ConnectionStatus extends HTMLElement {
|
|||
|
||||
// This is a progress dialog and shouldn't be closed.
|
||||
this.loggingInDialog.addEventListener("cancel", (event) => event.preventDefault());
|
||||
|
||||
this.errorDialog = this.querySelector("dialog[name='error-dialog']");
|
||||
this.errorText = this.errorDialog.querySelector("[name='error-text']");
|
||||
this.errorRefresh = this.errorDialog.querySelector("button[name='refresh']");
|
||||
|
||||
// If this appears then something broke, and therefore the app can't continue normally.
|
||||
this.errorDialog.addEventListener("cancel", (event) => event.preventDefault());
|
||||
|
||||
this.errorRefresh.addEventListener("click", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
this.disconnectedDialog = this.querySelector("dialog[name='disconnected-dialog']");
|
||||
this.reconnectDuration = this.disconnectedDialog.querySelector(
|
||||
"[name='reconnect-duration']",
|
||||
);
|
||||
|
||||
// If this appears then we can't let the user use the app, because we're disconnected.
|
||||
this.disconnectedDialog.addEventListener("cancel", (event) => event.preventDefault());
|
||||
}
|
||||
|
||||
showLoggingIn() {
|
||||
|
@ -14,6 +33,35 @@ export class ConnectionStatus extends HTMLElement {
|
|||
hideLoggingIn() {
|
||||
this.loggingInDialog.close();
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.errorDialog.showModal();
|
||||
if (error instanceof Error) {
|
||||
if (error.stack != null && error.stack != "") {
|
||||
this.errorText.textContent = `${error.toString()}\n\n${error.stack}`;
|
||||
} else {
|
||||
this.errorText.textContent = error.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async showDisconnected(duration) {
|
||||
this.disconnectedDialog.showModal();
|
||||
|
||||
let updateDuration = (remaining) => {
|
||||
let seconds = Math.floor(remaining / 1000);
|
||||
this.reconnectDuration.textContent = `${seconds} ${seconds == 1 ? "second" : "seconds"}`;
|
||||
};
|
||||
|
||||
let remaining = duration;
|
||||
updateDuration(remaining);
|
||||
while (remaining > 0) {
|
||||
let delay = Math.min(1000, remaining);
|
||||
remaining -= delay;
|
||||
updateDuration(remaining);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("rkgk-connection-status", ConnectionStatus);
|
||||
|
|
|
@ -36,6 +36,15 @@ body {
|
|||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Sans";
|
||||
src:
|
||||
local("Fira Sans Italic"),
|
||||
url("font/FiraSans-Italic.ttf");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Sans";
|
||||
src:
|
||||
|
@ -131,8 +140,7 @@ input {
|
|||
border-bottom: 1px solid var(--color-panel-border);
|
||||
}
|
||||
|
||||
*[contenteditable]:focus, input:focus, textarea:focus {
|
||||
border-radius: 2px;
|
||||
*:focus {
|
||||
outline: 1px solid #40b1f4;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
@ -149,6 +157,12 @@ dialog::backdrop {
|
|||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Details */
|
||||
|
||||
details>summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Throbbers */
|
||||
|
||||
@keyframes rkgk-throbber-loading {
|
||||
|
@ -295,7 +309,7 @@ rkgk-welcome {
|
|||
/* Connection status dialogs */
|
||||
|
||||
rkgk-connection-status {
|
||||
&>dialog[name='logging-in-dialog'][open] {
|
||||
&>dialog[name='logging-in-dialog'][open], &>dialog[name='disconnected-dialog'][open] {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
|
@ -304,4 +318,14 @@ rkgk-connection-status {
|
|||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&>dialog[name='error-dialog'][open] {
|
||||
& textarea[name='error-text'] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: 1px solid var(--color-panel-border);
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,12 +55,38 @@
|
|||
|
||||
<rkgk-connection-status>
|
||||
<dialog name="logging-in-dialog">
|
||||
<rkgk-throbber class="loading"></rkgk-throbber><p>Logging in...</p>
|
||||
<rkgk-throbber class="loading"></rkgk-throbber><p>Logging in…</p>
|
||||
</dialog>
|
||||
|
||||
<dialog name="error-dialog" class="rkgk-panel">
|
||||
<h1>owie! >_<</h1>
|
||||
<p><i>Uh oh. Seems like the pipe cracked again… There's water everywhere.<br>The basement's half full already. God dammit.</i></p>
|
||||
|
||||
<p>Super sorry about this! But rakugaki encountered an error and has to restart.</p>
|
||||
<p><b>Rest assured your drawings are safe and sound.</b></p>
|
||||
<p>Either way… try refreshing the page and see if it helps. If not, please report a bug with the following details.</p>
|
||||
|
||||
<details>
|
||||
<summary>Show error details</summary>
|
||||
<textarea name="error-text" rows="10" readonly></textarea>
|
||||
</details>
|
||||
|
||||
<p>Thank you from the mountain!</p>
|
||||
|
||||
<div style="display: flex; flex-direction: row; align-items: center; justify-content: end;">
|
||||
<button name="refresh">Refresh</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog name="disconnected-dialog">
|
||||
<rkgk-throbber class="loading"></rkgk-throbber>
|
||||
<p>Connection lost. Attempting to reconnect in <span name="reconnect-duration"></span>…</p>
|
||||
</dialog>
|
||||
</rkgk-connection-status>
|
||||
|
||||
<div class="fullscreen" id="js-loading">
|
||||
<rkgk-throbber class="loading"></rkgk-throbber>
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
#js-loading>rkgk-throbber { display: none; }
|
||||
|
@ -68,6 +94,7 @@
|
|||
<p>
|
||||
rakugaki is a web app and does not work without JavaScript :(<br>
|
||||
but I swear it's a very lightweight and delightful web app!
|
||||
You won't regret trying it out.
|
||||
</p>
|
||||
</noscript>
|
||||
</div>
|
||||
|
|
|
@ -77,14 +77,35 @@ function readUrl() {
|
|||
canvasRenderer.viewport.panY = urlData.viewport.y;
|
||||
canvasRenderer.viewport.zoomLevel = urlData.viewport.zoom;
|
||||
|
||||
let session = await newSession(
|
||||
getUserId(),
|
||||
getLoginSecret(),
|
||||
urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
|
||||
{
|
||||
let session = await newSession({
|
||||
userId: getUserId(),
|
||||
secret: getLoginSecret(),
|
||||
wallId: urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
|
||||
userInit: {
|
||||
brush: brushEditor.code,
|
||||
},
|
||||
);
|
||||
|
||||
onError(error) {
|
||||
connectionStatus.showError(error.source);
|
||||
},
|
||||
|
||||
async onDisconnect() {
|
||||
let duration = 5000 + Math.random() * 1000;
|
||||
while (true) {
|
||||
console.info("waiting a bit for the server to come back up", duration);
|
||||
await connectionStatus.showDisconnected(duration);
|
||||
try {
|
||||
console.info("trying to reconnect");
|
||||
let response = await fetch("/auto-reload/back-up");
|
||||
if (response.status == 200) {
|
||||
window.location.reload();
|
||||
break;
|
||||
}
|
||||
} catch (e) {}
|
||||
duration = duration * 1.618033989 + Math.random() * 1000;
|
||||
}
|
||||
},
|
||||
});
|
||||
localStorage.setItem("rkgk.mostRecentWallId", session.wallId);
|
||||
|
||||
connectionStatus.hideLoggingIn();
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
// NOTE: The server never fulfills this request, it stalls forever.
|
||||
// Once the connection is closed, we try to connect with the server until we establish a successful
|
||||
// connection. Then we reload the page.
|
||||
await fetch("/dev/live-reload/stall").catch(async () => {
|
||||
await fetch("/auto-reload/stall").catch(async () => {
|
||||
while (true) {
|
||||
try {
|
||||
let response = await fetch("/dev/live-reload/back-up");
|
||||
let response = await fetch("/auto-reload/back-up");
|
||||
if (response.status == 200) {
|
||||
window.location.reload();
|
||||
break;
|
||||
|
|
|
@ -134,6 +134,11 @@ class Session extends EventTarget {
|
|||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", (_) => {
|
||||
console.info("connection closed");
|
||||
this.dispatchEvent(new Event("disconnect"));
|
||||
});
|
||||
|
||||
try {
|
||||
await listen([this.ws, "open"]);
|
||||
await this.joinInner(wallId, userInit);
|
||||
|
@ -269,8 +274,10 @@ class Session extends EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
export async function newSession(userId, secret, wallId, userInit) {
|
||||
export async function newSession({ userId, secret, wallId, userInit, onError, onDisconnect }) {
|
||||
let session = new Session(userId, secret);
|
||||
session.addEventListener("error", onError);
|
||||
session.addEventListener("disconnect", onDisconnect);
|
||||
await session.join(wallId, userInit);
|
||||
return session;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue