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

+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'
}
}