mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-05-29 23:19:48 +00:00
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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
208
frontend/src/composables/useWebSocket.ts
Normal file
208
frontend/src/composables/useWebSocket.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user