feat(websocket): add frontend WebSocket support

Add useWebSocket composable with:
- Auto-connect on login, disconnect on logout
- Exponential backoff with ±25% jitter for reconnects
- Auth failure detection to prevent reconnect loops
- Trailing slash stripping from API_URL
- Overlapping reconnect prevention
- visibilityState check for fallback polling

Replace notification polling with real-time WebSocket push in the
Notifications component. Initial state is still loaded via REST on
mount, with fallback polling when WebSocket is disconnected. Incoming
notifications are deduplicated against already-loaded REST data.
Notifications are reloaded via REST on WS disconnect to catch missed
events.
This commit is contained in:
kolaente
2026-04-02 18:19:00 +02:00
committed by kolaente
parent f5385c574e
commit 09232ed880
4 changed files with 273 additions and 13 deletions

View File

@@ -86,6 +86,7 @@ import {useProjectStore} from '@/stores/projects'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
import {useSidebarResize} from '@/composables/useSidebarResize'
import {useWebSocket} from '@/composables/useWebSocket'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
@@ -136,6 +137,9 @@ watch(() => route.name as string, (routeName) => {
useRenewTokenOnFocus()
const {connect} = useWebSocket()
connect()
const labelStore = useLabelStore()
labelStore.loadAllLabels()

View File

@@ -83,10 +83,11 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRouter, isNavigationFailure, NavigationFailureType, RouteLocationRaw} from 'vue-router'
import NotificationService from '@/services/notification'
import NotificationModel from '@/models/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import User from '@/components/misc/User.vue'
@@ -95,11 +96,12 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
import {useWebSocket} from '@/composables/useWebSocket'
import XButton from '@/components/input/Button.vue'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
const {subscribe, connected: wsConnected} = useWebSocket()
const authStore = useAuthStore()
const router = useRouter()
@@ -117,26 +119,68 @@ const notifications = computed(() => {
})
const userInfo = computed(() => authStore.info)
let interval: ReturnType<typeof setInterval>
let unsubscribeWs: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL = 10000
onMounted(async () => {
// Initial load via REST - wrapped in try/catch so the rest of setup
// (click handler, WS subscription, polling) still runs if this fails
try {
await loadNotifications()
} catch (e) {
console.warn('Failed to load initial notifications:', e)
}
onMounted(() => {
loadNotifications()
document.addEventListener('click', hidePopup)
document.addEventListener('visibilitychange', loadNotifications)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
// Subscribe to real-time notifications
unsubscribeWs = subscribe('notification.created', (msg) => {
if (msg.event === 'notification.created' && msg.data) {
const notification = new NotificationModel(msg.data as Partial<INotification>)
// Avoid duplicates if the same notification was already loaded via REST
const exists = allNotifications.value.some(n => n.id === notification.id)
if (!exists) {
allNotifications.value = [notification, ...allNotifications.value]
}
}
})
// Fallback polling when WebSocket is not available
startPollingFallback()
})
// Reload notifications when WebSocket disconnects to catch any events
// that may have been missed during the disconnect window
watch(wsConnected, (isConnected, wasConnected) => {
if (wasConnected && !isConnected) {
loadNotifications().catch(e => console.warn('Failed to reload notifications after WS disconnect:', e))
}
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
document.removeEventListener('visibilitychange', loadNotifications)
clearInterval(interval)
unsubscribeWs?.()
stopPollingFallback()
})
async function loadNotifications() {
if (document.visibilityState !== 'visible') {
return
function startPollingFallback() {
pollInterval = setInterval(async () => {
if (!wsConnected.value && document.visibilityState === 'visible') {
await loadNotifications()
}
}, POLL_INTERVAL)
}
function stopPollingFallback() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
// We're recreating the notification service here to make sure it uses the latest api user token
}
async function loadNotifications() {
const notificationService = new NotificationService()
allNotifications.value = await notificationService.getAll()
}

View File

@@ -0,0 +1,208 @@
import {ref, readonly} from 'vue'
import {getToken} from '@/helpers/auth'
type MessageCallback = (msg: WebSocketEvent) => void
interface WebSocketEvent {
event?: string
action?: string
success?: boolean
error?: string
data?: unknown
}
const RECONNECT_BASE_DELAY = 1000
const RECONNECT_MAX_DELAY = 30000
let socket: WebSocket | null = null
let reconnectAttempt = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const subscriptions = new Map<string, Set<MessageCallback>>()
const connected = ref(false)
const authenticated = ref(false)
let manuallyDisconnected = false
function getWebSocketUrl(): string {
const base = window.API_URL.replace(/\/+$/, '')
const wsProtocol = base.startsWith('https') ? 'wss' : 'ws'
return base.replace(/^https?/, wsProtocol) + '/ws'
}
function sendMessage(msg: object) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg))
}
}
function sendAuth() {
const token = getToken()
if (token) {
sendMessage({action: 'auth', token})
}
}
function resubscribeAll() {
for (const event of subscriptions.keys()) {
sendMessage({action: 'subscribe', event})
}
}
function handleMessage(event: MessageEvent) {
let msg: WebSocketEvent
try {
msg = JSON.parse(event.data)
} catch {
console.warn('WebSocket: invalid message', event.data)
return
}
// Handle auth success
if (msg.action === 'auth.success' && msg.success) {
authenticated.value = true
console.debug('WebSocket: authenticated')
resubscribeAll()
return
}
// Handle auth error - treat as terminal (no reconnect) so we don't
// thrash the WS endpoint with a bad token. Fallback polling kicks in.
if (msg.error === 'invalid_token' || msg.error === 'auth_required') {
console.warn('WebSocket: auth failed:', msg.error)
manuallyDisconnected = true
authenticated.value = false
connected.value = false
socket?.close()
socket = null
return
}
// Handle regular events — route by event name
if (msg.event) {
const callbacks = subscriptions.get(msg.event)
if (callbacks) {
for (const cb of callbacks) {
cb(msg)
}
}
}
}
function scheduleReconnect() {
if (manuallyDisconnected) {
return
}
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const baseDelay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempt),
RECONNECT_MAX_DELAY,
)
// Add ±25% jitter to prevent thundering herd on server restart
const jitter = baseDelay * (0.75 + Math.random() * 0.5)
const delay = Math.round(jitter)
reconnectAttempt++
console.debug(`WebSocket: reconnecting in ${delay}ms (attempt ${reconnectAttempt})`)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
function connect() {
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
return
}
const token = getToken()
if (!token) {
return
}
manuallyDisconnected = false
authenticated.value = false
const url = getWebSocketUrl()
try {
socket = new WebSocket(url)
} catch (e) {
console.warn('WebSocket: failed to create connection', e)
scheduleReconnect()
return
}
socket.onopen = () => {
connected.value = true
reconnectAttempt = 0
console.debug('WebSocket: connected, sending auth')
sendAuth()
}
socket.onmessage = handleMessage
socket.onclose = () => {
connected.value = false
authenticated.value = false
socket = null
scheduleReconnect()
}
socket.onerror = () => {
// onclose will fire after onerror, which handles reconnect
}
}
function disconnect() {
manuallyDisconnected = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
reconnectAttempt = 0
if (socket) {
socket.close()
socket = null
}
connected.value = false
authenticated.value = false
subscriptions.clear()
}
function subscribe(event: string, callback: MessageCallback): () => void {
if (!subscriptions.has(event)) {
subscriptions.set(event, new Set())
}
subscriptions.get(event)!.add(callback)
// Only send subscribe if already authenticated
// (otherwise it will be sent after auth succeeds)
if (authenticated.value) {
sendMessage({action: 'subscribe', event})
}
return () => {
const callbacks = subscriptions.get(event)
if (callbacks) {
callbacks.delete(callback)
if (callbacks.size === 0) {
subscriptions.delete(event)
sendMessage({action: 'unsubscribe', event})
}
}
}
}
export function useWebSocket() {
return {
connect,
disconnect,
subscribe,
connected: readonly(connected),
authenticated: readonly(authenticated),
}
}

View File

@@ -8,6 +8,7 @@ import UserModel, {getDisplayName, fetchAvatarBlobUrl, invalidateAvatarCache} fr
import AvatarService from '@/services/avatar'
import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {useWebSocket} from '@/composables/useWebSocket'
import {setModuleLoading} from '@/stores/helper'
import {success, error} from '@/message'
import {
@@ -507,6 +508,9 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout() {
const {disconnect} = useWebSocket()
disconnect()
// Revoke the server session so the refresh token can't be reused.
// Best-effort: if the network call fails, still clean up locally.
try {