From d4add54f10d2c1d9a286f4a7d2df76312bb375ec Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 25 Sep 2026 18:19:05 +0000 Subject: [PATCH] Editor sockets: auth-aware reconnect, shared with the analytics view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expired session failed the /_api/ws/editor handshake exactly like a dead network, so the page/banner editors retried forever — the WS API cannot open the login dialog. reconnect.js gains authReconnect(): on close, probe /_api/settings (dialog-less fetchJson); a 401/403 runs the paskia login flow and reconnects the moment it completes, a declined login holds at a 'session expired' ConnNote, and only transient failures take the paced backoff. A paskia auth-success message from anywhere in the page reconnects a waiting editor at once. AnalyticsView moves from its inline copy of the same flow onto the shared helper. --- AGENTS.md | 2 +- docs/analytics.md | 3 +- docs/editing.md | 2 +- frontend/src/AnalyticsView.vue | 75 +++++++++++----------------------- frontend/src/BannerEditor.vue | 47 +++++++++++++++++---- frontend/src/PageEditor.vue | 43 +++++++++++++++---- frontend/src/reconnect.js | 35 ++++++++++++++++ 7 files changed, 136 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 937af8d..ddb5460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state. - `pagerite.js` — public page entry. - `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`). - - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling. + - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling. Also the auth-aware close handling for the admin sockets (`authReconnect`): probe `/_api/settings` on close, run the paskia login flow on 401/403 and reconnect at once, pace only genuine network/server failures. - `assets/` — base CSS, Pygments styles, fonts. - `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test). - `scripts/translator.py` — Seed-X translator service client for the `/_translate/{key}` socket (reference client, runs in its own uv env via PEP 723); stays connected full time, unloads the model after 60 s idle and reloads on the next job. diff --git a/docs/analytics.md b/docs/analytics.md index af86caa..36bad59 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -355,7 +355,8 @@ spans less than 24 hours, otherwise `week`. `AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open` page-chrome hiding and `#/analytics/` hash routing have been removed. -The viewer owns its connection lifecycle. When the socket drops it probes +The viewer owns its connection lifecycle (the probe/login mechanics are the +shared `authReconnect` in `reconnect.js`, also used by the editor sockets). When the socket drops it probes `/_api/settings` (plain `fetchJson`, never pops a dialog) to tell an expired session from a dead network: a 401/403 runs the paskia login flow instead of retrying (the WS handshake cannot open the login dialog) and the stream diff --git a/docs/editing.md b/docs/editing.md index bc9a2f3..e8361ad 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -18,7 +18,7 @@ In-place page re-rendering shared by the banner/site/structure tabs lives in `sw The page and structure tabs share one language selector: `LangSelect.vue` (small flag + dropdown) v-modeled on the shell-wide selection in `editorLang.js` (`''` = primary). While the panel is open that selection overrides the page's normal language preferences: EditorShell calls `swapdoc.setLangOverride`, which pins every `loadPlain` fetch (`?lang=`, the primary by its own code) and pagerite.js's own fetches/prefetches (`pagerite:session-lang`), until the panel closes and the override clears. -All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it). +All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. The admin sockets (`/_api/...`) also close through `authReconnect`: an expired session fails the WS handshake like a dead network but can never open the login dialog from a socket, so the close handler probes `/_api/settings` first — a 401/403 runs the paskia login flow and reconnects the moment it completes (a declined login holds at a "session expired" note; a login anywhere in the page reconnects the panel, via paskia's `auth-success` window message), and only genuine network/server failures take the paced backoff. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it). ## Saving behavior diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index cfb821d..0b79ba0 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -5,7 +5,7 @@ // visit/crawler tables. Read-only. // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' -import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia' +import { fetchJson } from 'paskia' import { RANGES, rangeWindow, @@ -29,7 +29,7 @@ import VisitorCell from './VisitorCell.vue' import TransitionGraph from './TransitionGraph.vue' import VisitorCharts from './VisitorCharts.vue' import { VIEW_W } from './analytics/chart.js' -import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' +import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import ConnNote from './ConnNote.vue' // Same centering margin as the charts, so the totals row's left edge @@ -49,7 +49,6 @@ const reconnects = reconnectPolicy() let timeInterval = null let mounted = false let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail -let authFlow = false // our paskia login flow (apiJson) is pending let hiddenAt = 0 // The panel is live data over its socket: while it is connecting, waiting to @@ -104,26 +103,28 @@ function connect() { ws.onclose = onSocketClose } -// The socket dropped. Find out why before retrying: an expired session fails -// the handshake exactly like a dead network, but retrying the socket loops -// forever — the WS API cannot open the login dialog. Probe /_api with plain -// fetchJson (it never pops a dialog): 401/403 means the session is gone and -// the login flow runs instead of another blind retry; anything else is -// transient and gets the paced backoff. +// The socket dropped. Find out why before retrying (./reconnect's +// authReconnect): an expired session fails the handshake exactly like a dead +// network, but retrying the socket loops forever — the WS API cannot open +// the login dialog. A lost session runs the paskia login flow and reconnects +// the moment it completes; an aborted login (declined, or a proxy offering +// no paskia flow) leaves for the front page — the viewer is useless without +// a session; anything else gets the paced backoff. async function onSocketClose() { ws = null if (!mounted) return const gen = epoch const wait = reconnects.closed() - let denied = false - try { - await fetchJson('/_api/settings') - } catch (e) { - denied = e instanceof ApiError && (e.status === 401 || e.status === 403) - } + const outcome = await authReconnect(() => { conn.value = 'login' }) if (!mounted || gen !== epoch) return - if (denied) beginLogin() - else scheduleRetry(wait) + if (outcome === 'auth') { + loadPageTree() // the mount-time fetch may have run anonymous + connectNow() + } else if (outcome === 'abort') { + goHome() + } else { + scheduleRetry(wait) + } } function scheduleRetry(wait) { @@ -133,37 +134,6 @@ function scheduleRetry(wait) { reconnectTimer = setTimeout(connect, wait) } -// The session is missing or expired: run the paskia login flow instead of -// retrying. apiJson pauses on the 401/403 while the auth iframe completes and -// retries the request, so resolving means we are in — reconnect at once, no -// backoff. An aborted login (declined, or a proxy offering no paskia flow) -// leaves for the front page: the viewer is useless without a session. -async function beginLogin() { - if (authFlow || !mounted) return - clearTimeout(reconnectTimer) - reconnectTimer = null - conn.value = 'login' - authFlow = true - try { - await apiJson('/_api/settings') - } catch (e) { - if (mounted) { - if (e instanceof NetworkError - || (e instanceof ApiError && e.status !== 401 && e.status !== 403)) { - // The server vanished mid-login: back to paced retries. - scheduleRetry(reconnects.closed()) - } else { - goHome() - } - } - return - } finally { - authFlow = false - } - loadPageTree() // the mount-time fetch may have run anonymous - connectNow() -} - // Leave for the front page, replacing the history entry: Back must not // return to /_a just to pop the login flow again. function goHome() { @@ -213,14 +183,15 @@ function onPageShow(ev) { } // paskia's auth iframe announces its result as a window message: a login -// completed anywhere in the page (the banner's 🔑 button or our own flow) -// reconnects at once instead of waiting out the backoff timer; a logout -// leaves for the front page — the stream is useless without a session. +// completed anywhere in the page (the banner's 🔑 button or our own flow — +// 'login' means it is pending and will connect itself on resolve) reconnects +// at once instead of waiting out the backoff timer; a logout leaves for the +// front page — the stream is useless without a session. function onAuthMessage(ev) { if (!mounted) return const type = ev.data?.type if (type === 'auth-success') { - if (authFlow || conn.value === 'open') return + if (conn.value === 'open' || conn.value === 'login') return loadPageTree() connectNow() } else if (type === 'auth-logout') { diff --git a/frontend/src/BannerEditor.vue b/frontend/src/BannerEditor.vue index 1dd8595..514d280 100644 --- a/frontend/src/BannerEditor.vue +++ b/frontend/src/BannerEditor.vue @@ -11,7 +11,7 @@ import { indentWithTab } from '@codemirror/commands' import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' import ConnNote from './ConnNote.vue' -import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' +import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { apiFetch, apiJson } from 'paskia' @@ -34,17 +34,21 @@ let reconnectTimer = null let connectWatchdog = null // Reconnection pacing lives in ./reconnect (shared with the other sockets). const reconnects = reconnectPolicy() +// Bumped by every connect()/unmount: the auth probe in onclose is async, so +// a stale tail checks it did not get superseded before acting. +let connectEpoch = 0 let everConnected = false // Connection state drives the note at the top (ConnNote), and locks input // until the banner's document has arrived (typing before it would be // clobbered by the doc accept). -const conn = ref('connecting') // connecting | open | waiting +const conn = ref('connecting') // connecting | open | waiting | login const retryIn = ref(0) const docReady = ref(false) const editable = new Compartment() const connNote = computed(() => conn.value === 'connecting' ? 'connecting to the server…' : conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…` + : conn.value === 'login' ? 'session expired — log in to continue editing…' : docReady.value ? '' : 'loading the banner…', ) let view = null // CodeMirror for the banner HTML @@ -393,6 +397,7 @@ function onKeydown(ev) { } function connect() { + connectEpoch++ clearTimeout(reconnectTimer) conn.value = 'connecting' if (ws) { @@ -416,17 +421,38 @@ function connect() { } everConnected = true } - ws.onclose = () => { - // The wait is the policy's: doubling backoff with jitter (./reconnect), - // reset only by a healthy connection — rapid retries trip the browser's - // WebSocket throttling (sockets stuck "pending" for minutes). + ws.onclose = async () => { + // An expired session fails the handshake like a dead network, but blind + // retries loop forever — the WS API cannot open the login dialog. Probe + // /_api (./reconnect's authReconnect): a lost session runs the paskia + // login flow and reconnects the moment it completes; a declined login + // holds at the 'login' note (a later login anywhere in the page, or a + // save attempt, reconnects); only transient failures take the paced + // backoff (./reconnect), reset only by a healthy connection — rapid + // retries trip the browser's WebSocket throttling. + const gen = connectEpoch const wait = reconnects.closed() - retryIn.value = Math.max(1, Math.round(wait / 1000)) - conn.value = 'waiting' - reconnectTimer = setTimeout(connect, wait) + const outcome = await authReconnect(() => { conn.value = 'login' }) + if (gen !== connectEpoch) return + if (outcome === 'auth') { + connect() + } else if (outcome === 'abort') { + conn.value = 'login' + } else { + retryIn.value = Math.max(1, Math.round(wait / 1000)) + conn.value = 'waiting' + reconnectTimer = setTimeout(connect, wait) + } } } +// A login completed anywhere in the page (the banner's 🔑 button, another +// panel's flow) reconnects a waiting editor at once instead of waiting out +// the backoff. paskia's auth iframe announces it as a window message. +function onAuthMessage(ev) { + if (ev.data?.type === 'auth-success' && conn.value !== 'open') connect() +} + onMounted(async () => { // The first connection takes a staggered slot (see ./reconnect). reconnectTimer = setTimeout(connect, socketSlot()) @@ -455,10 +481,12 @@ onMounted(async () => { }) addEventListener('keydown', onKeydown) addEventListener('pagerite:editor-shown', onEditorShown) + addEventListener('message', onAuthMessage) await loadSettings() }) onUnmounted(() => { + connectEpoch++ // invalidate a pending onclose auth probe clearTimeout(reconnectTimer) clearTimeout(connectWatchdog) for (const t of Object.values(timers)) clearTimeout(t) @@ -469,6 +497,7 @@ onUnmounted(() => { view?.destroy() removeEventListener('keydown', onKeydown) removeEventListener('pagerite:editor-shown', onEditorShown) + removeEventListener('message', onAuthMessage) }) diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 1a78ec6..83746f6 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -38,7 +38,7 @@ import { flagFor, langName, langSort } from './langs' import { editorLang, pagePrimary } from './editorLang' import LangSelect from './LangSelect.vue' import ConnNote from './ConnNote.vue' -import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' +import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { dropPageCache, loadPlain } from './swapdoc' const props = defineProps({ @@ -75,13 +75,14 @@ let sessionDoc = null // input until the page's document has arrived: typing before the accept // would be clobbered by it. While merely DISconnected the editor stays // editable — text stashes and pending saves flush on reconnect. -const conn = ref('connecting') // connecting | open | waiting +const conn = ref('connecting') // connecting | open | waiting | login const retryIn = ref(0) const docReady = ref(false) const editable = new Compartment() const connNote = computed(() => conn.value === 'connecting' ? 'connecting to the server…' : conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…` + : conn.value === 'login' ? 'session expired — log in to continue editing…' : docReady.value ? '' : 'loading the page…', ) @@ -96,6 +97,9 @@ let connectWatchdog = null // retries trip the browser's WebSocket throttling (sockets stuck "pending" // for minutes), which is what kept hollowing out the editor. const reconnects = reconnectPolicy() +// Bumped by every connect()/unmount: the auth probe in onclose is async, so +// a stale tail checks it did not get superseded before acting. +let connectEpoch = 0 const dirty = ref(false) // unsaved text exists (drives the 💾 button) let syncingScroll = false @@ -990,6 +994,7 @@ function consumePendingLine() { } function connect() { + connectEpoch++ clearTimeout(reconnectTimer) conn.value = 'connecting' if (ws) { @@ -1021,19 +1026,40 @@ function connect() { openPath(normPath(props.pagePath)) } } - ws.onclose = (ev) => { + ws.onclose = async (ev) => { // 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or // the connecting watchdog fired); worth seeing since a dead socket // before the first doc bricks the editor until a retry lands one. - // The wait is the policy's: doubling backoff with jitter (./reconnect). console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '') + const gen = connectEpoch const wait = reconnects.closed() - retryIn.value = Math.max(1, Math.round(wait / 1000)) - conn.value = 'waiting' - reconnectTimer = setTimeout(connect, wait) + // An expired session fails the handshake like a dead network, but blind + // retries loop forever — the WS API cannot open the login dialog. Probe + // /_api (./reconnect's authReconnect): a lost session runs the paskia + // login flow and reconnects the moment it completes; a declined login + // holds at the 'login' note (a later login anywhere in the page, or a + // save attempt, reconnects); only transient failures take the backoff. + const outcome = await authReconnect(() => { conn.value = 'login' }) + if (gen !== connectEpoch) return + if (outcome === 'auth') { + connect() + } else if (outcome === 'abort') { + conn.value = 'login' + } else { + retryIn.value = Math.max(1, Math.round(wait / 1000)) + conn.value = 'waiting' + reconnectTimer = setTimeout(connect, wait) + } } } +// A login completed anywhere in the page (the banner's 🔑 button, another +// panel's flow) reconnects a waiting editor at once instead of waiting out +// the backoff. paskia's auth iframe announces it as a window message. +function onAuthMessage(ev) { + if (ev.data?.type === 'auth-success' && conn.value !== 'open') connect() +} + onMounted(() => { // The first connection takes a staggered slot (see ./reconnect): page // load opens several sockets at once, and the burst trips throttling. @@ -1090,9 +1116,11 @@ onMounted(() => { addEventListener('pagerite:editor-shown', onEditorShown) // A section pen clicked while the page editor is already open. addEventListener('pagerite:edit-section', consumePendingLine) + addEventListener('message', onAuthMessage) }) onUnmounted(() => { + connectEpoch++ // invalidate a pending onclose auth probe clearTimeout(reconnectTimer) clearTimeout(connectWatchdog) if (ws) { @@ -1106,6 +1134,7 @@ onUnmounted(() => { removeEventListener('keydown', onKeydown) removeEventListener('pagerite:editor-shown', onEditorShown) removeEventListener('pagerite:edit-section', consumePendingLine) + removeEventListener('message', onAuthMessage) }) diff --git a/frontend/src/reconnect.js b/frontend/src/reconnect.js index 2656204..be9821b 100644 --- a/frontend/src/reconnect.js +++ b/frontend/src/reconnect.js @@ -10,6 +10,8 @@ // (a few seconds, doubling to half a minute), reset only after a // connection stayed open long enough to count as healthy. A socket // that closes right after opening must NOT reset the backoff. +import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia' + export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) { let delay = min let openedAt = 0 @@ -54,3 +56,36 @@ export function watchConnecting(ws, label) { } }, 10_000) } + +// Auth-aware close handling for the admin sockets (/_api/...). An expired +// session fails the WS handshake exactly like a dead network, but retrying +// the socket loops forever — the WS API cannot open the login dialog. So +// probe /_api first (plain fetchJson, which never pops a dialog) and: +// +// 'retry' — the session is fine or the trouble is a network/server one: +// take the policy's paced backoff. +// 'auth' — the session was gone, so the paskia login flow ran (apiJson +// pops the auth iframe and retries through it) and completed: +// reconnect at once, no backoff. +// 'abort' — the login was declined or the proxy offers no paskia flow: +// stop retrying; the caller decides what its UI does next. +// +// onLogin fires when the login flow starts, so the caller can say so. +export async function authReconnect(onLogin) { + try { + await fetchJson('/_api/settings') + return 'retry' + } catch (e) { + if (!(e instanceof ApiError && (e.status === 401 || e.status === 403))) return 'retry' + } + onLogin?.() + try { + await apiJson('/_api/settings') + return 'auth' + } catch (e) { + return e instanceof NetworkError + || (e instanceof ApiError && e.status !== 401 && e.status !== 403) + ? 'retry' + : 'abort' + } +}