feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림

- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기,
  401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기
- 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만
- 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기
- 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 13:32:09 +09:00
parent f725908053
commit 50165bd3b7
52 changed files with 5234 additions and 38 deletions
+5 -2
View File
@@ -217,8 +217,11 @@ function shouldRefresh(code: string, config: InternalAxiosRequestConfig): boolea
)
}
/** 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다 */
function isSessionEnd(code: ErrorCode): boolean {
/**
* 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다.
* axios 를 거치지 않는 연결(실시간 알림 — useMailRealtime)도 같은 규칙으로 가른다
*/
export function isSessionEnd(code: ErrorCode): boolean {
return code === 'AUTH_001' || code === 'AUTH_002'
}
@@ -0,0 +1,74 @@
import notificationIconUrl from '@/assets/brand/notification-icon.png'
import { DESKTOP_NOTIFY_TAG } from '@/shared/constants/mail-notify'
import { supportsDesktopNotification } from '@/shared/utils/browser-support'
import type { DesktopNotifyPermission } from '@/shared/utils/desktop-notify'
// 브라우저 바탕화면 알림(Notification) — 지원 · 허락 확인, 허락 묻기, 띄우기 (디자인 README "5단계 › 바탕화면 알림")
// 모양은 운영체제 · 브라우저가 그린다 — 정하는 것은 제목 · 내용 · 아이콘(ARA 마크, 바탕 #0B3640 둥근 사각)뿐이다.
// 아이콘은 assets/brand/notification-icon.png (192px — logo-ara-white.png 를 둥근 사각 가운데 34/44 폭으로 얹었다).
// 알림은 하나의 tag 로 앞 알림을 바꿔 쓴다(renotify 로 다시 알린다) — 바탕화면에 쌓이지 않는다.
// 허락은 사용자가 단추 · 칸을 누른 뒤에만 묻는다 (requestNotificationPermission 은 누른 처리 안에서 기다리기 전에 부른다).
// 휴대폰 Chrome 처럼 Notification 은 있는데 페이지에서 만들 수 없는(만들면 예외를 던지는) 브라우저가 있다 — 그 결과를
// 'unsupported' 로 돌려주고, 부르는 쪽이 설정(mail-notify.store 의 markUnsupported)을 "지원하지 않음"으로 바꾼다.
/** lib.dom 에 아직 없는 renotify — 같은 tag 로 바꿔 쓸 때도 다시 알린다 (지원하지 않는 브라우저는 무시한다) */
type DesktopNotificationOptions = NotificationOptions & { renotify?: boolean }
export function currentNotificationPermission(): DesktopNotifyPermission {
if (!supportsDesktopNotification()) return 'unsupported'
try {
return Notification.permission
} catch {
return 'unsupported'
}
}
/**
* 허락을 묻는다 — 이미 정했으면(허용 · 차단) 브라우저는 묻지 않고 그 값을 준다. 그냥 닫으면 'default' 다.
* 누른 처리 안에서 기다리기 전에 부른다(브라우저가 사용자 동작 없이 묻는 것을 막는다). 실패하면 지금 값을 돌려준다.
* 콜백 방식(옛 Safari)은 받지 않는다 — 그런 브라우저는 Web Locks 도 없어 지원하지 않는 것으로 본다 (browser-support.ts)
*/
export async function requestNotificationPermission(): Promise<DesktopNotifyPermission> {
if (!supportsDesktopNotification()) return 'unsupported'
try {
return await Notification.requestPermission()
} catch {
return currentNotificationPermission()
}
}
export interface DesktopNotificationInput {
title: string
body: string
/** 눌렀을 때 — 창을 앞으로 가져오고 알림을 닫은 뒤에 부른다 */
onClick?: () => void
}
/**
* 띄운 결과 — shown: 띄웠다(close 로 닫는다) · not-allowed: 허락이 없다(지원하지 않음 포함) ·
* unsupported: 만들 수 없는 브라우저다(만들면 예외 — 휴대폰 Chrome 등)
*/
export type DesktopNotificationResult = { status: 'shown'; close: () => void } | { status: 'not-allowed' | 'unsupported' }
export function showDesktopNotification(input: DesktopNotificationInput): DesktopNotificationResult {
if (currentNotificationPermission() !== 'granted') return { status: 'not-allowed' }
const options: DesktopNotificationOptions = {
body: input.body,
tag: DESKTOP_NOTIFY_TAG,
renotify: true,
icon: notificationIconUrl,
}
let notification: Notification
try {
notification = new Notification(input.title, options)
} catch {
// 페이지에서 바로 띄우지 못한다(서비스 워커만 — 휴대폰 Chrome 등)
return { status: 'unsupported' }
}
notification.onclick = () => {
window.focus()
notification.close()
input.onClick?.()
}
return { status: 'shown', close: () => notification.close() }
}
@@ -60,6 +60,19 @@ export function useMailNavigation() {
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
}
/**
* 바탕화면 알림을 눌렀을 때 (디자인 README "5단계 › 바탕화면 알림") — 받은편지함의 그 메일, 여러 통이면(messageId 없음) 받은편지함.
* 사용자가 고른 이동이라 기록에 쌓는다 — 목록에서 메일을 여는 것과 같다 (이미 그 화면이면 그대로).
* 받은편지함을 모르면 /mail 로 — /mail 은 메일함을 받은 뒤 기본 메일함(받은편지함)으로 바꿔 끼운다
*/
async function openFromNotification(inboxId: string | null, messageId: string | null): Promise<void> {
if (!inboxId) {
await router.push({ name: 'mail' })
return
}
await router.push(messageId ? messageLocation(inboxId, messageId) : mailboxLocation(inboxId))
}
return {
mailboxLocation,
messageLocation,
@@ -69,5 +82,6 @@ export function useMailNavigation() {
showSibling,
showSiblingIn,
replaceWithMailbox,
openFromNotification,
}
}
@@ -0,0 +1,889 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { effectScope, watch, type EffectScope } from 'vue'
import {
isUserViewingPage,
useMailRealtime,
type MailRealtimeOptions,
type NewMailContext,
} from '@/composables/useMailRealtime'
import {
MAIL_EVENTS_CHANNEL_NAME,
MAIL_EVENTS_SILENCE_MS,
MAIL_EVENTS_STABLE_MS,
MAIL_EVENTS_VISIBILITY_WAIT_MS,
} from '@/shared/constants/mail-notify'
import type { NewMailSignal } from '@/shared/utils/mail-events'
import {
mailEventsLockName,
packRealtimeMessage,
unpackRealtimeMessage,
type MailRealtimeStatus,
type RealtimeMessage,
} from '@/shared/utils/mail-realtime'
import { SSE_MAX_FIELD_LENGTH } from '@/shared/utils/sse-parser'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { createEventStreamServer, type EventStreamServer } from '@/test-utils/event-stream'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { installFakeLocks, uninstallFakeLocks, type FakeLockManager } from '@/test-utils/web-locks'
// 실시간 새 메일의 연결 (계약 2절 "실시간 알림 (GET /api/mail-events)")
// 가짜 서버(SSE) · 가짜 잠금(Web Locks) · 가짜 채널(BroadcastChannel) · 손으로 돌리는 시계로 확인한다 (고정 대기 없이).
// 다시 잇는 간격의 흔들림은 가운데 값(0.5 — 흔들림 없음)으로 둔다. 서버 종료 뒤 흔들림(0~3초)은 그래서 1.5초다.
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const OTHER_USER = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
/** 흔들림 0.5 일 때 서버 종료(end) 뒤 다시 잇기까지 */
const SHUTDOWN_SPREAD_AT_HALF_MS = 1_500
interface Tab {
scope: EffectScope
pinia: Pinia
realtime: ReturnType<typeof useMailRealtimeStore>
auth: ReturnType<typeof useAuthStore>
/** 연결 상태가 바뀐 차례 */
statuses: MailRealtimeStatus[]
}
describe('useMailRealtime (실시간 연결)', () => {
let clock: ManualClock
let server: EventStreamServer
let locks: FakeLockManager
let tabs: Tab[]
let apiRequests: MockRequest[]
beforeEach(() => {
clock = createManualClock()
server = createEventStreamServer()
locks = installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
tabs = []
apiRequests = installMockApi(({ url }) => (url === '/auth/refresh' ? ok(USER) : ok(null)))
})
afterEach(() => {
tabs.forEach((tab) => tab.scope.stop())
FakeBroadcastChannel.closeAll()
uninstallFakeLocks()
vi.unstubAllGlobals()
vi.restoreAllMocks()
restoreApiAdapter()
useToastStore().clear()
})
/** 탭 하나 — 탭마다 스토어(Pinia)가 따로다. 잠금 · 채널은 브라우저 하나를 함께 쓴다 */
function openTab(options: MailRealtimeOptions = {}, user = USER): Tab {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.markAuthenticated(user)
const scope = effectScope()
const realtime = useMailRealtimeStore(pinia)
const statuses: MailRealtimeStatus[] = []
scope.run(() => {
watch(
() => realtime.status,
(status) => statuses.push(status),
{ flush: 'sync' },
)
useMailRealtime({ clock, random: () => 0.5, fetch: server.fetch, isUserViewing: () => false, ...options })
})
const tab: Tab = { scope, pinia, realtime, auth, statuses }
tabs.push(tab)
return tab
}
/** 잠금 · 응답 · 채널 전달(다음 작업)이 끝나기를 몇 번 양보해 기다린다 */
async function settle() {
for (let round = 0; round < 6; round += 1) await flushPromises()
}
async function tick(ms: number) {
clock.advance(ms)
await settle()
}
/** 같은 브라우저의 다른 탭이 쓰는 채널 — 받은 메시지를 모은다 */
function rawTab(email = USER.email) {
const channel = new FakeBroadcastChannel(MAIL_EVENTS_CHANNEL_NAME)
const received: RealtimeMessage[] = []
channel.addEventListener('message', (event) => {
const message = unpackRealtimeMessage((event as MessageEvent<unknown>).data, email)
if (message) received.push(message)
})
return {
channel,
received,
post: (message: RealtimeMessage, from = email) => channel.postMessage(packRealtimeMessage(from, message)),
}
}
/** 다른 탭이 잠금을 쥐고 있다 — 돌려준 함수로 놓는다(그 탭이 stop 없이 닫힌 것처럼) */
function holdLockElsewhere(): () => void {
let release: () => void = () => undefined
void navigator.locks.request(
mailEventsLockName(USER.email),
() =>
new Promise<void>((resolve) => {
release = resolve
}),
)
return () => release()
}
/** 연결이 자리 잡게 한다 — 첫 state 와 정상 ping */
async function openAndSettle() {
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().emit('ping', { upstream: true })
await settle()
}
const refreshCount = () => apiRequests.filter((request) => request.url === '/auth/refresh').length
it('로그인한 탭이 잠금을 받아 /api/mail-events 를 fetch 로 연다 — 같은 주소 · 쿠키 · SSE', async () => {
const tab = openTab()
await settle()
expect(server.connections).toHaveLength(1)
const [connection] = server.connections
expect(connection?.url).toBe('/api/mail-events')
expect(connection?.init).toMatchObject({
method: 'GET',
cache: 'no-store',
credentials: 'same-origin',
headers: { Accept: 'text/event-stream' },
})
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(true)
expect(tab.realtime.status).toBe('connecting')
})
it('state 를 받으면 live — 처음 값은 기준, 바뀌면 메일 화면이 맞출 일 · 받은편지함의 안 읽은 수', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 2 })
await settle()
expect(tab.realtime.status).toBe('live')
expect(tab.realtime.pendingSync).toBe(false)
server.last().emit('state', { state: 's2', inboxUnread: 3 })
await settle()
expect(tab.realtime.pendingSync).toBe(true)
expect(tab.realtime.inboxUnread).toBe(3)
})
it('조각으로 와도 · 주석 줄이 섞여도 읽는다', async () => {
const tab = openTab()
await settle()
server.last().send(': hello\n\nevent: sta')
server.last().send('te\r\ndata: {"state":"s1","inboxUnread":0}\r')
server.last().send('\n\r\n')
await settle()
expect(tab.realtime.status).toBe('live')
})
it('ping.upstream 이 false 면 끊김(1분 확인으로) — 연결은 그대로 두고, true 가 오면 다시 live', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().emit('ping', { upstream: false })
await settle()
expect(tab.realtime.status).toBe('down')
expect(server.last().aborted).toBe(false)
server.last().emit('ping', { upstream: true })
await settle()
expect(tab.realtime.status).toBe('live')
expect(server.connections).toHaveLength(1)
})
describe('다시 잇기', () => {
it('실패(5xx · 429 · 네트워크)는 2 · 5 · 15 · 30 · 60초 간격으로 다시 잇는다 — 자리 잡으면 처음 간격부터', async () => {
server.reply(
{ kind: 'status', status: 503 },
{ kind: 'network' },
{ kind: 'status', status: 429 },
{ kind: 'status', status: 502 },
{ kind: 'status', status: 503 },
{ kind: 'status', status: 503 },
)
const tab = openTab()
await settle()
expect(server.connections).toHaveLength(1)
expect(tab.realtime.status).toBe('down')
for (const [delay, total] of [
[2_000, 2],
[5_000, 3],
[15_000, 4],
[30_000, 5],
[60_000, 6],
[60_000, 7],
] as const) {
await tick(delay - 1)
expect(server.connections).toHaveLength(total - 1)
await tick(1)
expect(server.connections).toHaveLength(total)
}
// 일곱째는 열리고 자리 잡는다(정상 ping) — 그 뒤 끊기면 다시 2초부터
await openAndSettle()
expect(tab.realtime.status).toBe('live')
server.last().fail()
await settle()
expect(tab.realtime.status).toBe('down')
await tick(1_999)
expect(server.connections).toHaveLength(7)
await tick(1)
expect(server.connections).toHaveLength(8)
})
it('서버가 중간에 죽으면(읽기 오류) end 가 아니라 보통의 실패다 — 바로가 아니라 2초 뒤에', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().fail()
await settle()
expect(tab.realtime.status).toBe('down')
expect(server.connections).toHaveLength(1)
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('서버가 그냥 닫아도(end 없이) 보통의 실패다', async () => {
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().close()
await settle()
expect(server.connections).toHaveLength(1)
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('state 뒤 바로 닫는 서버 — 자리 잡기 전에 끊기면 간격이 2 · 5 · 15초로 늘어난다 (2초마다 되풀이하지 않는다)', async () => {
openTab()
await settle()
for (const [delay, total] of [
[2_000, 2],
[5_000, 3],
[15_000, 4],
] as const) {
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().close()
await settle()
await tick(delay - 1)
expect(server.connections).toHaveLength(total - 1)
await tick(1)
expect(server.connections).toHaveLength(total)
}
})
it('정상 ping 이 없어도 20초 이어지면 자리 잡은 것이다 — 그 뒤 끊기면 2초부터', async () => {
server.reply({ kind: 'status', status: 503 }, { kind: 'status', status: 503 })
openTab()
await settle()
await tick(2_000)
await tick(5_000)
expect(server.connections).toHaveLength(3)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
// 연결 직후의 upstream: false 알림은 자리 잡은 것으로 세지 않는다
server.last().emit('ping', { upstream: false })
await settle()
await tick(MAIL_EVENTS_STABLE_MS)
server.last().close()
await settle()
await tick(2_000)
expect(server.connections).toHaveLength(4)
// 자리 잡기 전(20초 안)에 끊기면 이어서 센다 — 다음은 5초
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
await tick(MAIL_EVENTS_STABLE_MS - 1)
server.last().close()
await settle()
await tick(4_999)
expect(server.connections).toHaveLength(4)
await tick(1)
expect(server.connections).toHaveLength(5)
})
it('text/event-stream 이 아니면(프록시가 index.html 을 준 경우 등) 실패로 본다', async () => {
server.reply({ kind: 'stream', contentType: 'text/html' })
const tab = openTab()
await settle()
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('줄 끝 없이 1MB 를 넘게 보내면 실패로 끊고 다시 잇는다', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().send(`data: ${'x'.repeat(SSE_MAX_FIELD_LENGTH)}`)
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('자리 잡은 연결이 end(서버 종료)로 끝나면 0~3초 흔들어 다시 잇는다 — 끊김으로 알리지 않는다', async () => {
const tab = openTab()
await settle()
await openAndSettle()
server.last().emit('end', { reason: 'server-shutdown' })
await settle()
expect(server.connections[0]?.aborted).toBe(true)
await tick(SHUTDOWN_SPREAD_AT_HALF_MS - 1)
expect(server.connections).toHaveLength(1)
await tick(1)
expect(server.connections).toHaveLength(2)
expect(tab.realtime.status).toBe('live')
expect(tab.statuses).not.toContain('down')
expect(refreshCount()).toBe(0)
})
it('자리 잡은 연결의 end 가 token-expiring 이면 세션을 갱신한 뒤 바로 다시 잇는다', async () => {
openTab()
await settle()
await openAndSettle()
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
})
it('열리기 전(첫 state 전)에 온 end 는 실패처럼 기다린다', async () => {
openTab()
await settle()
server.last().emit('end', { reason: 'server-shutdown' })
await settle()
expect(server.connections).toHaveLength(1)
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('state 뒤 바로 end(token-expiring)를 보내는 서버(세션 끝 무렵) — 갱신하지 않고 간격이 늘어난다', async () => {
openTab()
await settle()
for (const [delay, total] of [
[2_000, 2],
[5_000, 3],
[15_000, 4],
] as const) {
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(server.connections).toHaveLength(total - 1)
await tick(delay)
expect(server.connections).toHaveLength(total)
}
expect(refreshCount()).toBe(0)
})
it('갱신한 바로 뒤의 연결이 ping 전에 또 token-expiring 이면 다시 갱신하지 않고 기다린다', async () => {
openTab()
await settle()
await openAndSettle()
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
await tick(2_000)
expect(server.connections).toHaveLength(3)
})
it('75초 동안 아무것도 오지 않으면 끊고 다시 잇는다 — 머리글이 오지 않는 연결도 fetch 를 시작할 때부터 센다', async () => {
server.reply({ kind: 'hang' })
const tab = openTab()
await settle()
await tick(MAIL_EVENTS_SILENCE_MS - 1)
expect(server.connections[0]?.aborted).toBe(false)
await tick(1)
expect(server.connections[0]?.aborted).toBe(true)
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(server.connections).toHaveLength(2)
// 열린 뒤에도 같다 — 마지막 신호에서 75초 (그만큼 이어졌으니 자리 잡은 것 — 다음은 2초)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
await tick(MAIL_EVENTS_SILENCE_MS - 1)
expect(tab.realtime.status).toBe('live')
await tick(1)
expect(tab.realtime.status).toBe('down')
expect(server.connections[1]?.aborted).toBe(true)
await tick(2_000)
expect(server.connections).toHaveLength(3)
})
it('ping 이 오면 75초를 다시 센다', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
await tick(50_000)
server.last().emit('ping', { upstream: true })
await settle()
await tick(50_000)
expect(tab.realtime.status).toBe('live')
expect(server.connections).toHaveLength(1)
})
})
describe('401 · 세션', () => {
it('401 이면 세션을 한 번 갱신하고 바로 다시 잇는다', async () => {
server.reply({ kind: 'status', status: 401 })
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
expect(tab.realtime.status).toBe('live')
expect(tab.auth.isLoggedIn).toBe(true)
})
it('갱신도 세션 끝(401)으로 실패하면 지금의 세션 만료 흐름 — 다시 잇지 않는다', async () => {
server.reply({ kind: 'status', status: 401 })
restoreApiAdapter()
apiRequests = installMockApi(() => fail(401, 'AUTH_001'))
const tab = openTab()
await settle()
expect(tab.auth.sessionExpired).toBe(true)
expect(tab.realtime.status).toBe('off')
await tick(120_000)
expect(server.connections).toHaveLength(1)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
})
it('갱신한 바로 뒤의 다시 잇기도 401 이면 세션 만료 (axios 의 401 재시도와 같다)', async () => {
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 401 })
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
expect(tab.auth.sessionExpired).toBe(true)
})
it('갱신 뒤의 다시 잇기가 401 이 아닌 까닭(503)으로 끝나면 "갱신 직후"를 지운다 — 나중의 401 은 다시 갱신하고 만료하지 않는다', async () => {
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 503 }, { kind: 'status', status: 401 })
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
await tick(2_000)
expect(server.connections).toHaveLength(4)
expect(refreshCount()).toBe(2)
expect(tab.auth.sessionExpired).toBe(false)
expect(tab.auth.isLoggedIn).toBe(true)
})
it('갱신이 장애(503)로 실패하면 세션은 그대로 두고 기다렸다 다시 — 그 뒤의 401 은 다시 갱신해 본다', async () => {
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 401 })
restoreApiAdapter()
const refreshReplies = [fail(503, 'MAIL_001'), ok(USER)]
apiRequests = installMockApi(() => refreshReplies.shift() ?? ok(USER))
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(tab.auth.isLoggedIn).toBe(true)
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(refreshCount()).toBe(2)
expect(server.connections).toHaveLength(3)
expect(tab.auth.sessionExpired).toBe(false)
})
it('로그아웃하면 잠금을 놓고 연결을 닫는다 — 다시 로그인하면 새로 잇는다', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
tab.auth.clearAuth()
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
expect(tab.realtime.status).toBe('off')
await tick(120_000)
expect(server.connections).toHaveLength(1)
tab.auth.startSession(USER)
await settle()
expect(server.connections).toHaveLength(2)
expect(tab.realtime.status).toBe('connecting')
})
it('다른 계정으로 로그인하면 그 계정의 잠금으로 새로 잇는다', async () => {
const tab = openTab()
await settle()
tab.auth.startSession(OTHER_USER)
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(server.connections).toHaveLength(2)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
expect(locks.isHeld(mailEventsLockName(OTHER_USER.email))).toBe(true)
})
it('토큰 갱신(같은 세션)으로는 다시 잇지 않는다', async () => {
const tab = openTab()
await settle()
tab.auth.markAuthenticated({ ...USER })
await settle()
expect(server.connections).toHaveLength(1)
expect(server.connections[0]?.aborted).toBe(false)
})
})
describe('브라우저마다 연결 하나 (Web Locks · BroadcastChannel)', () => {
it('두 번째 탭은 연결하지 않고 잠금을 기다리며, 첫 탭이 나눈 소식으로 같은 상태가 된다', async () => {
const first = openTab()
await settle()
const second = openTab()
await settle()
expect(server.connections).toHaveLength(1)
expect(locks.waitingCount(mailEventsLockName(USER.email))).toBe(1)
server.last().emit('state', { state: 's1', inboxUnread: 1 })
await settle()
expect(first.realtime.status).toBe('live')
expect(second.realtime.status).toBe('live')
server.last().emit('state', { state: 's2', inboxUnread: 2 })
await settle()
expect(first.realtime.pendingSync).toBe(true)
expect(second.realtime.pendingSync).toBe(true)
expect(second.realtime.inboxUnread).toBe(2)
server.last().emit('ping', { upstream: false })
await settle()
expect(second.realtime.status).toBe('down')
})
it('연결한 탭을 닫으면 다른 탭이 잠금을 받아 이어서 연결한다', async () => {
const first = openTab()
await settle()
const second = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 1 })
await settle()
first.scope.stop()
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(server.connections).toHaveLength(2)
// 이어받은 탭은 자기 연결이 열릴 때까지 "잇는 중" (앞 탭의 소식으로 정한 상태는 버린다)
expect(second.realtime.status).toBe('connecting')
server.last().emit('state', { state: 's1', inboxUnread: 1 })
await settle()
expect(second.realtime.status).toBe('live')
})
it('연결한 탭이 stop 없이 닫혀도(탭 닫기 · 새로 고침 · 폐기 — 잠금만 넘어간다) 이어받은 탭이 끊김을 나눠 다른 탭이 1분 확인으로', async () => {
const release = holdLockElsewhere()
const closingLeader = rawTab()
const second = openTab()
const third = openTab()
await settle()
closingLeader.post({ type: 'state', state: 's1', inboxUnread: 0 })
await settle()
expect(second.realtime.status).toBe('live')
expect(third.realtime.status).toBe('live')
// 브라우저가 앞 탭을 없앴다 — 채널도 닫히고 아무것도 보내지 않는다
server.reply({ kind: 'hang' })
closingLeader.channel.close()
release()
await settle()
expect(server.connections).toHaveLength(1)
const leader = [second, third].find((tab) => tab.realtime.status === 'connecting')
const follower = [second, third].find((tab) => tab !== leader)
expect(leader).toBeDefined()
expect(follower?.realtime.status).toBe('down')
})
it('연결한 탭이 닫힐 때(pagehide) 끊김을 나눈다', async () => {
const other = rawTab()
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
other.received.length = 0
window.dispatchEvent(new Event('pagehide'))
await settle()
expect(other.received).toEqual([{ type: 'down' }])
})
it('새로 연 탭은 hello 로 지금 상태를 받는다 — 마지막 state 를 기준으로 삼는다', async () => {
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 4 })
await settle()
const second = openTab()
await settle()
expect(second.realtime.status).toBe('live')
expect(second.realtime.observeState('s1')).toBe(false)
expect(second.realtime.observeState('s2')).toBe(true)
})
it('끊긴 채 hello 에 답할 때는 기준값(baseline)과 끊김만 — 다른 탭이 live 로 깜빡이지 않는다', async () => {
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
const second = openTab()
await settle()
server.reply({ kind: 'status', status: 503 }, { kind: 'status', status: 503 })
server.last().fail()
await settle()
expect(second.realtime.status).toBe('down')
second.statuses.length = 0
const third = openTab()
await settle()
expect(second.statuses).toEqual([])
expect(third.realtime.status).toBe('down')
expect(third.statuses).not.toContain('live')
expect(third.realtime.observeState('s1')).toBe(false)
})
it('기다리는 탭은 75초 동안 소식이 없으면 끊김으로 본다 (연결한 탭이 멈췄을 때)', async () => {
const holder = rawTab()
holdLockElsewhere()
const tab = openTab()
await settle()
holder.post({ type: 'state', state: 's1', inboxUnread: 0 })
await settle()
expect(tab.realtime.status).toBe('live')
await tick(MAIL_EVENTS_SILENCE_MS)
expect(tab.realtime.status).toBe('down')
expect(server.connections).toHaveLength(0)
})
it('다른 계정의 소식은 버린다', async () => {
const holder = rawTab()
holdLockElsewhere()
const tab = openTab()
await settle()
holder.post({ type: 'state', state: 's1', inboxUnread: 0 }, OTHER_USER.email)
await settle()
expect(tab.realtime.status).toBe('connecting')
holder.post({ type: 'state', state: 's1', inboxUnread: 0 })
await settle()
expect(tab.realtime.status).toBe('live')
})
it('연결한 탭은 받은 소식을 사용자 주소와 함께 다른 탭에 나눈다 — 연결하기 전에 끊김을 한 번', async () => {
const other = rawTab()
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 2 })
server.last().emit('ping', { upstream: true })
await settle()
expect(other.received).toEqual([
{ type: 'hello' },
{ type: 'down' },
{ type: 'state', state: 's1', inboxUnread: 2 },
{ type: 'ping', upstream: true },
])
})
it('Web Locks 가 없는 브라우저는 실시간 없이 1분 확인만 한다', async () => {
uninstallFakeLocks()
const tab = openTab()
await settle()
expect(tab.realtime.status).toBe('unsupported')
expect(server.connections).toHaveLength(0)
})
})
describe('새 메일 · 사용자가 보고 있는 탭 (연결한 탭)', () => {
it('new-mail 을 onNewMail 에 넘긴다 — 사용자가 보고 있는 탭이 있는지 다른 탭에 묻는 길과 함께', async () => {
const onNewMail = vi.fn<(signal: NewMailSignal, context: NewMailContext) => void>()
openTab({ onNewMail })
await settle()
server.last().emit('new-mail', {
count: 1,
messages: [{ id: 'm1', from: { name: '박서준', email: 'seojun.park@arasystem.test' }, subject: '견적서', receivedAt: '' }],
})
await settle()
expect(onNewMail).toHaveBeenCalledTimes(1)
const [signal, context] = onNewMail.mock.calls[0] ?? []
expect(signal).toEqual({
count: 1,
messages: [{ id: 'm1', from: { name: '박서준', email: 'seojun.park@arasystem.test' }, subject: '견적서', receivedAt: '' }],
})
expect(context?.isUserViewing()).toBe(false)
})
it('보고 있는 탭 묻기 — 그 탭이 답하면 true, 답이 없으면 기다린 뒤 false', async () => {
const contexts: NewMailContext[] = []
openTab({ onNewMail: (_signal, context) => contexts.push(context) })
await settle()
server.last().emit('new-mail', { count: 1, messages: [{ id: 'm1' }] })
await settle()
const context = contexts[0]
if (!context) throw new Error('새 메일을 받지 못했다')
// 답이 없다 — 기다린 뒤 false
const silent = context.askOtherTabsViewing()
let answered: boolean | null = null
void silent.then((value) => {
answered = value
})
await tick(MAIL_EVENTS_VISIBILITY_WAIT_MS - 1)
expect(answered).toBeNull()
await tick(1)
expect(answered).toBe(false)
// 보고 있는 탭이 답한다
const viewingTab = rawTab()
viewingTab.channel.addEventListener('message', (event) => {
const message = unpackRealtimeMessage((event as MessageEvent<unknown>).data, USER.email)
if (message?.type === 'visibility-query') viewingTab.post({ type: 'visibility-reply', id: message.id })
})
const asked = context.askOtherTabsViewing()
await settle()
await expect(asked).resolves.toBe(true)
})
it('기다리는 탭은 사용자가 보고 있을 때만 묻는 말에 답한다', async () => {
let viewing = false
const holder = rawTab()
holdLockElsewhere()
openTab({ isUserViewing: () => viewing })
await settle()
holder.post({ type: 'visibility-query', id: 'q1' })
await settle()
expect(holder.received.filter((message) => message.type === 'visibility-reply')).toEqual([])
viewing = true
holder.post({ type: 'visibility-query', id: 'q2' })
await settle()
expect(holder.received.filter((message) => message.type === 'visibility-reply')).toEqual([
{ type: 'visibility-reply', id: 'q2' },
])
})
it('"보고 있음"은 보이고 초점이 있을 때 — 두 번째 모니터에 띄워 두고 다른 창에서 일하면 보고 있지 않다', () => {
let visibility: DocumentVisibilityState = 'visible'
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
const hasFocus = vi.spyOn(document, 'hasFocus')
try {
hasFocus.mockReturnValue(true)
expect(isUserViewingPage()).toBe(true)
hasFocus.mockReturnValue(false)
expect(isUserViewingPage()).toBe(false)
visibility = 'hidden'
hasFocus.mockReturnValue(true)
expect(isUserViewingPage()).toBe(false)
} finally {
Reflect.deleteProperty(document, 'visibilityState')
}
})
it('사용자가 연결한 탭으로 돌아오면(초점) onUserReturn — 띄운 바탕화면 알림을 닫는다', async () => {
const onUserReturn = vi.fn()
let viewing = false
openTab({ onUserReturn, isUserViewing: () => viewing })
await settle()
window.dispatchEvent(new Event('focus'))
expect(onUserReturn).not.toHaveBeenCalled()
viewing = true
window.dispatchEvent(new Event('focus'))
expect(onUserReturn).toHaveBeenCalledTimes(1)
})
it('다른 탭으로 돌아오면 그 탭이 attended 를 보내고 연결한 탭이 onUserReturn', async () => {
const onUserReturn = vi.fn()
const other = rawTab()
openTab({ onUserReturn })
await settle()
other.post({ type: 'attended' })
await settle()
expect(onUserReturn).toHaveBeenCalledTimes(1)
})
it('기다리는 탭으로 돌아오면 연결한 탭에 attended 를 보낸다', async () => {
const holder = rawTab()
holdLockElsewhere()
openTab({ isUserViewing: () => true })
await settle()
document.dispatchEvent(new Event('visibilitychange'))
await settle()
expect(holder.received.filter((message) => message.type === 'attended')).toHaveLength(1)
})
})
})
+545
View File
@@ -0,0 +1,545 @@
import { onScopeDispose, watch } from 'vue'
import { buildApiUrl, isSessionEnd, refreshSession } from './useApi'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import {
MAIL_EVENTS_CHANNEL_NAME,
MAIL_EVENTS_CONTENT_TYPE,
MAIL_EVENTS_END_TOKEN_EXPIRING,
MAIL_EVENTS_PATH,
MAIL_EVENTS_SHUTDOWN_SPREAD_MS,
MAIL_EVENTS_SILENCE_MS,
MAIL_EVENTS_STABLE_MS,
MAIL_EVENTS_VISIBILITY_WAIT_MS,
} from '@/shared/constants/mail-notify'
import { supportsMailRealtime } from '@/shared/utils/browser-support'
import { parseMailEvent, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
import {
mailEventsLockName,
packRealtimeMessage,
reconnectDelayMs,
unpackRealtimeMessage,
type RealtimeMessage,
} from '@/shared/utils/mail-realtime'
import { createSseParser } from '@/shared/utils/sse-parser'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-realtime.store'
// 실시간 새 메일 — 계약 2절 "실시간 알림 (GET /api/mail-events)" 의 클라이언트. App.vue 가 한 번 부르고, 로그인한 동안 앱 전체에서 돈다
// (설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게 — 메일 화면이 할 일은 useMailStatePolling 이 이 소식을 받아서 한다).
//
// 브라우저마다 연결 하나 — 탭마다 열면 HTTP/1.1 의 주소당 연결 6개를 금방 다 써서 다른 요청이 멈춘다.
// - 잠금(navigator.locks — 이름에 사용자 주소)을 받은 탭 하나만 연결한다. 그 탭을 닫으면 잠금이 풀려 다른 탭이 이어받는다
// - 연결한 탭은 받은 소식(state · ping · 끊김)을 BroadcastChannel 로 다른 탭에 나눈다. 메시지에 사용자 주소를 넣고 다른 계정의 것은 버린다
// - 모든 탭(연결한 탭 포함)이 같은 규칙을 탄다: 75초 안에 ping · state 가 오고 upstream 이 true 면 live(1분 확인을 멈춘다), 아니면 down.
// state 는 마지막으로 본 값과 비교해 바뀌었으면 메일 화면이 맞출 일로 남긴다 (stores/mail-realtime.store.ts)
// - 잠금을 받은 탭은 연결하기 전에 down 을 한 번 나눈다 — 앞 탭이 stop 없이 닫혔어도(탭 닫기 · 새로 고침 · 폐기) 다른 탭이 1분 확인으로
// 돌아가게. 연결한 탭이 닫힐 때(pagehide)도 나눈다
// - 새로 연 탭은 hello 를 보내고, 연결한 탭은 지금 상태를 다시 보낸다 — 끊긴 채면 살아 있다는 신호로 세지 않는 기준값(baseline)과 끊김만
// - Web Locks · BroadcastChannel · fetch 스트림 가운데 하나라도 없으면 실시간 없이 지금의 1분 확인만 한다 (status: unsupported)
//
// 연결 (연결한 탭)
// - fetch 로 읽는다 — 브라우저의 EventSource 는 401 을 알 수 없어 세션을 갱신할 수 없다. 같은 주소라 쿠키가 함께 간다
// - 401 → 세션을 한 번 갱신하고(refreshSession) 바로 다시 잇는다. 그 다시 잇기(바로 다음 시도)도 401 이면 세션 만료 — axios 의 401 재시도와 같다.
// 다음 시도가 401 이 아닌 까닭으로 끝나면 "갱신 직후"는 지운다(나중의 401 은 다시 갱신해 본다).
// 갱신이 세션 끝(401)으로 실패해도 세션 만료 흐름(대화상자). 갱신이 장애(5xx · 네트워크)로 실패하면 아래처럼 기다렸다 다시
// - 그 밖의 실패(연결 안 됨 · 5xx · 429 · 스트림이 끊김 · 읽기 오류 · text/event-stream 이 아님 · 너무 긴 줄) → 2 · 5 · 15 · 30 · 60초(±20%) 뒤 다시.
// 연결이 자리 잡으면(첫 state 뒤 정상 ping · 20초) 처음 간격부터 다시 센다 — 열리자마자 끊기는 연결은 간격이 계속 늘어난다
// - end — 자리 잡은 연결이면 다시 잇는다: token-expiring 은 세션을 갱신한 뒤 바로, 그 밖(서버 종료 · 재시작)은 0~3초 흔들어서.
// 자리 잡기 전에 온 end 는 까닭과 상관없이 실패처럼 기다린다 — 세션 끝 무렵 서버가 state 뒤 바로 end(token-expiring) 해도
// 갱신 · 연결을 왕복 시간 속도로 되풀이해 갱신 한도를 다 쓰지 않게 (2026-10-28 리뷰)
// - 75초 동안 아무것도 오지 않으면(머리글을 기다리는 동안 포함) 끊긴 것으로 보고 연결을 끊은 뒤 위처럼 다시 잇는다
// - new-mail 은 연결한 탭만 받는다 — onNewMail 이 바탕화면 알림을 정한다(사용자가 보고 있는 탭이 있는지 다른 탭에 묻는다)
// - 사용자가 어느 웹메일 탭이든 보게 되면(보이고 초점이 있음) 연결한 탭에서 onUserReturn — 띄운 바탕화면 알림을 닫는다
//
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
export interface NewMailContext {
/** 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus) */
isUserViewing: () => boolean
/** 다른 탭 가운데 사용자가 보고 있는 탭이 있는지 — 그 탭의 답을 잠깐(MAIL_EVENTS_VISIBILITY_WAIT_MS) 기다린다 */
askOtherTabsViewing: () => Promise<boolean>
}
export type EventStreamFetch = (input: string, init: RequestInit) => Promise<Response>
export interface MailRealtimeOptions {
/** 연결한 탭이 새 메일을 받았을 때 — 바탕화면 알림을 정한다 (useNewMailNotifier). 결과(Promise 포함)는 기다리지 않는다 */
onNewMail?: (signal: NewMailSignal, context: NewMailContext) => unknown
/** 사용자가 웹메일 탭(어느 탭이든)을 보게 됐다 — 연결한 탭에서 부른다 (띄운 바탕화면 알림을 닫는다) */
onUserReturn?: () => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
/** 다시 잇는 간격의 흔들림 (0~1) — 테스트는 고정값을 넘긴다 */
random?: () => number
/** 테스트는 가짜 서버를 넘긴다 (test-utils/event-stream.ts) */
fetch?: EventStreamFetch
/** 사용자가 이 탭을 보고 있는지 — 기본은 isUserViewingPage */
isUserViewing?: () => boolean
}
/**
* 한 연결의 끝 — 401 · 실패 · 서버가 알린 끝(end).
* established: 자리 잡았다(첫 state 뒤 정상 ping 을 받았거나 MAIL_EVENTS_STABLE_MS 동안 이어졌다) — 다시 잇는 간격을 처음부터 센다
*/
type StreamOutcome =
| { kind: 'unauthorized' }
| { kind: 'failed'; established: boolean }
| { kind: 'ended'; established: boolean; reason: string }
/** 세션 갱신의 결과 — 이어서 잇는다 · 세션이 끝났다 · 장애(기다렸다 다시) */
type RenewOutcome = 'renewed' | 'ended' | 'failed'
interface RealtimeSession {
stop: () => void
}
type SignalMessage = Extract<RealtimeMessage, { type: 'ping' | 'state' | 'down' }>
const defaultFetch: EventStreamFetch = (input, init) => fetch(input, init)
/**
* 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus).
* 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 "보고 있지 않음"이다 — 그때도 바탕화면 알림이 온다
*/
export function isUserViewingPage(): boolean {
if (typeof document === 'undefined') return false
return document.visibilityState === 'visible' && document.hasFocus()
}
/** 서버가 돌려준 응답이 SSE 인지 — 프록시가 index.html(200)을 돌려준 경우 등을 거른다 */
function isEventStream(response: Response): boolean {
return (response.headers.get('Content-Type') ?? '').toLowerCase().startsWith(MAIL_EVENTS_CONTENT_TYPE)
}
/** 읽지 않을 응답 본문을 닫는다 — 연결을 붙잡지 않게 */
function discardBody(response: Response): void {
void response.body?.cancel().catch(() => undefined)
}
export function useMailRealtime(options: MailRealtimeOptions = {}): void {
const authStore = useAuthStore()
const realtimeStore = useMailRealtimeStore()
const clock = options.clock ?? systemClock
const random = options.random ?? Math.random
const fetchStream = options.fetch ?? defaultFetch
const isUserViewing = options.isUserViewing ?? isUserViewingPage
let session: RealtimeSession | null = null
function startSession(email: string, generation: number): RealtimeSession {
const channel = new BroadcastChannel(MAIL_EVENTS_CHANNEL_NAME)
const lockRequest = new AbortController()
/** 탭마다 다른 앞머리 — 보고 있는 탭 묻기의 답을 가려낸다 */
const queryPrefix = random().toString(36).slice(2)
const pendingQueries = new Map<string, (viewing: boolean) => void>()
let querySeq = 0
let stopped = false
let releaseLock: (() => void) | null = null
let leader: { stop: () => void; replyHello: () => void } | null = null
// ── 연결 상태 (모든 탭 같은 규칙) ─────────────────────
let lastSignalAt: number | null = null
let upstreamOk = true
let everDown = false
let silenceTimer: ClockTimer | null = null
function publishStatus() {
let next: MailRealtimeStatus
if (lastSignalAt === null) next = everDown ? 'down' : 'connecting'
else next = upstreamOk ? 'live' : 'down'
realtimeStore.setStatus(next)
}
/** 75초 안에 다음 신호가 없으면 끊긴 것으로 본다 (처음 잇는 중이어도 같다) */
function armSilence() {
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
silenceTimer = clock.setTimeout(() => {
silenceTimer = null
lastSignalAt = null
everDown = true
publishStatus()
}, MAIL_EVENTS_SILENCE_MS)
}
/** ping · state · 끊김을 이 탭에 적는다 — 연결한 탭은 자기 소식, 다른 탭은 채널로 받은 소식 */
function applySignal(message: SignalMessage) {
if (message.type === 'down') {
lastSignalAt = null
everDown = true
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
silenceTimer = null
publishStatus()
return
}
lastSignalAt = clock.now()
// state 는 서버가 엔진의 바뀜을 받았다는 뜻이다 — 서버 · 엔진 사이가 이어져 있다
upstreamOk = message.type === 'state' ? true : message.upstream
armSilence()
publishStatus()
if (message.type === 'state') realtimeStore.observeState(message.state, message.inboxUnread)
}
function post(message: RealtimeMessage) {
if (stopped) return
try {
channel.postMessage(packRealtimeMessage(email, message))
} catch {
// 닫힌 채널 — 나누지 못해도 이 탭은 그대로 간다
}
}
/** 연결한 탭의 소식 — 이 탭에 적고 다른 탭에 나눈다 */
function share(message: SignalMessage) {
applySignal(message)
post(message)
}
// ── 사용자가 보고 있는 탭 묻기 (바탕화면 알림 전에) ─────────────
function askOtherTabsViewing(): Promise<boolean> {
if (stopped) return Promise.resolve(false)
querySeq += 1
const id = `${queryPrefix}-${querySeq}`
return new Promise<boolean>((resolve) => {
let timer: ClockTimer | null = null
const settle = (viewing: boolean) => {
if (!pendingQueries.delete(id)) return
if (timer !== null) clock.clearTimeout(timer)
resolve(viewing)
}
pendingQueries.set(id, settle)
timer = clock.setTimeout(() => settle(false), MAIL_EVENTS_VISIBILITY_WAIT_MS)
post({ type: 'visibility-query', id })
})
}
const newMailContext: NewMailContext = { isUserViewing, askOtherTabsViewing }
/** 사용자가 이 탭을 보게 됐다 — 연결한 탭이면 바로, 아니면 연결한 탭에 알린다 (띄운 바탕화면 알림을 닫는다) */
function onUserMaybeReturned() {
if (stopped || !isUserViewing()) return
if (leader) options.onUserReturn?.()
else post({ type: 'attended' })
}
/** 연결한 탭이 닫힌다 — 다른 탭이 1분 확인으로 돌아가게 (잠금은 브라우저가 풀고, 다른 탭이 이어받는다) */
function onPageHide() {
if (leader) post({ type: 'down' })
}
function onChannelMessage(event: MessageEvent<unknown>) {
if (stopped) return
const message = unpackRealtimeMessage(event.data, email)
if (!message) return
switch (message.type) {
case 'ping':
case 'state':
case 'down':
// 연결한 탭은 자기 연결의 소식만 믿는다 (잠금을 막 넘긴 탭의 늦은 끊김 등은 버린다)
if (!leader) applySignal(message)
break
case 'baseline':
// 살아 있다는 신호가 아니다 — 기준값으로만 쓴다
if (!leader) realtimeStore.observeState(message.state, message.inboxUnread)
break
case 'hello':
leader?.replyHello()
break
case 'visibility-query':
if (isUserViewing()) post({ type: 'visibility-reply', id: message.id })
break
case 'visibility-reply':
pendingQueries.get(message.id)?.(true)
break
case 'attended':
if (leader) options.onUserReturn?.()
break
}
}
// ── 연결한 탭 ────────────────────────────────────
function startLeader(): { stop: () => void; replyHello: () => void } {
let leaderStopped = false
let abortStream: (() => void) | null = null
let wake: (() => void) | null = null
let lastState: { state: string; inboxUnread: number | null } | null = null
/** 자리 잡은 연결 뒤로 이어서 실패한 횟수 — 다시 잇는 간격을 정한다 */
let attempt = 0
const active = () => !leaderStopped && !stopped
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = clock.setTimeout(() => {
wake = null
resolve()
}, ms)
wake = () => {
clock.clearTimeout(timer)
wake = null
resolve()
}
})
}
/** 세션 만료 흐름 — 갱신 요청의 401 은 인터셉터가 이미 만료로 다뤘다. 여기서는 보장만 한다 (confirmSession 과 같다) */
function expire() {
if (authStore.generation === generation) authStore.expireSession()
}
async function renew(): Promise<RenewOutcome> {
const outcome = await refreshSession()
if (outcome.ok) return 'renewed'
if (isSessionEnd(outcome.error.code)) {
expire()
return 'ended'
}
return 'failed'
}
function handleServerEvent(event: Exclude<MailServerEvent, { type: 'end' }>) {
switch (event.type) {
case 'state':
lastState = { state: event.state, inboxUnread: event.inboxUnread }
share({ type: 'state', state: event.state, inboxUnread: event.inboxUnread })
break
case 'ping':
share({ type: 'ping', upstream: event.upstream })
break
case 'new-mail':
// 알림을 정하는 사이에도 계속 읽는다 (다른 탭의 답을 잠깐 기다린다) — 실패해도 연결과는 상관없다
void Promise.resolve()
.then(() => options.onNewMail?.({ count: event.count, messages: event.messages }, newMailContext))
.catch(() => undefined)
break
}
}
async function readStream(): Promise<StreamOutcome> {
const request = new AbortController()
abortStream = () => request.abort()
let watchdog: ClockTimer | null = null
/** 첫 state 를 받은 때 */
let openedAt: number | null = null
/** 정상 ping(upstream: true)을 받았다 — 연결 직후의 upstream: false 알림은 세지 않는다 */
let sawHealthyPing = false
const established = () =>
sawHealthyPing || (openedAt !== null && clock.now() - openedAt >= MAIL_EVENTS_STABLE_MS)
// 머리글을 기다리는 동안 · 읽는 동안 모두 75초 안에 무엇이든 와야 한다 — 아니면 끊고 다시 잇는다
const armWatchdog = () => {
if (watchdog !== null) clock.clearTimeout(watchdog)
watchdog = clock.setTimeout(() => request.abort(), MAIL_EVENTS_SILENCE_MS)
}
try {
armWatchdog()
const response = await fetchStream(buildApiUrl(MAIL_EVENTS_PATH), {
method: 'GET',
headers: { Accept: MAIL_EVENTS_CONTENT_TYPE },
cache: 'no-store',
credentials: 'same-origin',
signal: request.signal,
})
if (response.status === 401) {
discardBody(response)
return { kind: 'unauthorized' }
}
if (!response.ok || !response.body || !isEventStream(response)) {
discardBody(response)
return { kind: 'failed', established: false }
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
const parser = createSseParser()
for (;;) {
const chunk = await reader.read()
if (chunk.done || !active()) break
for (const raw of parser.push(decoder.decode(chunk.value, { stream: true }))) {
armWatchdog()
const event = parseMailEvent(raw)
if (!event) continue
if (event.type === 'end') return { kind: 'ended', established: established(), reason: event.reason }
if (event.type === 'state' && openedAt === null) openedAt = clock.now()
if (event.type === 'ping' && event.upstream) sawHealthyPing = true
handleServerEvent(event)
if (!active()) break
}
}
return { kind: 'failed', established: established() }
} catch {
// 네트워크 · 읽기 오류(서버가 중간에 죽음) · 너무 긴 줄 · 그만둠(75초 · 로그아웃) — 아래 run 이 가른다
return { kind: 'failed', established: established() }
} finally {
if (watchdog !== null) clock.clearTimeout(watchdog)
request.abort()
abortStream = null
}
}
/** 401 · token-expiring 뒤 — 세션을 갱신한다. 이어서 잇을지(now) · 그만둘지(stop) · 기다렸다 잇을지(backoff) */
async function renewThen(): Promise<'now' | 'stop' | 'backoff'> {
const renewed = await renew()
if (!active() || renewed === 'ended') return 'stop'
return renewed === 'renewed' ? 'now' : 'backoff'
}
async function run(): Promise<void> {
/** 이번 시도가 세션을 갱신한 바로 뒤의 다시 잇기다 — 이번에도 401 이면 세션 만료. 다른 까닭으로 끝나면 지운다 */
let retryingAfterRenew = false
while (active()) {
const outcome = await readStream()
if (!active()) return
const afterRenew = retryingAfterRenew
retryingAfterRenew = false
if (outcome.kind !== 'unauthorized' && outcome.established) attempt = 0
let next: 'now' | 'spread' | 'backoff' | 'stop' = 'backoff'
if (outcome.kind === 'unauthorized') {
if (afterRenew) {
share({ type: 'down' })
expire()
return
}
next = await renewThen()
} else if (outcome.kind === 'ended' && outcome.established) {
next = outcome.reason === MAIL_EVENTS_END_TOKEN_EXPIRING ? await renewThen() : 'spread'
}
if (next === 'stop') return
if (next === 'now') {
retryingAfterRenew = true
continue
}
if (next === 'spread') {
// 서버가 곧 닫는다고 알렸다(종료 · 재시작) — 끊김으로 알리지 않고, 모든 브라우저가 같은 순간에 붙지 않게 조금 흔들어 다시 잇는다
await sleep(Math.round(Math.min(1, Math.max(0, random())) * MAIL_EVENTS_SHUTDOWN_SPREAD_MS))
continue
}
share({ type: 'down' })
await sleep(reconnectDelayMs(attempt, random()))
attempt += 1
}
}
void run()
return {
stop: () => {
leaderStopped = true
abortStream?.()
wake?.()
},
/**
* 새로 연 탭에게 지금 상태를 다시 보낸다 — 살아 있으면 마지막 state(신호), 끊긴 채면 기준값(baseline)과 끊김만
* (끊긴 채 state 를 보내면 다른 탭이 잠깐 live 가 되었다가 down 으로 돌아가며 1분 확인을 한 번 더 한다)
*/
replyHello: () => {
const status = realtimeStore.status
if (status === 'live') {
post(lastState ? { type: 'state', ...lastState } : { type: 'ping', upstream: true })
return
}
if (lastState) post({ type: 'baseline', ...lastState })
if (status === 'down') post({ type: 'down' })
},
}
}
function stop() {
if (stopped) return
// 연결을 맡던 탭이면 다른 탭이 1분 확인으로 돌아가게 알리고 놓는다 (다른 탭이 잠금을 받아 이어서 연결한다)
if (leader) post({ type: 'down' })
stopped = true
lockRequest.abort()
leader?.stop()
leader = null
releaseLock?.()
releaseLock = null
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
silenceTimer = null
pendingQueries.forEach((settle) => settle(false))
channel.removeEventListener('message', onChannelMessage)
channel.close()
if (typeof window !== 'undefined') {
window.removeEventListener('pagehide', onPageHide)
window.removeEventListener('focus', onUserMaybeReturned)
}
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onUserMaybeReturned)
}
/** 잠금을 쓸 수 없는 환경(보안 제한 등) — 실시간 없이 1분 확인만 한다 */
function giveUp() {
stop()
realtimeStore.setStatus('unsupported')
}
channel.addEventListener('message', onChannelMessage)
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', onPageHide)
window.addEventListener('focus', onUserMaybeReturned)
}
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onUserMaybeReturned)
publishStatus()
armSilence()
post({ type: 'hello' })
try {
navigator.locks
.request(mailEventsLockName(email), { signal: lockRequest.signal }, () =>
new Promise<void>((resolve) => {
// 잠금을 받은 동안 이 탭이 연결한다 — 돌려준 Promise 가 끝나면(stop) 잠금이 풀린다
if (stopped) {
resolve()
return
}
releaseLock = resolve
// 이제 이 탭의 연결이 기준이다 — 앞 탭의 소식으로 정한 상태는 버리고, 이 연결이 열릴 때까지 "잇는 중".
// 다른 탭에는 끊김을 알린다 — 앞 탭이 stop 없이 닫혔으면(탭 닫기 · 새로 고침 · 폐기) 다른 탭은 아직 live 로 알고 있다
lastSignalAt = null
everDown = false
armSilence()
publishStatus()
post({ type: 'down' })
leader = startLeader()
}),
)
.catch(() => {
// 기다리다 그만둔 요청(AbortError)이면 할 일이 없다. 그 밖의 거절은 잠금을 쓸 수 없는 것이다
if (!stopped) giveUp()
})
} catch {
giveUp()
}
return { stop }
}
function restart() {
session?.stop()
session = null
const email = authStore.user?.email
if (!email) {
realtimeStore.setStatus('off')
return
}
if (!supportsMailRealtime()) {
realtimeStore.setStatus('unsupported')
return
}
try {
session = startSession(email, authStore.generation)
} catch {
// 채널을 만들지 못했다 — 실시간 없이 1분 확인만 한다
realtimeStore.setStatus('unsupported')
}
}
// 로그인 · 세션 복원으로 시작하고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 그만둔다(새 세션이면 다시 시작).
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다)
watch([() => authStore.generation, () => authStore.user?.email ?? null], restart, { immediate: true })
onScopeDispose(() => {
session?.stop()
session = null
realtimeStore.setStatus('off')
})
}
@@ -4,7 +4,9 @@ import { effectScope, type EffectScope } from 'vue'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
import { MAIL_REALTIME_SYNC_DELAY_MS } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
@@ -17,7 +19,7 @@ import {
type MockResult,
} from '@/test-utils/mock-api'
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
// 새 메일 확인 (계약 2절 "새 메일 확인" · "실시간 알림") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
describe('useMailStatePolling (새 메일 확인)', () => {
let scope: EffectScope
@@ -26,6 +28,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
let states: MockResult[]
let requests: MockRequest[]
const onChange = vi.fn()
const onHiddenChange = vi.fn()
beforeEach(() => {
setActivePinia(createPinia())
@@ -35,6 +38,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
visibility = 'visible'
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
onChange.mockReset()
onHiddenChange.mockReset()
states = []
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
})
@@ -47,7 +51,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
})
function start() {
scope.run(() => useMailStatePolling({ onChange, clock }))
scope.run(() => useMailStatePolling({ onChange, onHiddenChange, clock }))
}
function setVisibility(next: DocumentVisibilityState) {
@@ -169,4 +173,151 @@ describe('useMailStatePolling (새 메일 확인)', () => {
expect(asked()).toBe(1)
expect(clock.pendingCount()).toBe(0)
})
describe('실시간 연결과 함께 (계약 2절 "실시간 알림")', () => {
it('실시간 연결이 살아 있는 동안은 묻지 않는다 — 끊기면 바로 한 번 묻고 다시 1분마다, 살아나면 멈춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
expect(asked()).toBe(0)
realtime.setStatus('down')
await flushPromises()
expect(asked()).toBe(1)
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(asked()).toBe(2)
realtime.setStatus('live')
await flushPromises()
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
expect(asked()).toBe(2)
expect(clock.pendingCount()).toBe(0)
})
it('살아 있는 동안 탭으로 돌아와도 묻지 않는다 (10초 규칙은 1분 확인을 할 때만)', async () => {
useMailRealtimeStore().setStatus('live')
start()
setVisibility('hidden')
await tick(MAIL_STATE_RECHECK_GAP_MS * 6)
setVisibility('visible')
await flushPromises()
expect(asked()).toBe(0)
})
it('실시간 소식으로 바뀌면(보이는 탭) 1초 모았다가 맞춘다 — 1분 확인과 같은 기준이라 같은 값은 다시 맞추지 않는다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 0)
await flushPromises()
expect(onChange).not.toHaveBeenCalled()
realtime.observeState('s2', 1)
await flushPromises()
await tick(MAIL_REALTIME_SYNC_DELAY_MS - 1)
expect(onChange).not.toHaveBeenCalled()
await tick(1)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onHiddenChange).not.toHaveBeenCalled()
// 연결이 끊긴 뒤의 1분 확인이 같은 값을 받으면 바뀐 것이 아니다
states = [ok({ state: 's2' })]
realtime.setStatus('down')
await flushPromises()
expect(asked()).toBe(1)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('모으는 사이 온 바뀜은 함께 한 번에 맞춘다 (내가 여러 통을 읽어 state 가 이어서 바뀌어도)', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 0)
// 첫 바뀜부터 1초 — 그사이 바뀜은 함께 (다시 세지 않는다)
realtime.observeState('s2', 1)
await flushPromises()
await tick(300)
realtime.observeState('s3', 0)
await flushPromises()
await tick(300)
realtime.observeState('s4', 1)
await flushPromises()
await tick(MAIL_REALTIME_SYNC_DELAY_MS - 600 - 1)
expect(onChange).not.toHaveBeenCalled()
await tick(1)
expect(onChange).toHaveBeenCalledTimes(1)
await tick(MAIL_REALTIME_SYNC_DELAY_MS * 3)
expect(onChange).toHaveBeenCalledTimes(1)
expect(clock.pendingCount()).toBe(0)
})
it('모으는 동안 숨으면 맞추지 않고 다시 보일 때 바로 맞춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 0)
realtime.observeState('s2', 1)
await flushPromises()
setVisibility('hidden')
await tick(MAIL_REALTIME_SYNC_DELAY_MS)
expect(onChange).not.toHaveBeenCalled()
setVisibility('visible')
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
})
it('숨은 탭 — 받은편지함의 안 읽은 수만 넘기고(탭 제목), 다시 보일 때 한 번 맞춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 2)
setVisibility('hidden')
realtime.observeState('s2', 3)
await flushPromises()
realtime.observeState('s3', 4)
await flushPromises()
expect(onHiddenChange.mock.calls).toEqual([[3], [4]])
expect(onChange).not.toHaveBeenCalled()
setVisibility('visible')
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
setVisibility('hidden')
setVisibility('visible')
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
})
it('메일 화면 밖(설정 등)에 있는 동안 생긴 바뀜은 메일 화면이 생길 때 맞춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
realtime.observeState('s1', 0)
realtime.observeState('s2', 1)
start()
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
expect(asked()).toBe(0)
})
it('1분 확인이 본 바뀜도 같은 길로 맞춘다 · 세션이 바뀌면 지난 기준을 버린다', async () => {
states = [ok({ state: 's1' }), ok({ state: 's2' }), ok({ state: 'other-user' })]
start()
await flushPromises()
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(onChange).toHaveBeenCalledTimes(1)
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
await flushPromises()
expect(asked()).toBe(3)
expect(onChange).toHaveBeenCalledTimes(1)
})
})
})
+83 -19
View File
@@ -3,19 +3,32 @@ import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail } from './useMail'
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
import { MAIL_REALTIME_SYNC_DELAY_MS } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
// 메일 화면이 새 메일 · 다른 창의 변화를 알아채고 맞춘다
// (계약 2절 "새 메일 확인" · "실시간 알림" · 디자인 README "3단계 › 새 메일" · "5단계 › 실시간 반영")
// 메일 화면(MailLayout)이 있는 동안만 돈다. 바뀜은 두 곳에서 오고, 둘 다 같은 기준(마지막으로 본 state — mail-realtime.store)으로 비교한다:
// 1) 실시간 소식 — 로그인한 동안 앱 전체에서 도는 연결(useMailRealtime)의 state. 이 탭이 연결했든 다른 탭이 연결했든 같다
// 2) 1분 확인(GET /mail-state) — 실시간 연결이 살아 있지 않을 때만
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
// - 실시간 연결이 끊기면 바로 한 번 묻고 다시 60초마다 — 살아나면 멈춘다
// - 한 번에 하나씩. 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)
// 처음 본 값은 기준으로만 쓴다.
// 바뀌었으면:
// - 화면이 보이면 onChange (메일함 개수 · 보이는 목록 맞추기 — 부르는 쪽이). 실시간 소식이 알린 바뀜은 첫 바뀜부터 1초 모았다가
// 한 번에 (MAIL_REALTIME_SYNC_DELAY_MS — 내가 한 통만 읽어도 state 가 바뀌어, 바뀔 때마다 탭마다 다시 받으면 요청이 많다).
// 1분 확인이 본 바뀜은 바로
// - 숨었으면 onHiddenChange(받은편지함의 안 읽은 수 — 탭 제목 "(N) " 만 맞춘다) 하고, 다시 보일 때 onChange (바로)
// - 메일 화면 밖(설정 등)에 있는 동안 생긴 바뀜은 메일 화면으로 돌아오면 onChange (바로)
export interface MailStatePollingOptions {
/** 지난 값과 달라졌을 때 */
/** 바뀌었고 화면이 보일 때 */
onChange: () => void
/** 숨은 탭에서 실시간 소식으로 바뀌었을 때 — 받은편지함의 안 읽은 수 */
onHiddenChange?: (inboxUnread: number) => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
}
@@ -32,15 +45,21 @@ function isPageVisible(): boolean {
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
const { getMailState } = useMail()
const authStore = useAuthStore()
const realtimeStore = useMailRealtimeStore()
const clock = options.clock ?? systemClock
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
let baseline: string | null = null
let lastAskedAt: number | null = null
let timer: ClockTimer | null = null
/** 실시간 소식이 알린 바뀜을 모으는 중 — 끝나면 한 번에 맞춘다 */
let syncTimer: ClockTimer | null = null
let inFlight = false
let stopped = false
/** 1분 확인을 할 때인지 — 실시간 연결이 살아 있으면 묻지 않는다 */
function shouldPoll(): boolean {
return !stopped && isPageVisible() && authStore.isLoggedIn && !realtimeStore.isLive
}
function cancelTimer() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
@@ -48,7 +67,7 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
function schedule(delay: number) {
cancelTimer()
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
if (!shouldPoll()) return
timer = clock.setTimeout(
() => {
timer = null
@@ -59,7 +78,7 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
}
async function ask(): Promise<void> {
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
if (inFlight || !shouldPoll()) return
inFlight = true
lastAskedAt = clock.now()
const session = authStore.generation
@@ -67,9 +86,8 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
const { state } = await getMailState({ silent: true })
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
if (stopped || authStore.generation !== session) return
const changed = baseline !== null && state !== baseline
baseline = state
if (changed) options.onChange()
// 1분 확인이 본 바뀜은 기다리지 않고 맞춘다 (이미 최대 1분 늦었다)
if (realtimeStore.observeState(state)) syncPending()
} catch {
// 알리지 않는다 — 다음 차례에 다시 묻는다
} finally {
@@ -78,13 +96,35 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
}
}
function cancelSyncTimer() {
if (syncTimer !== null) clock.clearTimeout(syncTimer)
syncTimer = null
}
/** 남은 바뀜을 맞춘다 — 화면이 보일 때만 (숨었으면 다시 보일 때) */
function syncPending() {
if (stopped || !isPageVisible()) return
cancelSyncTimer()
if (realtimeStore.takePendingSync()) options.onChange()
}
/** 실시간 소식이 알린 바뀜 — 첫 바뀜부터 잠깐 모았다가 한 번에 맞춘다 (모으는 사이 온 바뀜은 함께) */
function scheduleSync() {
if (syncTimer !== null) return
syncTimer = clock.setTimeout(() => {
syncTimer = null
syncPending()
}, MAIL_REALTIME_SYNC_DELAY_MS)
}
function onVisibilityChange() {
if (stopped) return
if (!isPageVisible()) {
cancelTimer()
return
}
if (inFlight) return
syncPending()
if (inFlight || realtimeStore.isLive) return
const elapsed = lastAskedAt === null ? Number.POSITIVE_INFINITY : clock.now() - lastAskedAt
if (elapsed >= MAIL_STATE_RECHECK_GAP_MS) void ask()
else schedule(MAIL_STATE_POLL_INTERVAL_MS - elapsed)
@@ -93,21 +133,45 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
function stop() {
stopped = true
cancelTimer()
cancelSyncTimer()
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
}
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
// 바뀜이 생기면 — 보이면 잠깐 모았다가 맞추고(1분 확인이 본 것은 이미 맞췄다), 숨었으면 탭 제목의 안 읽은 수만 바로
watch(
() => realtimeStore.changeSeq,
() => {
if (stopped) return
if (!isPageVisible()) {
if (realtimeStore.inboxUnread !== null) options.onHiddenChange?.(realtimeStore.inboxUnread)
return
}
if (realtimeStore.pendingSync) scheduleSync()
},
)
// 실시간 연결이 살아나면 1분 확인을 멈추고, 끊기면 바로 한 번 묻고 다시 1분마다
watch(
() => realtimeStore.isLive,
(live) => {
if (live) cancelTimer()
else void ask()
},
)
// 세션이 바뀌면(다시 로그인 등) 새로 기준을 잡는다 — 지난 기준은 스토어가 버렸다
watch(
() => authStore.generation,
() => {
baseline = null
if (authStore.isLoggedIn) void ask()
else cancelTimer()
},
)
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
// 메일 화면 밖에 있는 동안 생긴 바뀜을 맞추고, 바로 한 번 물어 기준을 잡는다
// (1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다 — 실시간이 살아 있으면 묻지 않는다)
syncPending()
void ask()
onScopeDispose(stop)
@@ -83,6 +83,27 @@ describe('useMailTabTitle (탭 제목)', () => {
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
})
it('숨은 탭 — 실시간 소식의 안 읽은 수(setInboxUnread)로 바로 맞춘다 (계약 2절 "실시간 알림")', async () => {
useMailStore().mailboxes = [inbox(3)]
await mountHost()
useMailStore().setInboxUnread(5)
await flushPromises()
expect(document.title).toBe('(5) 메일 · ARA SYSTEM')
useMailStore().setInboxUnread(0)
await flushPromises()
expect(document.title).toBe('메일 · ARA SYSTEM')
})
it('메일함을 아직 받지 않았으면 안 읽은 수를 고칠 메일함이 없다 — 받은 뒤의 값을 쓴다', async () => {
await mountHost()
useMailStore().setInboxUnread(5)
await flushPromises()
expect(document.title).toBe('메일 · ARA SYSTEM')
})
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
useMailStore().mailboxes = [inbox(3)]
const router = await mountHost()
@@ -0,0 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import type { Router } from 'vue-router'
import type { NewMailContext } from '@/composables/useMailRealtime'
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
import type { Mailbox } from '@/shared/types/mail'
import type { NewMailItem, NewMailSignal } from '@/shared/utils/mail-events'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailStore } from '@/stores/mail.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
// 새 메일 바탕화면 알림 (계약 2절 "실시간 알림" · 디자인 README "5단계 › 바탕화면 알림")
// 설정을 켰고 · 브라우저가 허락했고 · 보이는 웹메일 탭이 하나도 없을 때만 — 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = {
id: 'inbox',
name: '받은편지함',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 3,
unreadCount: 3,
}
function item(id: string, name: string, email: string, subject = `제목 ${id}`): NewMailItem {
return { id, from: { name, email }, subject, receivedAt: '2026-10-28T05:42:00.000Z' }
}
const ONE: NewMailSignal = { count: 1, messages: [item('m1', '박서준', 'seojun.park@arasystem.test', '11월 납품분 견적서')] }
const THREE: NewMailSignal = {
count: 3,
messages: [
item('m3', '박서준', 'seojun.park@arasystem.test'),
item('m2', '이도윤', 'doyun.lee@arasystem.test'),
item('m1', '최지호', 'jiho.choi@arasystem.test'),
],
}
describe('useNewMailNotifier (새 메일 바탕화면 알림)', () => {
let pinia: Pinia
let router: Router
let notify: ReturnType<typeof useNewMailNotifier>['notify']
let dismiss: ReturnType<typeof useNewMailNotifier>['dismiss']
let viewing: boolean
let otherTabViewing: boolean
let askOtherTabsViewing: Mock<() => Promise<boolean>>
let apiRequests: MockRequest[]
beforeEach(async () => {
pinia = createPinia()
setActivePinia(pinia)
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('granted')
useAuthStore().markAuthenticated(USER)
apiRequests = installMockApi(() =>
ok({ items: [INBOX], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }),
)
viewing = false
otherTabViewing = false
askOtherTabsViewing = vi.fn(() => Promise.resolve(otherTabViewing))
router = createTestRouter()
await router.push('/settings/mail/notify')
await router.isReady()
const Host = defineComponent({
setup() {
const notifier = useNewMailNotifier()
notify = notifier.notify
dismiss = notifier.dismiss
return () => h('div')
},
})
mount(Host, { global: { plugins: [pinia, router] } })
})
afterEach(() => {
FakeBroadcastChannel.closeAll()
uninstallFakeLocks()
vi.unstubAllGlobals()
vi.restoreAllMocks()
restoreApiAdapter()
})
const context = (): NewMailContext => ({ isUserViewing: () => viewing, askOtherTabsViewing })
async function enable() {
await useMailNotifyStore().turnOn()
}
it('설정을 켜지 않았으면 알리지 않는다 — 다른 탭에 묻지도 않는다', async () => {
await expect(notify(ONE, context())).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
expect(askOtherTabsViewing).not.toHaveBeenCalled()
})
it('브라우저가 허락하지 않았으면 알리지 않는다', async () => {
await enable()
FakeNotification.permission = 'denied'
await expect(notify(ONE, context())).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
})
it('사용자가 이 탭을 보고 있으면(보이고 초점이 있음) 알리지 않는다 — 목록 · 탭 제목에 바로 보이므로', async () => {
await enable()
viewing = true
await expect(notify(ONE, context())).resolves.toBe(false)
expect(askOtherTabsViewing).not.toHaveBeenCalled()
expect(FakeNotification.shown).toEqual([])
})
it('사용자가 다른 탭을 보고 있으면 알리지 않는다', async () => {
await enable()
otherTabViewing = true
await expect(notify(ONE, context())).resolves.toBe(false)
expect(askOtherTabsViewing).toHaveBeenCalledTimes(1)
expect(FakeNotification.shown).toEqual([])
})
it('보고 있는 탭이 없으면 한 통 — 제목은 보낸 사람, 내용은 메일 제목, tag 하나로 바꿔 쓰고 다시 알린다 · ARA 아이콘', async () => {
await enable()
await expect(notify(ONE, context())).resolves.toBe(true)
expect(FakeNotification.shown).toHaveLength(1)
const [shown] = FakeNotification.shown
expect(shown?.title).toBe('박서준')
expect(shown?.options).toMatchObject({ body: '11월 납품분 견적서', tag: 'arasystem-new-mail', renotify: true })
expect(shown?.options.icon).toMatch(/notification-icon/)
})
it('여러 통 — "새 메일 3통" · "박서준 · 이도윤 외 1명"', async () => {
await enable()
await notify(THREE, context())
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
['새 메일 3통', '박서준 · 이도윤 외 1명'],
])
})
it('「메일 제목도 보이기」를 끄면 한 통의 내용은 "새 메일이 왔습니다" — 여러 통은 그대로', async () => {
await enable()
useMailNotifyStore().setShowSubject(false)
await notify(ONE, context())
await notify(THREE, context())
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
['박서준', '새 메일이 왔습니다'],
['새 메일 3통', '박서준 · 이도윤 외 1명'],
])
})
it('누르면 창을 앞으로 가져오고 알림을 닫은 뒤 그 메일을 연다 — 기록에 쌓는다', async () => {
await enable()
useMailStore().mailboxes = [INBOX]
const focus = vi.spyOn(window, 'focus').mockImplementation(() => undefined)
const push = vi.spyOn(router, 'push')
await notify(ONE, context())
FakeNotification.shown[0]?.click()
await flushPromises()
expect(focus).toHaveBeenCalledTimes(1)
expect(FakeNotification.shown[0]?.closed).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(push).toHaveBeenCalledTimes(1)
})
it('여러 통이면 받은편지함을 연다', async () => {
await enable()
useMailStore().mailboxes = [INBOX]
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
await notify(THREE, context())
FakeNotification.shown[0]?.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('받은편지함을 아직 모르면(설정 화면 등) 메일함을 받아 본 뒤 연다', async () => {
await enable()
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
await notify(ONE, context())
FakeNotification.shown[0]?.click()
await flushPromises()
await flushPromises()
expect(apiRequests.map((request) => request.url)).toEqual(['/mailboxes'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('다른 탭의 답을 기다리는 사이 로그아웃하면 알리지 않는다', async () => {
await enable()
let answer: (value: boolean) => void = () => undefined
askOtherTabsViewing.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
answer = resolve
}),
)
const pending = notify(ONE, context())
useAuthStore().clearAuth()
answer(false)
await expect(pending).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
})
it('답을 기다리는 사이 사용자가 돌아왔으면(보게 됨) 알리지 않는다', async () => {
await enable()
askOtherTabsViewing.mockImplementation(() => {
viewing = true
return Promise.resolve(false)
})
await expect(notify(ONE, context())).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
})
it('만들 수 없는 브라우저(휴대폰 Chrome 등 — 만들면 예외)면 알리지 못하고 설정을 "지원하지 않음"으로 바꾼다', async () => {
await enable()
FakeNotification.constructorThrows = true
await expect(notify(ONE, context())).resolves.toBe(false)
const notifyStore = useMailNotifyStore()
expect(notifyStore.permission).toBe('unsupported')
expect(notifyStore.canNotify).toBe(false)
// 다음 새 메일은 묻지도 않는다
await expect(notify(ONE, context())).resolves.toBe(false)
expect(askOtherTabsViewing).toHaveBeenCalledTimes(1)
})
it('사용자가 웹메일로 돌아오면(dismiss) 띄운 알림을 닫는다 — 띄운 것이 없으면 할 일이 없다', async () => {
await enable()
expect(() => dismiss()).not.toThrow()
await notify(ONE, context())
expect(FakeNotification.shown[0]?.closed).toBe(false)
dismiss()
expect(FakeNotification.shown[0]?.closed).toBe(true)
})
it('누르기 전에 세션이 바뀌었으면(다른 계정) 이동하지 않는다 — 메일 id 는 계정마다 겹칠 수 있다', async () => {
await enable()
useMailStore().mailboxes = [INBOX]
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
await notify(ONE, context())
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
FakeNotification.shown[0]?.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/notify')
})
})
@@ -0,0 +1,75 @@
import { showDesktopNotification } from './useDesktopNotification'
import type { NewMailContext } from './useMailRealtime'
import { useMailNavigation } from './useMailNavigation'
import { newMailNotificationContent, type NewMailNotificationTarget } from '@/shared/utils/desktop-notify'
import type { NewMailSignal } from '@/shared/utils/mail-events'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailStore } from '@/stores/mail.store'
// 새 메일 바탕화면 알림 — 계약 2절 "실시간 알림" · 디자인 README "5단계 › 바탕화면 알림"
// new-mail 을 받은 연결 탭(useMailRealtime)이 부른다. 아래가 모두 맞을 때만 알린다:
// - 설정 › 메일 › 알림을 켰다 (이 브라우저 · 이 사용자) · 브라우저가 허락했다(Notification.permission === 'granted')
// - 사용자가 보고 있는 웹메일 탭이 하나도 없다 — "보고 있음"은 보이고(visibilityState) 초점이 있음(hasFocus).
// 이 탭이 그렇지 않고, 다른 탭에 물어 그런 탭의 답이 없을 때. 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 알린다
// 알림은 하나의 tag 로 앞 알림을 바꿔 쓴다. 누르면 이 탭으로 와서 그 메일을 연다(여러 통이면 받은편지함) — 기록에 쌓는다(목록에서 여는 것과 같다).
// 사용자가 어느 웹메일 탭이든 보게 되면 띄운 알림을 닫는다 (dismiss — useMailRealtime 의 onUserReturn).
// 만들 수 없는 브라우저(휴대폰 Chrome 등)로 드러나면 설정을 "지원하지 않음"으로 바꾼다 (mail-notify.store 의 markUnsupported).
// 묻는 사이 · 누르기 전에 세션이 바뀌었으면(로그아웃 · 다른 계정) 아무것도 하지 않는다 — 메일 id 는 계정마다 겹칠 수 있다.
// App.vue 의 setup 에서 부른다 (라우터를 쓴다).
export function useNewMailNotifier() {
const authStore = useAuthStore()
const notifyStore = useMailNotifyStore()
const mailStore = useMailStore()
const { openFromNotification } = useMailNavigation()
/** 마지막으로 띄운 알림을 닫는다 — 띄운 것이 없으면 null */
let closeShown: (() => void) | null = null
/** 알림을 눌렀다 — 받은편지함을 아직 모르면 받아 본 뒤, 그래도 모르면 메일 첫 화면으로 */
async function open(target: NewMailNotificationTarget, session: number): Promise<void> {
closeShown = null
if (authStore.generation !== session) return
const inbox = await mailStore.resolveMailboxByRole('inbox')
if (authStore.generation !== session) return
await openFromNotification(
'mailbox' in inbox ? inbox.mailbox.id : null,
target.kind === 'message' ? target.messageId : null,
)
}
/** 알릴 수 있는지 — 설정 · 브라우저의 허락을 지금 다시 본다 */
function canNotify(): boolean {
notifyStore.refreshPermission()
return notifyStore.canNotify
}
/** @returns 알렸는지 */
async function notify(signal: NewMailSignal, context: NewMailContext): Promise<boolean> {
if (!canNotify() || context.isUserViewing()) return false
const session = authStore.generation
if (await context.askOtherTabsViewing()) return false
// 답을 기다리는 사이 설정을 끄거나(다른 탭) 세션이 바뀌었거나 사용자가 돌아왔을 수 있다
if (authStore.generation !== session || !canNotify() || context.isUserViewing()) return false
const content = newMailNotificationContent(signal, { showSubject: notifyStore.settings.showSubject })
if (!content) return false
const result = showDesktopNotification({
title: content.title,
body: content.body,
onClick: () => void open(content.target, session),
})
if (result.status === 'unsupported') notifyStore.markUnsupported()
if (result.status !== 'shown') return false
closeShown = result.close
return true
}
/** 사용자가 웹메일로 돌아왔다 — 띄운 알림을 닫는다 (목록 · 탭 제목에 바로 보이므로) */
function dismiss(): void {
closeShown?.()
closeShown = null
}
return { notify, dismiss }
}
+9 -1
View File
@@ -39,6 +39,14 @@ export function useToast() {
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
}
/**
* 화면이 직접 띄우는 오류 알림 — API 오류가 아니라 브라우저 상태를 알릴 때만 쓴다 (예: 바탕화면 알림을 막아 둠 + [설정 열기]).
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
*/
function error(message: string, action?: ToastAction): number {
return store.show({ kind: 'error', message, action: action ? sessionBound(action) : undefined })
}
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
function progress(message: string): ProgressToast {
const session = authStore.generation
@@ -60,5 +68,5 @@ export function useToast() {
}
}
return { success, progress, dismiss: store.dismiss }
return { success, error, progress, dismiss: store.dismiss }
}