import ky, { HTTPError, type Options, TimeoutError } from 'ky' import { ACCESS_TOKEN_REFRESH_SKEW_MS, API_ERROR_MESSAGES, API_SUCCESS_CODE, AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY, AUTH_REFRESH_ENDPOINT, AUTH_RELOGIN_REQUIRED_CODES, AUTH_SKIP_REFRESH_CONTEXT_KEY, AUTH_TOKEN_CACHE_SKEW_MS, AUTH_TOKEN_ENDPOINT, AUTHORIZATION_BEARER_PREFIX, CONTENT_TYPE_JSON, DEFAULT_REQUEST_ACCEPT_HEADER, DEFAULT_REQUEST_TIMEOUT_MS, HTTP_STATUS, REQUEST_HEADERS, } from '@/constants' import type { AuthTokenDto } from '@/features/auth/api/types' import { getPreferredLanguage, isSupportedLanguage } from '@/i18n' import { ApiError } from '@/lib/api/api-error.ts' import { handleInvalidTokenSession, handleUnauthorizedSession, tryRefreshAuthSession, } from '@/lib/auth/auth-session' import { md5 } from '@/lib/crypto/md5' import { getAuthDeviceId, getStoredAppLanguage, useAuthStore, } from '@/store/auth' import type { ApiResponse } from '@/type' type RequestOptions = Omit type JsonRequestOptions = RequestOptions & { json?: TBody } const appEnv = import.meta.env.VITE_APP_ENV const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim() const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true' function getRequestLanguage() { const storedLanguage = getStoredAppLanguage() if (isSupportedLanguage(storedLanguage)) { return storedLanguage } return getPreferredLanguage() } function normalizeApiBaseUrl(baseUrl: string | undefined) { const candidate = baseUrl?.trim() if (!candidate) { throw new Error('VITE_API_BASE_URL 未配置') } if (candidate === '/') { return '/' } if (/^https?:\/\//.test(candidate)) { return candidate.replace(/\/+$/, '') } return candidate.replace(/^\/+/, '').replace(/\/+$/, '') } async function parseResponseBody(response: Response) { if (response.status === HTTP_STATUS.noContent) { return null } const contentType = response.headers.get('content-type') ?? '' if (contentType.includes(CONTENT_TYPE_JSON)) { return response.json() } return response.text() } function getErrorMessage(response: Response, data: unknown) { if (data && typeof data === 'object') { const message = 'message' in data ? data.message : 'msg' in data ? data.msg : null if (typeof message === 'string' && message.length > 0) { return message } } return `Request failed with status ${response.status}` } async function toApiError(error: unknown) { if (error instanceof ApiError) { return error } if (error instanceof HTTPError) { const data = error.data return new ApiError({ message: getErrorMessage(error.response, data), status: error.response.status, data, url: error.response.url, }) } if (error instanceof TimeoutError) { return new ApiError({ message: API_ERROR_MESSAGES.timeout, status: HTTP_STATUS.requestTimeout, }) } if (error instanceof Error) { return new ApiError({ message: error.message, }) } return new ApiError({ message: API_ERROR_MESSAGES.unexpected, }) } export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL) const authTokenClient = ky.create({ prefix: apiBaseUrl, retry: 0, timeout: DEFAULT_REQUEST_TIMEOUT_MS, headers: { Accept: DEFAULT_REQUEST_ACCEPT_HEADER, }, }) const apiClient = ky.create({ prefix: apiBaseUrl, retry: 0, timeout: DEFAULT_REQUEST_TIMEOUT_MS, hooks: { beforeRequest: [ ({ request }) => { request.headers.set( REQUEST_HEADERS.accept, DEFAULT_REQUEST_ACCEPT_HEADER, ) request.headers.set(REQUEST_HEADERS.lang, getRequestLanguage()) const token = useAuthStore.getState().accessToken if (token) { request.headers.set( REQUEST_HEADERS.authorization, `${AUTHORIZATION_BEARER_PREFIX}${token}`, ) request.headers.set(REQUEST_HEADERS.userToken, token) } if (shouldLogRequests) { console.info(`[api:${appEnv}] ${request.method} ${request.url}`) } }, ], afterResponse: [ ({ request, response }) => { if (shouldLogRequests) { console.info( `[api:${appEnv}] ${request.method} ${response.url} -> ${response.status}`, ) } }, ], }, }) function shouldAttachAuthToken(input: string) { return input !== AUTH_TOKEN_ENDPOINT } function shouldTryRefreshAccessToken(input: string, options?: Options) { if ( input === AUTH_REFRESH_ENDPOINT || options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true ) { return false } const authState = useAuthStore.getState() return Boolean( authState.accessToken && authState.accessTokenExpiresAt && authState.accessTokenExpiresAt <= Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS, ) } function isApiEnvelope(value: unknown): value is ApiResponse { return Boolean( value && typeof value === 'object' && 'code' in value && typeof value.code === 'number', ) } function getApiEnvelopeMessage(response: ApiResponse) { return 'msg' in response && typeof response.msg === 'string' ? response.msg : 'message' in response && typeof response.message === 'string' ? response.message : API_ERROR_MESSAGES.unexpected } function assertValidAuthEnvelope(data: unknown) { if ( !isApiEnvelope(data) || !AUTH_RELOGIN_REQUIRED_CODES.includes(data.code) ) { return } handleInvalidTokenSession() throw new ApiError({ data, message: getApiEnvelopeMessage(data), status: HTTP_STATUS.unauthorized, }) } function unwrapEnvelopeData(response: ApiResponse) { assertValidAuthEnvelope(response) if (response.code === API_SUCCESS_CODE) { return response.data } throw new ApiError({ data: response, message: 'msg' in response && typeof response.msg === 'string' ? response.msg : 'message' in response && typeof response.message === 'string' ? response.message : API_ERROR_MESSAGES.unexpected, }) } async function fetchAuthToken() { try { const authState = useAuthStore.getState() if ( authState.apiAuthToken && authState.apiAuthTokenExpiresAt && authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS ) { return authState.apiAuthToken } if (!authSecret) { throw new ApiError({ message: 'auth.errors.authTokenConfigMissing', }) } const deviceId = getAuthDeviceId() const timestamp = Math.floor(Date.now() / 1000) const signature = md5( `device_id=${deviceId}&secret=${authSecret}×tamp=${timestamp}`, ).toUpperCase() const response = await authTokenClient .get(AUTH_TOKEN_ENDPOINT, { searchParams: { device_id: deviceId, secret: authSecret, signature, timestamp: String(timestamp), }, }) .json>() const data = unwrapEnvelopeData(response) const expiresAt = Date.now() + data.expires_in * 1000 useAuthStore.getState().setApiAuthToken({ expiresAt, serverTime: data.server_time, value: data.auth_token, }) return data.auth_token } catch (error) { throw await toApiError(error) } } export async function prefetchAuthToken() { await fetchAuthToken() } function createHeaders(headersInit?: Options['headers']) { const headers = new Headers() if (!headersInit) { return headers } if (headersInit instanceof Headers) { headersInit.forEach((value, key) => { headers.set(key, value) }) return headers } if (Array.isArray(headersInit)) { for (const [key, value] of headersInit) { headers.set(key, value) } return headers } for (const [key, value] of Object.entries(headersInit)) { if (typeof value === 'string') { headers.set(key, value) } } return headers } async function buildRequestOptions(input: string, options?: Options) { const headers = createHeaders(options?.headers) if (shouldAttachAuthToken(input) && !headers.has(REQUEST_HEADERS.authToken)) { headers.set(REQUEST_HEADERS.authToken, await fetchAuthToken()) } return { ...options, headers, } satisfies Options } async function request(input: string, options?: Options) { try { if (shouldTryRefreshAccessToken(input, options)) { await tryRefreshAuthSession() } const response = await apiClient( input, await buildRequestOptions(input, options), ) const data = await parseResponseBody(response) assertValidAuthEnvelope(data) return data as TResponse } catch (error) { if ( error instanceof HTTPError && error.response.status === HTTP_STATUS.unauthorized && input !== AUTH_REFRESH_ENDPOINT && options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true && options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true ) { const refreshed = await tryRefreshAuthSession() if (refreshed) { return request(input, { ...options, context: { ...options?.context, [AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY]: true, }, }) } } if ( error instanceof HTTPError && error.response.status === HTTP_STATUS.unauthorized ) { handleUnauthorizedSession() } throw await toApiError(error) } } async function requestRest(input: string, options?: Options) { return request>(input, options) } export const api = { get(input: string, options?: RequestOptions) { return requestRest(input, { ...options, method: 'get', }) }, post( input: string, { json, ...options }: JsonRequestOptions = {}, ) { return requestRest(input, { ...options, json, method: 'post', }) }, put( input: string, { json, ...options }: JsonRequestOptions = {}, ) { return requestRest(input, { ...options, json, method: 'put', }) }, patch( input: string, { json, ...options }: JsonRequestOptions = {}, ) { return requestRest(input, { ...options, json, method: 'patch', }) }, delete(input: string, options?: RequestOptions) { return requestRest(input, { ...options, method: 'delete', }) }, }