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;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
pub fn router<S>() -> Router<S> {
|
pub fn router<S>() -> Router<S> {
|
||||||
Router::new()
|
let router = Router::new().route("/back-up", get(back_up));
|
||||||
.route("/stall", get(stall))
|
|
||||||
.route("/back-up", get(back_up))
|
// The endpoint for immediate reload is only enabled on debug builds.
|
||||||
.with_state(())
|
// 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 {
|
async fn stall() -> String {
|
||||||
loop {
|
loop {
|
||||||
// Sleep for a day, I guess. Just to uphold the connection forever without really using any
|
// 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 config;
|
||||||
mod haku;
|
mod haku;
|
||||||
mod id;
|
mod id;
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
mod live_reload;
|
mod live_reload;
|
||||||
mod login;
|
mod login;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
@ -102,8 +101,7 @@ async fn fallible_main() -> eyre::Result<()> {
|
||||||
.nest_service("/static", ServeDir::new(paths.target_dir.join("static")))
|
.nest_service("/static", ServeDir::new(paths.target_dir.join("static")))
|
||||||
.nest("/api", api::router(api));
|
.nest("/api", api::router(api));
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
let app = app.nest("/auto-reload", live_reload::router());
|
||||||
let app = app.nest("/dev/live-reload", live_reload::router());
|
|
||||||
|
|
||||||
let port: u16 = std::env::var("RKGK_PORT")
|
let port: u16 = std::env::var("RKGK_PORT")
|
||||||
.unwrap_or("8080".into())
|
.unwrap_or("8080".into())
|
||||||
|
|
|
@ -5,6 +5,25 @@ export class ConnectionStatus extends HTMLElement {
|
||||||
|
|
||||||
// This is a progress dialog and shouldn't be closed.
|
// This is a progress dialog and shouldn't be closed.
|
||||||
this.loggingInDialog.addEventListener("cancel", (event) => event.preventDefault());
|
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() {
|
showLoggingIn() {
|
||||||
|
@ -14,6 +33,35 @@ export class ConnectionStatus extends HTMLElement {
|
||||||
hideLoggingIn() {
|
hideLoggingIn() {
|
||||||
this.loggingInDialog.close();
|
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);
|
customElements.define("rkgk-connection-status", ConnectionStatus);
|
||||||
|
|
|
@ -36,6 +36,15 @@ body {
|
||||||
font-weight: 400;
|
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-face {
|
||||||
font-family: "Fira Sans";
|
font-family: "Fira Sans";
|
||||||
src:
|
src:
|
||||||
|
@ -131,8 +140,7 @@ input {
|
||||||
border-bottom: 1px solid var(--color-panel-border);
|
border-bottom: 1px solid var(--color-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
*[contenteditable]:focus, input:focus, textarea:focus {
|
*:focus {
|
||||||
border-radius: 2px;
|
|
||||||
outline: 1px solid #40b1f4;
|
outline: 1px solid #40b1f4;
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
@ -149,6 +157,12 @@ dialog::backdrop {
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Details */
|
||||||
|
|
||||||
|
details>summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Throbbers */
|
/* Throbbers */
|
||||||
|
|
||||||
@keyframes rkgk-throbber-loading {
|
@keyframes rkgk-throbber-loading {
|
||||||
|
@ -295,7 +309,7 @@ rkgk-welcome {
|
||||||
/* Connection status dialogs */
|
/* Connection status dialogs */
|
||||||
|
|
||||||
rkgk-connection-status {
|
rkgk-connection-status {
|
||||||
&>dialog[name='logging-in-dialog'][open] {
|
&>dialog[name='logging-in-dialog'][open], &>dialog[name='disconnected-dialog'][open] {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
@ -304,4 +318,14 @@ rkgk-connection-status {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
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>
|
<rkgk-connection-status>
|
||||||
<dialog name="logging-in-dialog">
|
<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>
|
</dialog>
|
||||||
</rkgk-connection-status>
|
</rkgk-connection-status>
|
||||||
|
|
||||||
<div class="fullscreen" id="js-loading">
|
<div class="fullscreen" id="js-loading">
|
||||||
<rkgk-throbber class="loading"></rkgk-throbber>
|
<rkgk-throbber class="loading"></rkgk-throbber>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
<style>
|
<style>
|
||||||
#js-loading>rkgk-throbber { display: none; }
|
#js-loading>rkgk-throbber { display: none; }
|
||||||
|
@ -68,6 +94,7 @@
|
||||||
<p>
|
<p>
|
||||||
rakugaki is a web app and does not work without JavaScript :(<br>
|
rakugaki is a web app and does not work without JavaScript :(<br>
|
||||||
but I swear it's a very lightweight and delightful web app!
|
but I swear it's a very lightweight and delightful web app!
|
||||||
|
You won't regret trying it out.
|
||||||
</p>
|
</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -77,14 +77,35 @@ function readUrl() {
|
||||||
canvasRenderer.viewport.panY = urlData.viewport.y;
|
canvasRenderer.viewport.panY = urlData.viewport.y;
|
||||||
canvasRenderer.viewport.zoomLevel = urlData.viewport.zoom;
|
canvasRenderer.viewport.zoomLevel = urlData.viewport.zoom;
|
||||||
|
|
||||||
let session = await newSession(
|
let session = await newSession({
|
||||||
getUserId(),
|
userId: getUserId(),
|
||||||
getLoginSecret(),
|
secret: getLoginSecret(),
|
||||||
urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
|
wallId: urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
|
||||||
{
|
userInit: {
|
||||||
brush: brushEditor.code,
|
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);
|
localStorage.setItem("rkgk.mostRecentWallId", session.wallId);
|
||||||
|
|
||||||
connectionStatus.hideLoggingIn();
|
connectionStatus.hideLoggingIn();
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
// NOTE: The server never fulfills this request, it stalls forever.
|
// 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
|
// Once the connection is closed, we try to connect with the server until we establish a successful
|
||||||
// connection. Then we reload the page.
|
// connection. Then we reload the page.
|
||||||
await fetch("/dev/live-reload/stall").catch(async () => {
|
await fetch("/auto-reload/stall").catch(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
let response = await fetch("/dev/live-reload/back-up");
|
let response = await fetch("/auto-reload/back-up");
|
||||||
if (response.status == 200) {
|
if (response.status == 200) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -134,6 +134,11 @@ class Session extends EventTarget {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", (_) => {
|
||||||
|
console.info("connection closed");
|
||||||
|
this.dispatchEvent(new Event("disconnect"));
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await listen([this.ws, "open"]);
|
await listen([this.ws, "open"]);
|
||||||
await this.joinInner(wallId, userInit);
|
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);
|
let session = new Session(userId, secret);
|
||||||
|
session.addEventListener("error", onError);
|
||||||
|
session.addEventListener("disconnect", onDisconnect);
|
||||||
await session.join(wallId, userInit);
|
await session.join(wallId, userInit);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue