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:
1 parent
31b56c3e5e
commit
d4add54f10
7 files changed
+136
-71
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user