client.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import type { JsonValue } from '@shared/types'
  2. export class ApiError extends Error {
  3. readonly status: number
  4. readonly code?: string
  5. readonly details?: JsonValue
  6. constructor(message: string, status = 0, code?: string, details?: JsonValue) {
  7. super(message)
  8. this.name = 'ApiError'
  9. this.status = status
  10. this.code = code
  11. this.details = details
  12. }
  13. }
  14. let csrfToken = ''
  15. const unauthorizedListeners = new Set<() => void>()
  16. const securityChannel = typeof window !== 'undefined' && typeof window.BroadcastChannel !== 'undefined'
  17. ? new window.BroadcastChannel('hermes-yaoyao-security')
  18. : undefined
  19. securityChannel?.addEventListener('message', event => {
  20. if (typeof event.data === 'string') csrfToken = event.data
  21. })
  22. export function setApiCsrfToken(token?: string | null): void {
  23. csrfToken = token?.trim() ?? ''
  24. securityChannel?.postMessage(csrfToken)
  25. }
  26. export function clearApiSecurityContext(): void {
  27. csrfToken = ''
  28. }
  29. export function onApiUnauthorized(listener: () => void): () => void {
  30. unauthorizedListeners.add(listener)
  31. return () => unauthorizedListeners.delete(listener)
  32. }
  33. export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
  34. body?: JsonValue | FormData | Blob
  35. csrf?: boolean
  36. timeoutMs?: number
  37. notifyUnauthorized?: boolean
  38. }
  39. function isBodyInit(value: ApiRequestOptions['body']): value is FormData | Blob {
  40. return (typeof FormData !== 'undefined' && value instanceof FormData)
  41. || (typeof Blob !== 'undefined' && value instanceof Blob)
  42. }
  43. function errorMessage(payload: unknown, fallback: string): string {
  44. if (payload && typeof payload === 'object') {
  45. const record = payload as Record<string, unknown>
  46. const nested = record.error && typeof record.error === 'object'
  47. ? record.error as Record<string, unknown>
  48. : undefined
  49. for (const candidate of [record.message, record.error, nested?.message, record.detail]) {
  50. if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
  51. }
  52. if (Array.isArray(record.detail)) {
  53. const validation = record.detail.find(item => item && typeof item === 'object') as Record<string, unknown> | undefined
  54. if (validation && typeof validation.msg === 'string' && validation.msg.trim()) {
  55. const location = Array.isArray(validation.loc)
  56. ? validation.loc.filter(item => typeof item === 'string' || typeof item === 'number').join('.')
  57. : ''
  58. return location ? `${location}:${validation.msg.trim()}` : validation.msg.trim()
  59. }
  60. }
  61. }
  62. if (typeof payload === 'string' && payload.trim()) return payload.trim()
  63. return fallback
  64. }
  65. export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
  66. if (!path.startsWith('/api/app/')) throw new ApiError('客户端仅允许访问 /api/app 接口', 0, 'INVALID_PATH')
  67. const method = (options.method ?? 'GET').toUpperCase()
  68. const headers = new Headers(options.headers)
  69. headers.set('Accept', 'application/json')
  70. if (options.csrf !== false && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
  71. if (!csrfToken) throw new ApiError('安全令牌尚未就绪,请刷新后重试', 0, 'CSRF_MISSING')
  72. headers.set('X-CSRF-Token', csrfToken)
  73. }
  74. let body: BodyInit | undefined
  75. if (options.body !== undefined) {
  76. if (isBodyInit(options.body)) {
  77. body = options.body
  78. } else {
  79. headers.set('Content-Type', 'application/json')
  80. body = JSON.stringify(options.body)
  81. }
  82. }
  83. const controller = new AbortController()
  84. const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 30_000)
  85. const abort = () => controller.abort(options.signal?.reason)
  86. options.signal?.addEventListener('abort', abort, { once: true })
  87. try {
  88. const response = await fetch(path, {
  89. ...options,
  90. method,
  91. headers,
  92. body,
  93. credentials: 'include',
  94. cache: 'no-store',
  95. signal: controller.signal,
  96. })
  97. const contentType = response.headers.get('content-type') ?? ''
  98. const payload = response.status === 204
  99. ? undefined
  100. : contentType.includes('application/json')
  101. ? await response.json().catch(() => undefined)
  102. : await response.text().catch(() => undefined)
  103. if (!response.ok) {
  104. const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : undefined
  105. const code = typeof record?.code === 'string' ? record.code : undefined
  106. if (response.status === 401
  107. && options.notifyUnauthorized !== false) {
  108. for (const listener of unauthorizedListeners) listener()
  109. }
  110. throw new ApiError(
  111. errorMessage(payload, `请求失败(${response.status})`),
  112. response.status,
  113. code,
  114. payload as JsonValue,
  115. )
  116. }
  117. return payload as T
  118. } catch (error) {
  119. if (error instanceof ApiError) throw error
  120. if (controller.signal.aborted) throw new ApiError('请求已取消或超时', 0, 'REQUEST_ABORTED')
  121. throw new ApiError(error instanceof Error ? error.message : '网络请求失败', 0, 'NETWORK_ERROR')
  122. } finally {
  123. window.clearTimeout(timeout)
  124. options.signal?.removeEventListener('abort', abort)
  125. }
  126. }
  127. export function apiUrl(path: string, query: Record<string, string | number | boolean | null | undefined> = {}): string {
  128. const params = new URLSearchParams()
  129. for (const [key, value] of Object.entries(query)) {
  130. if (value !== undefined && value !== null && value !== '') params.set(key, String(value))
  131. }
  132. const encoded = params.toString()
  133. return encoded ? `${path}?${encoded}` : path
  134. }
  135. export function unwrapData<T>(payload: T | { data: T }): T {
  136. return payload && typeof payload === 'object' && 'data' in payload
  137. ? (payload as { data: T }).data
  138. : payload as T
  139. }