tokens and more
This commit is contained in:
+135
-2
@@ -2772,6 +2772,110 @@ dialog::backdrop {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.footer-connection-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
:is(.connection-details, .mobile-connection-details) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:is(.connection-details, .mobile-connection-details)>summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted-2);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
:is(.connection-details, .mobile-connection-details)>summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-details__chevron {
|
||||
font-size: .68rem;
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
|
||||
.connection-details[open] .connection-details__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.connection-diagnostics-popover {
|
||||
position: absolute;
|
||||
z-index: 55;
|
||||
left: 0;
|
||||
bottom: calc(100% + 9px);
|
||||
width: min(430px, calc(100vw - 24px));
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 16px 40px var(--shadow-45);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.connection-diagnostics-popover>strong {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.connection-diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-diagnostics-grid>div {
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
|
||||
.connection-diagnostics-grid__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.connection-diagnostics-grid dt {
|
||||
margin: 0 0 3px;
|
||||
color: var(--muted);
|
||||
font-size: .62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.connection-diagnostics-grid dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
:is(.connection-details, .mobile-connection-details):is(.is-quality-excellent, .is-quality-good) [data-connection-diagnostic="quality"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
:is(.connection-details, .mobile-connection-details).is-quality-degraded [data-connection-diagnostic="quality"] {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
:is(.connection-details, .mobile-connection-details).is-quality-poor [data-connection-diagnostic="quality"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.markdown-more {
|
||||
position: relative;
|
||||
}
|
||||
@@ -5160,18 +5264,46 @@ dialog::backdrop {
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 8px 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--wash-hover);
|
||||
color: var(--muted);
|
||||
font-size: .72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-connection-details {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-connection-details[open]>.mobile-connection-status {
|
||||
background: color-mix(in srgb, var(--accent) 24%, var(--wash-hover));
|
||||
}
|
||||
|
||||
.mobile-connection-status #mobile-status-text {
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#mobile-socket-latency {
|
||||
color: var(--muted-2);
|
||||
font-size: .66rem;
|
||||
}
|
||||
|
||||
.mobile-connection-diagnostics-popover {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
left: auto;
|
||||
width: min(430px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 88px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.pad-page .toast {
|
||||
bottom: calc(62px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
@@ -5303,7 +5435,7 @@ dialog::backdrop {
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.editor-column-label {
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.authorship-mode-control button {
|
||||
@@ -6154,6 +6286,7 @@ dialog::backdrop {
|
||||
.pad-page .participant-badges:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Unified RustPad resource identity used by notes and workspaces. */
|
||||
.resource-brand {
|
||||
display: grid;
|
||||
@@ -6222,4 +6355,4 @@ dialog::backdrop {
|
||||
padding-left: 0;
|
||||
font-size: .68rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-7
@@ -163,10 +163,64 @@
|
||||
</div>
|
||||
<footer class="editor-footer">
|
||||
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
|
||||
words</span><span class="footer-connection-block"> · <span class="footer-status status"><span
|
||||
id="status-dot" class="status__dot"></span><span
|
||||
id="status-text">Connecting…</span></span> · <span id="socket-latency"
|
||||
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
|
||||
words</span>
|
||||
<div class="footer-connection-block"> · <details id="connection-details" class="connection-details">
|
||||
<summary title="WebSocket connection diagnostics"><span class="footer-status status"><span
|
||||
id="status-dot" class="status__dot"></span><span
|
||||
id="status-text">Connecting…</span></span><span id="socket-latency"
|
||||
class="footer-socket-latency">— ms</span><span class="connection-details__chevron"
|
||||
aria-hidden="true">▾</span></summary>
|
||||
<div class="connection-diagnostics-popover">
|
||||
<strong>Connection diagnostics</strong>
|
||||
<dl class="connection-diagnostics-grid">
|
||||
<div>
|
||||
<dt>Quality</dt>
|
||||
<dd data-connection-diagnostic="quality">Waiting</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latency</dt>
|
||||
<dd data-connection-diagnostic="latency">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Jitter</dt>
|
||||
<dd data-connection-diagnostic="jitter">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Uptime</dt>
|
||||
<dd data-connection-diagnostic="uptime">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reconnects</dt>
|
||||
<dd data-connection-diagnostic="reconnects">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Transport</dt>
|
||||
<dd data-connection-diagnostic="transport">WebSocket</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Heartbeat</dt>
|
||||
<dd data-connection-diagnostic="heartbeat">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Network</dt>
|
||||
<dd data-connection-diagnostic="network">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Client</dt>
|
||||
<dd data-connection-diagnostic="client">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Server</dt>
|
||||
<dd data-connection-diagnostic="server">—</dd>
|
||||
</div>
|
||||
<div class="connection-diagnostics-grid__wide">
|
||||
<dt>Last event</dt>
|
||||
<dd data-connection-diagnostic="last-event">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
</div> ·
|
||||
<details id="room-details" class="room-details">
|
||||
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
|
||||
hidden></span></summary>
|
||||
@@ -296,9 +350,60 @@
|
||||
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
|
||||
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
|
||||
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
|
||||
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span
|
||||
id="mobile-status-dot" class="status__dot"></span><span
|
||||
id="mobile-status-text">Connecting…</span></span>
|
||||
<details id="mobile-connection-details" class="mobile-connection-details">
|
||||
<summary id="mobile-connection-status" class="mobile-connection-status"
|
||||
title="WebSocket connection diagnostics"><span id="mobile-status-dot" class="status__dot"></span><span
|
||||
id="mobile-status-text">Connecting…</span><span id="mobile-socket-latency">—</span></summary>
|
||||
<div class="connection-diagnostics-popover mobile-connection-diagnostics-popover">
|
||||
<strong>Connection diagnostics</strong>
|
||||
<dl class="connection-diagnostics-grid">
|
||||
<div>
|
||||
<dt>Quality</dt>
|
||||
<dd data-connection-diagnostic="quality">Waiting</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latency</dt>
|
||||
<dd data-connection-diagnostic="latency">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Jitter</dt>
|
||||
<dd data-connection-diagnostic="jitter">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Uptime</dt>
|
||||
<dd data-connection-diagnostic="uptime">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reconnects</dt>
|
||||
<dd data-connection-diagnostic="reconnects">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Transport</dt>
|
||||
<dd data-connection-diagnostic="transport">WebSocket</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Heartbeat</dt>
|
||||
<dd data-connection-diagnostic="heartbeat">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Network</dt>
|
||||
<dd data-connection-diagnostic="network">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Client</dt>
|
||||
<dd data-connection-diagnostic="client">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Server</dt>
|
||||
<dd data-connection-diagnostic="server">—</dd>
|
||||
</div>
|
||||
<div class="connection-diagnostics-grid__wide">
|
||||
<dt>Last event</dt>
|
||||
<dd data-connection-diagnostic="last-event">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
</body>
|
||||
|
||||
+2
-3
@@ -74,9 +74,8 @@
|
||||
<footer class="home-footer">
|
||||
<div class="home-footer__inner">
|
||||
<div id="footer-account-guest" class="home-footer__account">
|
||||
<button id="footer-login" class="footer-action" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register
|
||||
nickname</button>
|
||||
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user">
|
||||
|
||||
+161
-116
@@ -26,6 +26,37 @@ const DEFAULT_ERRORS = {
|
||||
504: "The server took too long to respond. Try again.",
|
||||
};
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const CSRF_REFRESH_MS = 20 * 60 * 1000;
|
||||
let csrfTokenPromise = null;
|
||||
let csrfTokenFetchedAt = 0;
|
||||
|
||||
async function csrfToken({ refresh = false } = {}) {
|
||||
if (refresh || Date.now() - csrfTokenFetchedAt >= CSRF_REFRESH_MS) {
|
||||
csrfTokenPromise = null;
|
||||
csrfTokenFetchedAt = 0;
|
||||
}
|
||||
if (!csrfTokenPromise) {
|
||||
csrfTokenPromise = fetch("/api/security/csrf", {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).then(async response => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || typeof data.token !== "string" || !data.token) {
|
||||
throw requestError(response.status, data);
|
||||
}
|
||||
csrfTokenFetchedAt = Date.now();
|
||||
return data.token;
|
||||
}).catch(error => {
|
||||
csrfTokenPromise = null;
|
||||
csrfTokenFetchedAt = 0;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return csrfTokenPromise;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
|
||||
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||
@@ -67,10 +98,14 @@ function validateUploadSize(body) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(options, body) {
|
||||
async function requestHeaders(options, body) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.delete("x-rustpad-user-token");
|
||||
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
if (!SAFE_METHODS.has(method) && !headers.has("x-rustpad-csrf")) {
|
||||
headers.set("x-rustpad-csrf", await csrfToken());
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -92,10 +127,18 @@ export async function api(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const headers = requestHeaders(options, options.body);
|
||||
let headers = await requestHeaders(options, options.body);
|
||||
const started = performance.now();
|
||||
logDebug("api.request", { method: options.method || "GET", path });
|
||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
||||
let response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||
if (response.status === 403 && !SAFE_METHODS.has(String(options.method || "GET").toUpperCase())) {
|
||||
const preview = await response.clone().json().catch(() => ({}));
|
||||
if (/security token/i.test(preview.error || "")) {
|
||||
headers = new Headers(headers);
|
||||
headers.set("x-rustpad-csrf", await csrfToken({ refresh: true }));
|
||||
response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||
}
|
||||
}
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
@@ -129,120 +172,122 @@ export function uploadWithProgress(path, options = {}) {
|
||||
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
||||
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const headers = requestHeaders(options, options.body);
|
||||
const started = performance.now();
|
||||
let lastAt = started;
|
||||
let lastLoaded = 0;
|
||||
let speed = 0;
|
||||
let stallTimer = null;
|
||||
let responseTimer = null;
|
||||
let stalled = false;
|
||||
let responseTimedOut = false;
|
||||
let externallyAborted = false;
|
||||
return (async () => {
|
||||
const headers = await requestHeaders({ ...options, method }, options.body);
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const started = performance.now();
|
||||
let lastAt = started;
|
||||
let lastLoaded = 0;
|
||||
let speed = 0;
|
||||
let stallTimer = null;
|
||||
let responseTimer = null;
|
||||
let stalled = false;
|
||||
let responseTimedOut = false;
|
||||
let externallyAborted = false;
|
||||
|
||||
const clearStallTimer = () => {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
const armStallTimer = () => {
|
||||
clearStallTimer();
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled = true;
|
||||
xhr.abort();
|
||||
}, stallTimeoutMs);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
options.signal?.removeEventListener("abort", abortFromSignal);
|
||||
};
|
||||
const abortFromSignal = () => {
|
||||
externallyAborted = true;
|
||||
xhr.abort();
|
||||
};
|
||||
const fail = error => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.open(method, path, true);
|
||||
xhr.responseType = "text";
|
||||
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
|
||||
|
||||
xhr.upload.addEventListener("loadstart", () => {
|
||||
armStallTimer();
|
||||
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
|
||||
});
|
||||
xhr.upload.addEventListener("progress", event => {
|
||||
const now = performance.now();
|
||||
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
|
||||
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
|
||||
const instantaneousSpeed = deltaBytes / elapsedSeconds;
|
||||
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
|
||||
lastAt = now;
|
||||
lastLoaded = event.loaded;
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
|
||||
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
|
||||
if (total > 0 && event.loaded >= total) clearStallTimer();
|
||||
else armStallTimer();
|
||||
});
|
||||
xhr.upload.addEventListener("load", event => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = setTimeout(() => {
|
||||
responseTimedOut = true;
|
||||
xhr.abort();
|
||||
}, responseTimeoutMs);
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
cleanup();
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
logDebug("api.response", { method, path, status: xhr.status, durationMs });
|
||||
let data = {};
|
||||
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(data);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401) void clearSessionIfInvalid();
|
||||
const error = requestError(xhr.status, data);
|
||||
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
|
||||
reject(error);
|
||||
});
|
||||
xhr.addEventListener("error", () => {
|
||||
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
|
||||
logError("api.network_error", error, { method, path });
|
||||
fail(error);
|
||||
});
|
||||
xhr.addEventListener("abort", () => {
|
||||
const error = new Error(stalled
|
||||
? "Upload stopped making progress. Check the connection and try again."
|
||||
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
|
||||
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
|
||||
error.name = externallyAborted ? "AbortError" : "UploadError";
|
||||
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
|
||||
fail(error);
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
const clearStallTimer = () => {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
const armStallTimer = () => {
|
||||
clearStallTimer();
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled = true;
|
||||
xhr.abort();
|
||||
}, stallTimeoutMs);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
options.signal?.removeEventListener("abort", abortFromSignal);
|
||||
};
|
||||
const abortFromSignal = () => {
|
||||
externallyAborted = true;
|
||||
const error = new Error("Upload cancelled.");
|
||||
error.name = "AbortError";
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortFromSignal, { once: true });
|
||||
}
|
||||
xhr.abort();
|
||||
};
|
||||
const fail = error => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
logDebug("api.request", { method, path });
|
||||
xhr.send(options.body ?? null);
|
||||
});
|
||||
xhr.open(method, path, true);
|
||||
xhr.responseType = "text";
|
||||
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
|
||||
|
||||
xhr.upload.addEventListener("loadstart", () => {
|
||||
armStallTimer();
|
||||
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
|
||||
});
|
||||
xhr.upload.addEventListener("progress", event => {
|
||||
const now = performance.now();
|
||||
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
|
||||
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
|
||||
const instantaneousSpeed = deltaBytes / elapsedSeconds;
|
||||
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
|
||||
lastAt = now;
|
||||
lastLoaded = event.loaded;
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
|
||||
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
|
||||
if (total > 0 && event.loaded >= total) clearStallTimer();
|
||||
else armStallTimer();
|
||||
});
|
||||
xhr.upload.addEventListener("load", event => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = setTimeout(() => {
|
||||
responseTimedOut = true;
|
||||
xhr.abort();
|
||||
}, responseTimeoutMs);
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
cleanup();
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
logDebug("api.response", { method, path, status: xhr.status, durationMs });
|
||||
let data = {};
|
||||
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(data);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401) void clearSessionIfInvalid();
|
||||
const error = requestError(xhr.status, data);
|
||||
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
|
||||
reject(error);
|
||||
});
|
||||
xhr.addEventListener("error", () => {
|
||||
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
|
||||
logError("api.network_error", error, { method, path });
|
||||
fail(error);
|
||||
});
|
||||
xhr.addEventListener("abort", () => {
|
||||
const error = new Error(stalled
|
||||
? "Upload stopped making progress. Check the connection and try again."
|
||||
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
|
||||
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
|
||||
error.name = externallyAborted ? "AbortError" : "UploadError";
|
||||
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
|
||||
fail(error);
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
externallyAborted = true;
|
||||
const error = new Error("Upload cancelled.");
|
||||
error.name = "AbortError";
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortFromSignal, { once: true });
|
||||
}
|
||||
|
||||
logDebug("api.request", { method, path });
|
||||
xhr.send(options.body ?? null);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { getTheme } from "@rustpad/theme";
|
||||
export function startNoteEditor(adapter) {
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
|
||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
|
||||
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
|
||||
@@ -216,7 +216,65 @@ export function startNoteEditor(adapter) {
|
||||
updateCurrentUser(); return info;
|
||||
}
|
||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
|
||||
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
|
||||
function updateLatency(ms) {
|
||||
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
|
||||
socketLatency.textContent = text;
|
||||
const mobileLatency = document.querySelector("#mobile-socket-latency");
|
||||
if (mobileLatency) mobileLatency.textContent = text;
|
||||
}
|
||||
function setDiagnosticField(name, value) {
|
||||
document.querySelectorAll(`[data-connection-diagnostic="${name}"]`).forEach(node => { node.textContent = value; });
|
||||
}
|
||||
function formatDiagnosticDuration(milliseconds) {
|
||||
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
function renderConnectionDiagnostics(snapshot = {}) {
|
||||
const server = snapshot.server || {};
|
||||
const runtime = snapshot.runtime || {};
|
||||
const latency = runtime.latency || {};
|
||||
const client = server.client || {};
|
||||
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
|
||||
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
|
||||
setDiagnosticField("quality", qualityLabel);
|
||||
setDiagnosticField("latency", Number.isFinite(latency.current)
|
||||
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}–${latency.maximum} ms`
|
||||
: "Waiting for heartbeat");
|
||||
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
|
||||
setDiagnosticField("uptime", runtime.authenticated_at
|
||||
? formatDiagnosticDuration(runtime.uptime_ms)
|
||||
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
|
||||
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
|
||||
const scheme = client.request_scheme ? `${client.request_scheme.toUpperCase()} / ` : "";
|
||||
setDiagnosticField("transport", `${scheme}${server.transport || "WebSocket"}`);
|
||||
setDiagnosticField("heartbeat", server.heartbeat_interval_ms
|
||||
? `${Math.round(server.heartbeat_interval_ms / 1000)}s ping · ${Math.round(server.heartbeat_timeout_ms / 1000)}s timeout`
|
||||
: "Waiting for server policy");
|
||||
const network = runtime.network || {};
|
||||
const networkParts = [network.effective_type || client.effective_type];
|
||||
if (Number.isFinite(network.downlink_mbps ?? client.downlink_mbps)) networkParts.push(`${network.downlink_mbps ?? client.downlink_mbps} Mb/s`);
|
||||
if (Number.isFinite(network.rtt_ms ?? client.network_rtt_ms)) networkParts.push(`system RTT ${Math.round(network.rtt_ms ?? client.network_rtt_ms)} ms`);
|
||||
if ((network.save_data ?? client.save_data) === true) networkParts.push("data saver");
|
||||
setDiagnosticField("network", networkParts.filter(Boolean).join(" · ") || (runtime.online === false ? "Offline" : "Not exposed by browser"));
|
||||
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
|
||||
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
|
||||
setDiagnosticField("server", server.server_version ? `RustPad ${server.server_version} · connection ${server.connection_id}` : "Waiting for server data");
|
||||
const lastEvent = runtime.last_close
|
||||
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
|
||||
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
|
||||
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
|
||||
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
|
||||
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
|
||||
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
|
||||
if (!details) continue;
|
||||
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
|
||||
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
|
||||
}
|
||||
}
|
||||
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
|
||||
@@ -774,6 +832,7 @@ export function startNoteEditor(adapter) {
|
||||
},
|
||||
onPresence: updatePresence,
|
||||
onLatency: updateLatency,
|
||||
onDiagnostics: renderConnectionDiagnostics,
|
||||
onChat: appendChatMessage,
|
||||
onError: message => {
|
||||
hideConnectionNotice();
|
||||
@@ -1281,10 +1340,13 @@ export function startNoteEditor(adapter) {
|
||||
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
|
||||
}
|
||||
document.addEventListener("pointerdown", event => {
|
||||
if (pageSettings?.open && !event.target.closest(".page-settings")) pageSettings.open = false;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
|
||||
if (mobileConnectionDetails?.open && (!target || !mobileConnectionDetails.contains(target))) mobileConnectionDetails.open = false;
|
||||
}, { passive: true });
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
|
||||
if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false;
|
||||
});
|
||||
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
|
||||
publicPageEnabled.addEventListener("change", async () => {
|
||||
|
||||
+139
-8
@@ -9,9 +9,10 @@
|
||||
|
||||
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 10000;
|
||||
const HEARTBEAT_TIMEOUT_MS = 30000;
|
||||
const MAX_RECONNECT_DELAY_MS = 12000;
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
||||
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_MAX_RECONNECT_DELAY_MS = 12000;
|
||||
const DEFAULT_LATENCY_SAMPLE_WINDOW = 20;
|
||||
|
||||
class RoomSocket {
|
||||
constructor(options) {
|
||||
@@ -23,6 +24,21 @@ class RoomSocket {
|
||||
this.intentionalClose = false;
|
||||
this.reconnectAttempt = 0;
|
||||
this.pendingPings = new Map();
|
||||
this.heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
this.heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
||||
this.maxReconnectDelayMs = DEFAULT_MAX_RECONNECT_DELAY_MS;
|
||||
this.latencySampleWindow = DEFAULT_LATENCY_SAMPLE_WINDOW;
|
||||
this.latencySamples = [];
|
||||
this.totalReconnects = 0;
|
||||
this.connectedAt = null;
|
||||
this.authenticatedAt = null;
|
||||
this.lastConnectionUptimeMs = 0;
|
||||
this.lastMessageAt = null;
|
||||
this.lastClose = null;
|
||||
this.bytesSent = 0;
|
||||
this.bytesReceived = 0;
|
||||
this.serverDiagnostics = null;
|
||||
this.diagnosticsTimer = null;
|
||||
this.handleOnline = () => this.reconnectNow("Network connection restored.");
|
||||
this.handleOffline = () => this.handleNetworkOffline();
|
||||
this.handleVisibility = () => this.checkHeartbeat();
|
||||
@@ -34,9 +50,23 @@ class RoomSocket {
|
||||
get url() { throw new Error("Socket URL not implemented"); }
|
||||
get kind() { return "room"; }
|
||||
|
||||
clientDiagnostics() {
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
return {
|
||||
language: navigator.language || null,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
|
||||
platform: navigator.userAgentData?.platform || navigator.platform || null,
|
||||
effective_type: connection?.effectiveType || null,
|
||||
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
|
||||
network_rtt_ms: Number.isFinite(connection?.rtt) ? Math.max(0, Math.round(connection.rtt)) : null,
|
||||
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
|
||||
};
|
||||
}
|
||||
|
||||
connect() {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
clearInterval(this.pingTimer);
|
||||
clearInterval(this.diagnosticsTimer);
|
||||
if (this.stopped) return;
|
||||
if (!navigator.onLine) {
|
||||
this.scheduleReconnect("Your device is offline.");
|
||||
@@ -48,6 +78,7 @@ class RoomSocket {
|
||||
attempt: this.reconnectAttempt,
|
||||
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
||||
});
|
||||
this.emitDiagnostics();
|
||||
|
||||
let socket;
|
||||
try {
|
||||
@@ -62,6 +93,9 @@ class RoomSocket {
|
||||
socket.addEventListener("open", () => {
|
||||
if (socket !== this.socket || this.stopped) return;
|
||||
logInfo("websocket.open", { kind: this.kind });
|
||||
this.connectedAt = Date.now();
|
||||
this.serverDiagnostics = null;
|
||||
this.lastClose = null;
|
||||
this.send({
|
||||
type: "authenticate",
|
||||
password: this.password || null,
|
||||
@@ -69,13 +103,17 @@ class RoomSocket {
|
||||
nickname: this.nickname || null,
|
||||
guest_id: this.guestId || null,
|
||||
color: this.color || null,
|
||||
diagnostics: this.clientDiagnostics(),
|
||||
});
|
||||
this.emitDiagnostics();
|
||||
});
|
||||
|
||||
socket.addEventListener("message", event => {
|
||||
if (socket !== this.socket || this.stopped) return;
|
||||
let message;
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
this.lastMessageAt = Date.now();
|
||||
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
|
||||
if (message.type === "error") {
|
||||
this.intentionalClose = true;
|
||||
this.onError?.(message.message);
|
||||
@@ -85,9 +123,17 @@ class RoomSocket {
|
||||
if (message.type === "authenticated") {
|
||||
const restored = this.reconnectAttempt > 0;
|
||||
this.reconnectAttempt = 0;
|
||||
this.authenticatedAt = Date.now();
|
||||
this.latencySamples = [];
|
||||
this.onStatus?.("online", { restored });
|
||||
this.onAuthenticated?.(message);
|
||||
this.startPing();
|
||||
this.startDiagnostics();
|
||||
this.emitDiagnostics();
|
||||
return;
|
||||
}
|
||||
if (message.type === "diagnostics") {
|
||||
this.applyServerDiagnostics(message.diagnostics || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "document") this.onDocument?.(message);
|
||||
@@ -97,7 +143,11 @@ class RoomSocket {
|
||||
const started = this.pendingPings.get(message.nonce);
|
||||
if (started !== undefined) {
|
||||
this.pendingPings.delete(message.nonce);
|
||||
this.onLatency?.(Math.max(0, Math.round(performance.now() - started)));
|
||||
const latency = Math.max(0, Math.round(performance.now() - started));
|
||||
this.latencySamples.push(latency);
|
||||
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
|
||||
this.onLatency?.(latency);
|
||||
this.emitDiagnostics();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -105,9 +155,16 @@ class RoomSocket {
|
||||
socket.addEventListener("close", event => {
|
||||
if (socket !== this.socket) return;
|
||||
clearInterval(this.pingTimer);
|
||||
clearInterval(this.diagnosticsTimer);
|
||||
this.pendingPings.clear();
|
||||
this.onPresence?.([]);
|
||||
this.onLatency?.(null);
|
||||
const closedAt = Date.now();
|
||||
this.lastConnectionUptimeMs = this.authenticatedAt ? Math.max(0, closedAt - this.authenticatedAt) : this.lastConnectionUptimeMs;
|
||||
this.connectedAt = null;
|
||||
this.authenticatedAt = null;
|
||||
this.lastClose = { code: event.code, reason: event.reason || "", at: closedAt };
|
||||
this.emitDiagnostics();
|
||||
logWarn("websocket.close", {
|
||||
kind: this.kind,
|
||||
code: event.code,
|
||||
@@ -138,9 +195,11 @@ class RoomSocket {
|
||||
if (this.stopped) return;
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectAttempt += 1;
|
||||
const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
||||
const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
||||
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
||||
this.totalReconnects += 1;
|
||||
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
|
||||
this.emitDiagnostics();
|
||||
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
||||
}
|
||||
|
||||
@@ -164,7 +223,7 @@ class RoomSocket {
|
||||
checkHeartbeat() {
|
||||
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
const now = performance.now();
|
||||
const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS);
|
||||
const expired = [...this.pendingPings.values()].some(started => now - started >= this.heartbeatTimeoutMs);
|
||||
if (expired) {
|
||||
logWarn("websocket.heartbeat_timeout", { kind: this.kind });
|
||||
this.socket.close(4000, "heartbeat timeout");
|
||||
@@ -181,12 +240,83 @@ class RoomSocket {
|
||||
this.send({ type: "ping", nonce });
|
||||
};
|
||||
ping();
|
||||
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS);
|
||||
this.pingTimer = window.setInterval(ping, this.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
startDiagnostics() {
|
||||
clearInterval(this.diagnosticsTimer);
|
||||
this.diagnosticsTimer = window.setInterval(() => this.emitDiagnostics(), 1000);
|
||||
}
|
||||
|
||||
applyServerDiagnostics(diagnostics) {
|
||||
this.serverDiagnostics = diagnostics;
|
||||
const positiveNumber = (value, fallback, min, max) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
|
||||
};
|
||||
this.heartbeatIntervalMs = positiveNumber(diagnostics.heartbeat_interval_ms, this.heartbeatIntervalMs, 1000, 120000);
|
||||
this.heartbeatTimeoutMs = positiveNumber(diagnostics.heartbeat_timeout_ms, this.heartbeatTimeoutMs, this.heartbeatIntervalMs * 2, 300000);
|
||||
this.maxReconnectDelayMs = positiveNumber(diagnostics.max_reconnect_delay_ms, this.maxReconnectDelayMs, 1000, 120000);
|
||||
this.latencySampleWindow = Math.round(positiveNumber(diagnostics.latency_sample_window, this.latencySampleWindow, 3, 100));
|
||||
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
|
||||
if (this.socket?.readyState === WebSocket.OPEN && this.authenticatedAt) this.startPing();
|
||||
this.onServerDiagnostics?.(diagnostics);
|
||||
this.emitDiagnostics();
|
||||
}
|
||||
|
||||
latencyStats() {
|
||||
if (!this.latencySamples.length) return { current: null, average: null, minimum: null, maximum: null, jitter: null, quality: "unknown" };
|
||||
const samples = this.latencySamples;
|
||||
const current = samples.at(-1);
|
||||
const average = Math.round(samples.reduce((sum, value) => sum + value, 0) / samples.length);
|
||||
const minimum = Math.min(...samples);
|
||||
const maximum = Math.max(...samples);
|
||||
const differences = samples.slice(1).map((value, index) => Math.abs(value - samples[index]));
|
||||
const jitter = differences.length ? Math.round(differences.reduce((sum, value) => sum + value, 0) / differences.length) : 0;
|
||||
const thresholds = this.serverDiagnostics?.quality_thresholds || {};
|
||||
const excellent = Number(thresholds.excellent_max_ms ?? 100);
|
||||
const good = Number(thresholds.good_max_ms ?? 250);
|
||||
const degraded = Number(thresholds.degraded_max_ms ?? 600);
|
||||
const quality = current <= excellent ? "excellent" : current <= good ? "good" : current <= degraded ? "degraded" : "poor";
|
||||
return { current, average, minimum, maximum, jitter, quality };
|
||||
}
|
||||
|
||||
emitDiagnostics() {
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
const readyStates = ["connecting", "open", "closing", "closed"];
|
||||
this.onDiagnostics?.({
|
||||
server: this.serverDiagnostics,
|
||||
runtime: {
|
||||
state: readyStates[this.socket?.readyState ?? WebSocket.CLOSED] || "closed",
|
||||
online: navigator.onLine,
|
||||
visibility: document.visibilityState,
|
||||
uptime_ms: this.authenticatedAt ? Math.max(0, Date.now() - this.authenticatedAt) : 0,
|
||||
last_connection_uptime_ms: this.lastConnectionUptimeMs,
|
||||
reconnect_attempt: this.reconnectAttempt,
|
||||
total_reconnects: this.totalReconnects,
|
||||
connected_at: this.connectedAt,
|
||||
authenticated_at: this.authenticatedAt,
|
||||
last_message_at: this.lastMessageAt,
|
||||
last_close: this.lastClose,
|
||||
buffered_amount: this.socket?.bufferedAmount || 0,
|
||||
bytes_sent: this.bytesSent,
|
||||
bytes_received: this.bytesReceived,
|
||||
network: {
|
||||
effective_type: connection?.effectiveType || null,
|
||||
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
|
||||
rtt_ms: Number.isFinite(connection?.rtt) ? connection.rtt : null,
|
||||
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
|
||||
},
|
||||
latency: this.latencyStats(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) return false;
|
||||
this.socket.send(JSON.stringify(message));
|
||||
const payload = JSON.stringify(message);
|
||||
this.bytesSent += new Blob([payload]).size;
|
||||
this.socket.send(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -199,6 +329,7 @@ class RoomSocket {
|
||||
this.intentionalClose = true;
|
||||
clearTimeout(this.reconnectTimer);
|
||||
clearInterval(this.pingTimer);
|
||||
clearInterval(this.diagnosticsTimer);
|
||||
window.removeEventListener("online", this.handleOnline);
|
||||
window.removeEventListener("offline", this.handleOffline);
|
||||
document.removeEventListener("visibilitychange", this.handleVisibility);
|
||||
|
||||
Reference in New Issue
Block a user