Editor sockets: auth-aware reconnect, shared with the analytics view

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.
This commit is contained in:
LeoVasanko committed 2026-09-25 18:19:05 +00:00
1 parent 31b56c3e5e
commit d4add54f10
7 files changed
+136 -71

No files matched your search

+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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/<range>` 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
+1 -1
View File
@@ -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
+23 -52
View File
@@ -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') {
+38 -9
View File
@@ -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)
})
</script>
+36 -7
View File
@@ -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)
})
</script>
+35
View File
@@ -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'
}
}