优化登录,优化阅读

This commit is contained in:
truewhile
2026-10-03 21:00:16 +08:00
parent 5242d54a06
commit b0f1ab39b9
22 changed files with 1513 additions and 155 deletions
+23 -23
View File
@@ -2,9 +2,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
import type { RefreshOutcome } from '../utils/authRefresh'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and routes 401s back to the login page.
// requests and refreshes it on 401. Only an explicit server-side rejection of
// the refresh token sends the user back to the login page; transient failures
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
@@ -92,33 +95,30 @@ api.interceptors.response.use(
originalRequest._retry = true
isRefreshing = true
try {
const refreshed = await useAuthStore.getState().tokenRefresh()
if (refreshed) {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
onTokenRefreshed(newToken || '')
isRefreshing = false
return api(originalRequest)
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
// 超时只是暂时不可用,保留本地令牌等下一次重试。
const outcome: RefreshOutcome = await useAuthStore
.getState()
.refreshSession()
.catch((): RefreshOutcome => 'transient')
isRefreshing = false
if (outcome === 'refreshed') {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
} catch (refreshError) {
isRefreshing = false
onTokenRefreshFailed(refreshError)
onTokenRefreshed(newToken || '')
return api(originalRequest)
}
onTokenRefreshFailed(err)
if (outcome === 'invalid') {
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(refreshError)
}
// Refresh failed, logout
isRefreshing = false
onTokenRefreshFailed(err)
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(err)
}
+8 -2
View File
@@ -366,9 +366,15 @@ export const readerAPI = {
.then((r) => r.data.chapters ?? []),
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
bookContent: (id: string, chapter: number) =>
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
api
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
params: { chapter },
timeout: LONG_REQUEST_TIMEOUT,
signal,
})
.then((r) => r.data),
// ── 替换净化规则 ──
+50 -31
View File
@@ -1,37 +1,56 @@
// 令牌刷新 API 模块
import { api } from './client'
// 令牌刷新 API 模块。
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
// 刷新令牌请求/响应
export interface RefreshTokenRequest {
refresh_token: string
}
const REFRESH_ENDPOINT = '/api/auth/refresh'
export interface RefreshTokenResponse {
token: string
refresh_token: string
expires_in: number
token_type: string
}
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
// 不要用默认的长超时把页面卡在等待里。
const REFRESH_TIMEOUT_MS = 15_000
// 刷新访问令牌。
//
// 后端响应封装在 { code, message, data } 里,需解包 .data。
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
const resp = await api.post<{
code: number
message: string
data: RefreshTokenResponse
}>('/auth/refresh', { refresh_token: refreshToken })
const body = resp.data
if (!body || !body.data || !body.data.token) {
throw new Error(body?.message || 'refresh failed')
/**
* 用 refresh token 换取新的令牌对。
*
* 刻意使用 fetch 而不是共享的 axios 实例:
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
*
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
*/
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
let resp: Response
try {
resp = await fetch(REFRESH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ refresh_token: refreshToken }),
signal: controller.signal,
})
} catch {
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
throw new RefreshRequestError('refresh request failed')
} finally {
clearTimeout(timer)
}
return body.data
}
// 登出
export async function logout(): Promise<void> {
await api.post('/me/logout')
const body = (await resp.json().catch(() => null)) as
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
| null
if (!resp.ok) {
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
}
const token = body?.data?.token
const nextRefreshToken = body?.data?.refresh_token
if (!token || !nextRefreshToken) {
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
// 不要据此清空用户会话。
throw new RefreshRequestError('malformed refresh response')
}
return { token, refresh_token: nextRefreshToken }
}
+16 -5
View File
@@ -52,13 +52,24 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
let timer: number | undefined
let reconnectAttempts = 0
// 过期的 token 握手必然 401。先走刷新流程:成功会更新 token 并让本
// effect 重建连接;「暂时不可用」(部署/重启窗口的网络故障)时保留
// 会话继续退避重试;只有服务端明确判定凭证失效才会停止重连。
const refreshExpiredToken = () => {
void useAuthStore
.getState()
.tokenRefresh()
.then((refreshed) => {
if (closed || refreshed) return
if (!useAuthStore.getState().refreshToken) return
timer = window.setTimeout(open, SLOW_RECONNECT_INTERVAL)
})
}
const open = () => {
if (closed) return
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
// 让本 effect 重建连接,失败则清空会话停止重连。
if (isTokenExpired(token)) {
void useAuthStore.getState().tokenRefresh()
refreshExpiredToken()
return
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -82,7 +93,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
if (closed) return
// token 过期时不要继续退避重试,交给刷新流程处理。
if (isTokenExpired(token)) {
void useAuthStore.getState().tokenRefresh()
refreshExpiredToken()
return
}
reconnectAttempts += 1
+30 -8
View File
@@ -109,6 +109,8 @@ export default function ReaderViewPage() {
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
const [reloadKey, setReloadKey] = useState(0)
// 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
const [contentReloadKey, setContentReloadKey] = useState(0)
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
@@ -248,20 +250,33 @@ export default function ReaderViewPage() {
}
}, [bookId, reloadKey])
// 正文缓存的键带上书源标识(origin + book_url):换源后在途的旧源响应即使晚到,
// 也只会落在旧键上,不会被新源读到。聚合源的不同子源共用同一个 origin(子源写在
// book_url 里),所以不能只用 origin。
const contentCacheKey = useCallback(
(index: number) => `${book?.origin ?? ''}\u0000${book?.book_url ?? ''}\u0000${index}`,
[book?.origin, book?.book_url],
)
// ── 加载章节正文(带缓存与下一章预取) ──
useEffect(() => {
if (chapterIndex === null || !book || chapters.length === 0) return
const ch = chapters[chapterIndex]
if (!ch) return
let cancelled = false
// 换章/换源/离开页面时取消在途请求:慢源一章要等十几秒,不取消就会白等旧源的
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
const ac = new AbortController()
;(async () => {
setContent(null)
setLoadingStage('content')
try {
const cacheKey = String(chapterIndex)
const cacheKey = contentCacheKey(chapterIndex)
let ct = contentCache.current.get(cacheKey)
if (!ct) {
ct = await readerAPI.bookContent(book.id, chapterIndex)
ct = await readerAPI.bookContent(book.id, chapterIndex, ac.signal)
// 请求期间切了章/换了源:结果已作废,既不用也不必入缓存。
if (cancelled) return
contentCache.current.set(cacheKey, ct)
}
if (cancelled) return
@@ -300,10 +315,14 @@ export default function ReaderViewPage() {
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
if (!contentCache.current.has(String(chapterIndex + 1))) {
if (!contentCache.current.has(contentCacheKey(chapterIndex + 1))) {
readerAPI
.bookContent(book.id, chapterIndex + 1)
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
.bookContent(book.id, chapterIndex + 1, ac.signal)
.then((c) => {
// 预取是在「旧源」发起、在换源后才回来的话,结果属于脏数据,丢掉。
if (cancelled) return
contentCache.current.set(contentCacheKey(chapterIndex + 1), c)
})
.catch(() => undefined)
}
} catch (e) {
@@ -317,8 +336,9 @@ export default function ReaderViewPage() {
})()
return () => {
cancelled = true
ac.abort()
}
}, [chapterIndex, book, chapters])
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
// ── 分页排版(CSS 多栏 + 平移) ──
const relayout = useCallback(() => {
@@ -953,8 +973,10 @@ export default function ReaderViewPage() {
onClick={() => {
setError('')
if (chapterIndex !== null) {
contentCache.current.delete(String(chapterIndex))
setChapterIndex(chapterIndex)
contentCache.current.delete(contentCacheKey(chapterIndex))
// 用 reload 计数触发重取:把 chapterIndex 设成同一个值不会让
// effect 重跑,以前点「重试」只是把错误提示清掉了。
setContentReloadKey((v) => v + 1)
}
}}
className="rounded-xl border px-4 py-1.5 text-xs font-bold"
+70 -40
View File
@@ -2,7 +2,12 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
import { refreshToken } from '../api/refresh'
import { requestRefreshTokens } from '../api/refresh'
import {
createBrowserExclusiveRunner,
createRefreshCoordinator,
type RefreshOutcome,
} from '../utils/authRefresh'
// Single source of truth for the authenticated user + JWT.
// Persisted to localStorage so a page reload does not drop the session.
@@ -16,50 +21,65 @@ interface AuthState {
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
logout: () => void
/**
* 刷新会话的唯一入口(同标签页 + 跨标签页收敛成一次请求)。
*
* 返回的 outcome 决定调用方怎么处理:
* - 'refreshed' 已拿到新令牌,可重试原请求;
* - 'invalid' 服务端明确判定凭证失效,本地会话已清空,需重新登录;
* - 'transient' 网络/服务暂时不可用,本地会话保留,稍后重试即可。
*/
refreshSession: () => Promise<RefreshOutcome>
/** 兼容旧调用方:只在真的换到新令牌时返回 true。 */
tokenRefresh: () => Promise<boolean>
}
export const AUTH_STORAGE_KEY = 'mebox-auth'
// 跨标签页互斥:多个标签页共享同一份 refresh token,
// 只允许其中一个真正发起刷新。
const runRefreshExclusive = createBrowserExclusiveRunner('mebox-auth-refresh')
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
token: null,
refreshToken: null,
user: null,
tier: 'free',
setSession: (token, refreshToken, user) => set({
token,
refreshToken,
user,
tier: user.tier || 'free'
}),
setUser: (user) => set({ user, tier: user.tier || 'free' }),
setToken: (token) => set({ token }),
setRefreshToken: (refreshToken) => set({ refreshToken }),
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
tokenRefresh: async () => {
const rt = get().refreshToken
if (!rt) {
return false
}
try {
const resp = await refreshToken(rt)
set({
token: resp.token,
refreshToken: resp.refresh_token
})
return true
} catch {
// Refresh failed, need to logout
set({ token: null, refreshToken: null, user: null, tier: 'free' })
return false
}
},
}),
{
name: 'mebox-auth',
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
(set, get) => {
const refreshSession = createRefreshCoordinator({
readAccessToken: () => get().token,
readRefreshToken: () => get().refreshToken,
applyTokens: (tokens) =>
set({ token: tokens.token, refreshToken: tokens.refresh_token }),
clearSession: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
request: requestRefreshTokens,
runExclusive: runRefreshExclusive,
// 其他标签页刷新成功后会写回 localStorage;重新读进来,避免本标签页
// 继续持有已被服务端轮换作废的旧 refresh token。
syncFromStorage: () => useAuthStore.persist.rehydrate(),
})
return {
token: null,
refreshToken: null,
user: null,
tier: 'free',
setSession: (token, refreshToken, user) => set({
token,
refreshToken,
user,
tier: user.tier || 'free'
}),
setUser: (user) => set({ user, tier: user.tier || 'free' }),
setToken: (token) => set({ token }),
setRefreshToken: (refreshToken) => set({ refreshToken }),
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
refreshSession,
tokenRefresh: async () => (await refreshSession()) === 'refreshed',
}
},
{
name: AUTH_STORAGE_KEY,
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
user: state.user,
tier: state.tier
}),
@@ -67,6 +87,16 @@ export const useAuthStore = create<AuthState>()(
),
)
// storage 事件只在其他标签页写入时触发:登出/轮换后同步本地会话,
// 否则本标签页会继续用已失效的令牌发请求。
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key === AUTH_STORAGE_KEY) {
void useAuthStore.persist.rehydrate()
}
})
}
// Helper function to check if user is authenticated
export function isAuthenticated(): boolean {
return useAuthStore.getState().token !== null
+188
View File
@@ -0,0 +1,188 @@
// 刷新令牌并发保护与失败分类的回归测试。
// 纯逻辑,用 node 直接跑:node src/utils/authRefresh.test.ts
// (对齐 utils/mediaVersion.test.ts 的写法)。
import {
RefreshRequestError,
createRefreshCoordinator,
createSingleFlightRunner,
refreshFailureOutcome,
type RefreshOutcome,
type RefreshTokens,
} from './authRefresh.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`authRefresh: ${name}`)
}
async function expectOutcome(name: string, promise: Promise<RefreshOutcome>, want: RefreshOutcome) {
const got = await promise
if (got !== want) throw new Error(`authRefresh: ${name} = ${got}, want ${want}`)
}
// ── 失败分类:只有服务端明确拒绝凭证才算会话失效 ──────────────────────────
check('401 is invalid', refreshFailureOutcome(401) === 'invalid')
check('403 is invalid', refreshFailureOutcome(403) === 'invalid')
check('400 is invalid', refreshFailureOutcome(400) === 'invalid')
check('network failure is transient', refreshFailureOutcome(undefined) === 'transient')
check('502 gateway is transient', refreshFailureOutcome(502) === 'transient')
check('503 gateway is transient', refreshFailureOutcome(503) === 'transient')
check('timeout is transient', refreshFailureOutcome(408) === 'transient')
check('429 is transient', refreshFailureOutcome(429) === 'transient')
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
{
let calls = 0
let release: (() => void) | null = null
const run = createSingleFlightRunner(async () => {
calls += 1
const current = calls
// 只有第一次执行停在闸门上,用来观察「在途期间不会重复执行」。
if (current === 1) {
await new Promise<void>((resolve) => {
release = resolve
})
}
return current
})
const first = run()
const second = run()
check('single flight shares one promise', first === second)
// 任务体在下一个微任务才开始,排在其后放行即可确保 release 已就绪。
queueMicrotask(() => release?.())
check('single flight result', (await first) === 1)
check('single flight stayed single', calls === 1)
check('single flight re-runs after settle', (await run()) === 2)
}
// ── 协调器 ────────────────────────────────────────────────────────────────
function coordinatorHarness(overrides: {
refreshToken?: string | null
accessToken?: string | null
request?: (token: string) => Promise<RefreshTokens>
onApply?: (tokens: RefreshTokens) => void
onClear?: () => void
syncFromStorage?: () => void
}) {
const state = {
refreshToken: overrides.refreshToken === undefined ? 'rt-1' : overrides.refreshToken,
accessToken: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken,
applied: [] as RefreshTokens[],
cleared: 0,
requests: [] as string[],
}
const refreshSession = createRefreshCoordinator({
readAccessToken: () => state.accessToken,
readRefreshToken: () => state.refreshToken,
applyTokens: (tokens) => {
state.applied.push(tokens)
state.accessToken = tokens.token
state.refreshToken = tokens.refresh_token
overrides.onApply?.(tokens)
},
clearSession: () => {
state.cleared += 1
state.accessToken = null
state.refreshToken = null
overrides.onClear?.()
},
request: async (token) => {
state.requests.push(token)
return overrides.request ? overrides.request(token) : { token: 'at-2', refresh_token: 'rt-2' }
},
runExclusive: (task) => task(),
syncFromStorage: () => overrides.syncFromStorage?.(),
})
return { state, refreshSession }
}
// 并发刷新只发一次请求,且都拿到成功结果(部署后 WS 重连 + 拦截器同时刷新)。
{
const { state, refreshSession } = coordinatorHarness({})
const [a, b] = await Promise.all([refreshSession(), refreshSession()])
check('concurrent refresh both succeed', a === 'refreshed' && b === 'refreshed')
check('concurrent refresh sends one request', state.requests.length === 1)
check('concurrent refresh applies once', state.applied.length === 1)
}
// 服务暂时不可用(502 / 网络错误)时保留会话,不清空本地令牌。
{
const { state, refreshSession } = coordinatorHarness({
request: async () => {
throw new RefreshRequestError('bad gateway', 502)
},
})
await expectOutcome('gateway error', refreshSession(), 'transient')
check('gateway error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
}
{
const { state, refreshSession } = coordinatorHarness({
request: async () => {
throw new RefreshRequestError('network down')
},
})
await expectOutcome('network error', refreshSession(), 'transient')
check('network error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
}
// 服务端明确判定凭证失效(401 revoked)时才清空会话。
{
const { state, refreshSession } = coordinatorHarness({
request: async () => {
throw new RefreshRequestError('refresh token revoked', 401)
},
})
await expectOutcome('revoked token', refreshSession(), 'invalid')
check('revoked token clears session', state.cleared === 1 && state.refreshToken === null)
}
// 没有 refresh token 时直接判定失效,不发请求。
{
const { state, refreshSession } = coordinatorHarness({ refreshToken: null })
await expectOutcome('missing refresh token', refreshSession(), 'invalid')
check('missing refresh token sends no request', state.requests.length === 0)
}
// 另一个标签页已经换过令牌:直接复用,不能拿旧 token 再刷(那会被判 401)。
{
const { state, refreshSession } = coordinatorHarness({
syncFromStorage: () => {
state.refreshToken = 'rt-other-tab'
state.accessToken = 'at-other-tab'
},
})
await expectOutcome('other tab already rotated', refreshSession(), 'refreshed')
check('other tab rotation sends no request', state.requests.length === 0)
check('other tab rotation keeps new session', state.refreshToken === 'rt-other-tab')
}
// 另一个标签页登出了:本标签页也应判定失效。
{
const { state, refreshSession } = coordinatorHarness({
syncFromStorage: () => {
state.refreshToken = null
state.accessToken = null
},
})
await expectOutcome('other tab logged out', refreshSession(), 'invalid')
check('other tab logout sends no request', state.requests.length === 0)
}
// 瞬时故障后仍可重试:会话没被清空,下一次刷新能成功。
{
let attempt = 0
const { state, refreshSession } = coordinatorHarness({
request: async () => {
attempt += 1
if (attempt === 1) throw new RefreshRequestError('network down')
return { token: 'at-retry', refresh_token: 'rt-retry' }
},
})
await expectOutcome('first transient attempt', refreshSession(), 'transient')
await expectOutcome('retry after transient', refreshSession(), 'refreshed')
check('retry used the same token', state.requests[1] === 'rt-1')
check('retry applied new tokens', state.refreshToken === 'rt-retry')
}
console.log('authRefresh.test.ts ok')
+148
View File
@@ -0,0 +1,148 @@
// 刷新令牌的并发保护与失败分类。
//
// 这里是纯逻辑(不 import 任何模块),便于用 node 直接跑回归测试
// (对齐 utils/mediaVersion.test.ts 的写法)。
//
// 背景:refresh token 是一次性凭证,服务端刷新成功即作废旧 token。真实
// 客户端却天然会并发使用同一个令牌——同一标签页里 WebSocket 重连与 401
// 拦截器、多个标签页共享一份 localStorage、部署/重启后多个页面同时刷新。
// 此前第二个请求必然拿到 401,前端把 401 当成会话失效并清空本地令牌,
// 外部表现就是「每次部署之后都要重新登录」。本模块负责:
//
// 1. 把刷新收敛成单次执行(single-flight + 浏览器跨标签页锁);
// 2. 只在服务端明确拒绝凭证时才清空会话,网络/网关故障保留会话等重试。
/** 一次刷新尝试的结果。 */
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
/** 刷新接口返回的令牌对。 */
export interface RefreshTokens {
token: string
refresh_token: string
}
/** 刷新请求失败;status 缺失表示网络不可达/超时等非 HTTP 失败。 */
export class RefreshRequestError extends Error {
readonly status?: number
constructor(message: string, status?: number) {
super(message)
this.name = 'RefreshRequestError'
this.status = status
}
}
/**
* 判断刷新失败属于「会话确实失效」还是「暂时不可用」。
*
* 只有服务端明确答复凭证无效才允许清空会话。部署/容器重启窗口里的连接
* 失败、网关 502、超时都只是暂时不可用:把 token 留在本地等下一次重试,
* 否则一次瞬时故障就会把用户永久踢到登录页。
*/
export function refreshFailureOutcome(status?: number): RefreshOutcome {
if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) {
return 'transient'
}
if (status === 408 || status === 429 || status >= 500) {
return 'transient'
}
if (status === 400 || status === 401 || status === 403) {
return 'invalid'
}
// 其他状态码(含网关/代理的非标准应答)都无法证明会话失效。
return 'transient'
}
/** 从抛出的错误里取出 HTTP 状态码。 */
export function httpStatusOf(error: unknown): number | undefined {
if (error instanceof RefreshRequestError) return error.status
const status = (error as { status?: unknown } | null)?.status
return typeof status === 'number' ? status : undefined
}
/**
* 让并发调用共享同一次执行,结束后允许下一次重新执行。
*
* 这是同标签页内的收敛点:无论是 401 拦截器还是 WebSocket 重连触发刷新,
* 都复用同一个在途请求,不会拿同一个一次性凭证发两次。
*/
export function createSingleFlightRunner<T>(task: () => Promise<T>): () => Promise<T> {
let inflight: Promise<T> | null = null
return () => {
if (inflight) return inflight
inflight = Promise.resolve()
.then(task)
.finally(() => {
inflight = null
})
return inflight
}
}
/**
* 跨标签页互斥执行(Web Locks API)。
*
* 多个标签页共享同一份 localStorage 里的 refresh token,各自刷新的结果就是
* 互相把对方打成 401。拿锁后只有第一个标签页真正发请求,其余标签页在锁内
* 重新读取 localStorage,直接复用刚轮换出来的会话。
* 浏览器不支持 Web Locks 时退化成纯单标签页收敛。
*/
export function createBrowserExclusiveRunner(
lockName: string,
): <T>(task: () => Promise<T>) => Promise<T> {
return <T>(task: () => Promise<T>): Promise<T> => {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
if (!locks || typeof locks.request !== 'function') return task()
try {
return locks.request(lockName, () => task()) as Promise<T>
} catch {
return task()
}
}
}
export interface RefreshCoordinatorDeps {
readAccessToken: () => string | null
readRefreshToken: () => string | null
applyTokens: (tokens: RefreshTokens) => void
clearSession: () => void
request: (refreshToken: string) => Promise<RefreshTokens>
runExclusive: <T>(task: () => Promise<T>) => Promise<T>
/** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
syncFromStorage: () => void | Promise<void>
}
/**
* 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
* 结果只会是 'refreshed' | 'invalid' | 'transient'。
*/
export function createRefreshCoordinator(
deps: RefreshCoordinatorDeps,
): () => Promise<RefreshOutcome> {
return createSingleFlightRunner(async (): Promise<RefreshOutcome> => {
const tokenAtEntry = deps.readRefreshToken()
// 没有 refresh token 就无从恢复会话。
if (!tokenAtEntry) return 'invalid'
return deps.runExclusive(async () => {
// 其他标签页可能刚完成轮换:先同步它写回的令牌,
// 否则本标签页会拿已经被服务端作废的旧 token 去刷新。
await deps.syncFromStorage()
const current = deps.readRefreshToken()
if (!current) return 'invalid'
if (current !== tokenAtEntry) {
// 令牌已被其他标签页换新:直接用新会话,不再发请求。
return deps.readAccessToken() ? 'refreshed' : 'invalid'
}
try {
deps.applyTokens(await deps.request(current))
return 'refreshed'
} catch (error) {
const outcome = refreshFailureOutcome(httpStatusOf(error))
if (outcome === 'invalid') deps.clearSession()
return outcome
}
})
})
}