From b5a4da1285a3c861d4bf8d0bcdeb328416b00758 Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 18:04:10 +0500 Subject: [PATCH] feat(timer): sync open tabs via SSE When a timer starts, pauses, or stops, other open clients restore state from the backend instead of staying stale. Co-authored-by: Cursor --- Caddyfile | 11 ++ alfy-bot-frontend/src/composables/useSse.ts | 112 ++++++++++++++++++ .../features/task-timer/model/timer-store.ts | 32 ++++- .../src/features/task-timer/ui/TimeBlock.vue | 10 +- alfy-bot-frontend/src/sw.ts | 4 +- .../tests/composables/useSse.spec.ts | 108 +++++++++++++++++ .../task-timer/timer-store-restore.spec.ts | 90 ++++++++++++++ alfy-bot/src/app.module.ts | 2 + .../modules/events/domain/user-events.port.ts | 11 ++ .../modules/events/events.controller.spec.ts | 37 ++++++ .../src/modules/events/events.controller.ts | 29 +++++ alfy-bot/src/modules/events/events.module.ts | 13 ++ .../in-memory-user-events.hub.spec.ts | 39 ++++++ .../in-memory-user-events.hub.ts | 37 ++++++ alfy-bot/src/modules/task/task.module.ts | 2 + .../task/timer-session.service.spec.ts | 12 ++ .../src/modules/task/timer-session.service.ts | 10 +- 17 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 alfy-bot-frontend/src/composables/useSse.ts create mode 100644 alfy-bot-frontend/tests/composables/useSse.spec.ts create mode 100644 alfy-bot-frontend/tests/features/task-timer/timer-store-restore.spec.ts create mode 100644 alfy-bot/src/modules/events/domain/user-events.port.ts create mode 100644 alfy-bot/src/modules/events/events.controller.spec.ts create mode 100644 alfy-bot/src/modules/events/events.controller.ts create mode 100644 alfy-bot/src/modules/events/events.module.ts create mode 100644 alfy-bot/src/modules/events/infrastructure/in-memory-user-events.hub.spec.ts create mode 100644 alfy-bot/src/modules/events/infrastructure/in-memory-user-events.hub.ts diff --git a/Caddyfile b/Caddyfile index 46c035f..f524b1e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -6,6 +6,17 @@ tracker.rocketup.tech { } } + @sse path /api/events /api/events/* + handle @sse { + reverse_proxy backend:3002 { + flush_interval -1 + transport http { + read_timeout 0 + write_timeout 0 + } + } + } + handle /api/* { reverse_proxy backend:3002 } diff --git a/alfy-bot-frontend/src/composables/useSse.ts b/alfy-bot-frontend/src/composables/useSse.ts new file mode 100644 index 0000000..19bee20 --- /dev/null +++ b/alfy-bot-frontend/src/composables/useSse.ts @@ -0,0 +1,112 @@ +import { onUnmounted } from 'vue' +import { api } from '@/api/client' +import { getToken } from '@/api/tokenStorage' + +export type SseHandler = (event: string, data: unknown) => void + +const INITIAL_RETRY_MS = 1000 +const MAX_RETRY_MS = 30_000 + +function parseSseBlock(block: string): { event: string, data: string } | null { + let event = 'message' + let data = '' + for (const line of block.split('\n')) { + if (line.startsWith('event:')) + event = line.slice(6).trim() + else if (line.startsWith('data:')) + data += line.slice(5).trim() + } + if (!data) + return null + return { event, data } +} + +export function useSse(path: string, onEvent: SseHandler) { + let abort: AbortController | null = null + let retryMs = INITIAL_RETRY_MS + let stopped = false + let retryTimer: ReturnType | null = null + + async function readStream(body: ReadableStream) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buf = '' + + while (true) { + if (stopped) + break + const { done, value } = await reader.read() + if (done) + break + buf += decoder.decode(value, { stream: true }) + const parts = buf.split('\n\n') + buf = parts.pop() ?? '' + for (const block of parts) { + const parsed = parseSseBlock(block) + if (!parsed) + continue + try { + onEvent(parsed.event, JSON.parse(parsed.data)) + } + catch { + onEvent(parsed.event, parsed.data) + } + } + } + } + + async function connect() { + const token = getToken() + if (!token) + return + + abort = new AbortController() + const base = String(api.defaults.baseURL ?? '').replace(/\/$/, '') + const res = await fetch(`${base}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'text/event-stream', + }, + signal: abort.signal, + }) + + if (res.status === 401) { + stopped = true + return + } + if (!res.ok || !res.body) + throw new Error(`SSE ${res.status}`) + + retryMs = INITIAL_RETRY_MS + await readStream(res.body) + } + + async function loop() { + while (true) { + if (stopped) + return + try { + await connect() + } + catch (e) { + if (stopped || (e instanceof DOMException && e.name === 'AbortError')) + return + } + if (stopped) + return + await new Promise((resolve) => { + retryTimer = setTimeout(resolve, retryMs) + }) + retryMs = Math.min(retryMs * 2, MAX_RETRY_MS) + } + } + + loop() + + onUnmounted(() => { + stopped = true + if (retryTimer) + clearTimeout(retryTimer) + abort?.abort() + }) +} diff --git a/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts b/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts index e8bcd38..3455be1 100644 --- a/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts +++ b/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts @@ -30,6 +30,11 @@ export const useTimerStore = defineStore('timer', () => { const timerInterval: Ref | null> = ref(null) const { play: playSound } = useSounds() let swListenerRegistered = false + let sseMutedUntil = 0 + + function muteRemoteRestore(ms = 1500): void { + sseMutedUntil = Date.now() + ms + } const checkPhase = { isWorkPhase: (phaseNumber: number): boolean => phaseNumber % 2 === 1, @@ -200,6 +205,8 @@ export const useTimerStore = defineStore('timer', () => { const taskId = currentSettings.value.taskId if (!taskId) return + muteRemoteRestore() + try { if (isActive.value) { const expiresAt = new Date(Date.now() + timeBlock.value * 1000).toISOString() @@ -229,9 +236,14 @@ export const useTimerStore = defineStore('timer', () => { async function restoreSession(): Promise { registerSWListener() + if (Date.now() < sseMutedUntil) return + try { const { data: session } = await api.get('/tasks/timer') - if (!session) return + if (!session) { + clearLocalSession() + return + } if (session.task?.pomodoroConfig) { const cfg = session.task.pomodoroConfig @@ -245,6 +257,8 @@ export const useTimerStore = defineStore('timer', () => { } } + stopLocalTicker() + const sessionState = determineSessionState(session) switch (sessionState.type) { @@ -318,28 +332,34 @@ export const useTimerStore = defineStore('timer', () => { } } - async function resetToInitialState(): Promise { + function stopLocalTicker(): void { if (timerInterval.value) { clearInterval(timerInterval.value) timerInterval.value = null } - const timerId = currentSettings.value.taskId || 'pomodoro' sendToSW({ type: 'TIMER_STOP', data: { id: timerId } }) - isActive.value = false expiresAt.value = null + } + + function clearLocalSession(): void { + stopLocalTicker() phase.value = 0 timeBlock.value = 0 namePhase.value = '' + updateTitle() + } + + async function resetToInitialState(): Promise { + muteRemoteRestore() + clearLocalSession() try { await api.delete('/tasks/timer') } catch (err) { console.error('Ошибка деактивации таймера:', err) } - - updateTitle() } function startTask(task: Task): void { diff --git a/alfy-bot-frontend/src/features/task-timer/ui/TimeBlock.vue b/alfy-bot-frontend/src/features/task-timer/ui/TimeBlock.vue index 48c4f8a..0d36c72 100644 --- a/alfy-bot-frontend/src/features/task-timer/ui/TimeBlock.vue +++ b/alfy-bot-frontend/src/features/task-timer/ui/TimeBlock.vue @@ -1,8 +1,9 @@