diff --git a/src/apis/client.ts b/src/apis/client.ts index fcb2734..5691be0 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -2,6 +2,7 @@ import { refreshAccessToken } from '@/apis/auth'; import type { ApiError, ApiErrorResponse } from '@/interface/error'; import { useAuthStore } from '@/stores/authStore'; import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; +import { postNativeMessage } from '@/utils/ts/nativeBridge'; const BASE_URL = import.meta.env.VITE_API_PATH; @@ -19,8 +20,6 @@ interface FetchOptions

> exten requiresAuth?: boolean; } -let refreshPromise: Promise | null = null; - export const apiClient = { get: >( endPoint: string, @@ -128,23 +127,62 @@ function buildQuery(params: Record) { return usp.toString(); } -async function sendRequest>( - endPoint: string, - options: FetchOptions

= {}, - timeout: number = 10000 -): Promise { +function buildUrl(endPoint: string, params?: Record): string { + let url = joinUrl(BASE_URL, endPoint); + if (params && Object.keys(params).length > 0) { + const query = buildQuery(params); + if (query) url += `?${query}`; + } + return url; +} + +function buildFetchOptions

( + options: FetchOptions

& { method: string }, + abortSignal: AbortSignal +): RequestInit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { headers, body, method, params, requiresAuth, ...restOptions } = options; - if (!method) { - throw new Error('HTTP method가 설정되지 않았습니다.'); + const isPlainObjectOrArray = + body !== undefined && + body !== null && + typeof body === 'object' && + (Array.isArray(body) || body.constructor === Object); + + const h: Record = { + ...(isPlainObjectOrArray ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }; + + if (requiresAuth) { + const accessToken = useAuthStore.getState().getAccessToken(); + if (accessToken) { + h['Authorization'] = `Bearer ${accessToken}`; + } } - let url = joinUrl(BASE_URL, endPoint); - if (params && Object.keys(params).length > 0) { - const query = buildQuery(params as Record); - if (query) url += `?${query}`; + const fetchOpts: RequestInit = { + headers: h, + method, + signal: abortSignal, + credentials: 'include', + ...restOptions, + }; + + if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { + fetchOpts.body = isPlainObjectOrArray ? JSON.stringify(body) : (body as BodyInit); } + return fetchOpts; +} + +async function executeFetch

( + endPoint: string, + options: FetchOptions

& { method: string }, + timeout: number +): Promise<{ response: Response; timeoutId: ReturnType }> { + const url = buildUrl(endPoint, options.params as Record | undefined); + const abortController = new AbortController(); let didTimeout = false; const timeoutId = setTimeout(() => { @@ -152,44 +190,38 @@ async function sendRequest => { - const h: Record = { - ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }; + try { + const fetchOpts = buildFetchOptions(options, abortController.signal); + const response = await fetch(url, fetchOpts); + return { response, timeoutId }; + } catch (error) { + clearTimeout(timeoutId); + rethrowFetchError(error, url, didTimeout); + } +} - if (requiresAuth) { - const accessToken = useAuthStore.getState().getAccessToken(); - if (accessToken) { - h['Authorization'] = `Bearer ${accessToken}`; - } - } +async function sendRequest>( + endPoint: string, + options: FetchOptions

= {}, + timeout: number = 10000, + allowRetry: boolean = true +): Promise { + const { method } = options; - return h; - }; + if (!method) { + throw new Error('HTTP method가 설정되지 않았습니다.'); + } - try { - const fetchOptions: RequestInit = { - headers: buildHeaders(), - method, - signal: abortController.signal, - credentials: 'include', - ...restOptions, - }; - - if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = - typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData) - ? JSON.stringify(body) - : (body as BodyInit); - } + const { response, timeoutId } = await executeFetch

( + endPoint, + options as FetchOptions

& { method: string }, + timeout + ); - const response = await fetch(url, fetchOptions); + const url = response.url; - if (response.status === 401 && requiresAuth) { - clearTimeout(timeoutId); + try { + if (response.status === 401 && options.requiresAuth && allowRetry) { return await handleUnauthorized(endPoint, options, timeout); } @@ -197,9 +229,12 @@ async function sendRequest(response); + return await parseResponse(response); } catch (error) { - rethrowFetchError(error, url, didTimeout); + if (error instanceof Error && error.name === 'AbortError') { + rethrowFetchError(error, url, true); + } + throw error; } finally { clearTimeout(timeoutId); } @@ -213,98 +248,16 @@ async function handleUnauthorized(endPoint, options, timeout); -} - -async function sendRequestWithoutRetry>( - endPoint: string, - options: FetchOptions

= {}, - timeout: number = 10000 -): Promise { - const { headers, body, method, params, requiresAuth, ...restOptions } = options; - - if (!method) { - throw new Error('HTTP method가 설정되지 않았습니다.'); - } - - let url = joinUrl(BASE_URL, endPoint); - if (params && Object.keys(params).length > 0) { - const query = buildQuery(params as Record); - if (query) url += `?${query}`; - } - - const abortController = new AbortController(); - let didTimeout = false; - const timeoutId = setTimeout(() => { - didTimeout = true; - abortController.abort(); - }, timeout); - - const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); - - try { - const h: Record = { - ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }; - - if (requiresAuth) { - const accessToken = useAuthStore.getState().getAccessToken(); - if (accessToken) { - h['Authorization'] = `Bearer ${accessToken}`; - } - } - - const fetchOptions: RequestInit = { - headers: h, - method, - signal: abortController.signal, - credentials: 'include', - ...restOptions, - }; - - if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = - typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData) - ? JSON.stringify(body) - : (body as BodyInit); - } - - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - return await throwApiError(response); - } - - return parseResponse(response); - } catch (error) { - rethrowFetchError(error, url, didTimeout); - } finally { - clearTimeout(timeoutId); - } + return await sendRequest(endPoint, options, timeout, false); } async function parseErrorResponse(response: Response): Promise { @@ -320,16 +273,28 @@ async function parseErrorResponse(response: Response): Promise(response: Response): Promise { + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + return null as unknown as T; + } + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { try { return await response.json(); } catch { - return {} as T; + const error = new Error('응답 JSON 파싱에 실패했습니다.') as ApiError; + error.name = 'ParseError'; + error.status = response.status; + error.statusText = response.statusText; + error.url = response.url; + throw error; } - } else if (contentType.includes('text')) { + } + + if (contentType.includes('text')) { return (await response.text()) as unknown as T; - } else { - return null as unknown as T; } + + return null as unknown as T; } diff --git a/src/components/notification/hooks/useInboxNotificationStream.ts b/src/components/notification/hooks/useInboxNotificationStream.ts index b1b0939..a50874b 100644 --- a/src/components/notification/hooks/useInboxNotificationStream.ts +++ b/src/components/notification/hooks/useInboxNotificationStream.ts @@ -4,6 +4,7 @@ import type { InboxNotification } from '@/apis/notification/entity'; import { useAuthStore } from '@/stores/authStore'; import { getAccessTokenExpirationTime } from '@/utils/ts/accessToken'; import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; +import { postNativeMessage } from '@/utils/ts/nativeBridge'; import { NORMALIZED_API_BASE_URL } from '@/utils/ts/oauth'; const ACCESS_TOKEN_REFRESH_BUFFER_MS = 60_000; @@ -60,16 +61,6 @@ function parseSseEvent(chunk: string): { event: string | null; data: string | nu }; } -function syncAccessTokenToNative(accessToken: string) { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'TOKEN_REFRESH', accessToken })); - } - } catch { - // 브릿지 전달 실패가 인증 흐름을 중단시키지 않도록 무시 - } -} - async function openInboxNotificationStream( signal: AbortSignal, onConnected: () => void, @@ -106,7 +97,7 @@ async function openInboxNotificationStream( try { const nextAccessToken = await refreshAccessToken(); useAuthStore.getState().setAccessToken(nextAccessToken); - syncAccessTokenToNative(nextAccessToken); + postNativeMessage({ type: 'TOKEN_REFRESH', accessToken: nextAccessToken }); } catch { useAuthStore.getState().clearAuth(); throw new Error('인증이 만료되었습니다.'); @@ -224,7 +215,7 @@ export function useInboxNotificationStream( } useAuthStore.getState().setAccessToken(nextAccessToken); - syncAccessTokenToNative(nextAccessToken); + postNativeMessage({ type: 'TOKEN_REFRESH', accessToken: nextAccessToken }); } catch { if (isCancelled || useAuthStore.getState().getAccessToken() !== accessToken) { return; diff --git a/src/pages/User/MyPage/hooks/useLogout.ts b/src/pages/User/MyPage/hooks/useLogout.ts index e499765..415f8d9 100644 --- a/src/pages/User/MyPage/hooks/useLogout.ts +++ b/src/pages/User/MyPage/hooks/useLogout.ts @@ -10,13 +10,6 @@ export const useLogoutMutation = () => { return useMutation({ ...authMutations.logout(), onSuccess: () => { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'LOGOUT' })); - } - } catch { - // 브릿지 전달 실패가 로그아웃 흐름을 중단시키지 않도록 무시 - } clearAuth(); navigate('/'); }, diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index b095242..508548b 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { getMyInfo, refreshAccessToken } from '@/apis/auth'; import type { MyInfoResponse } from '@/apis/auth/entity'; import { isAccessTokenExpired } from '@/utils/ts/accessToken'; +import { postNativeMessage } from '@/utils/ts/nativeBridge'; let initializePromise: Promise | null = null; let hydrateUserPromise: Promise | null = null; @@ -19,15 +20,7 @@ const hydrateUser = async (nextAccessToken: string) => { useAuthStore.setState({ user: nextUser }); - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage( - JSON.stringify({ type: 'LOGIN_COMPLETE', accessToken: nextAccessToken }) - ); - } - } catch { - // 브릿지 전달 실패가 인증 성공 상태를 롤백시키지 않도록 무시 - } + postNativeMessage({ type: 'LOGIN_COMPLETE', accessToken: nextAccessToken }); } catch { if (useAuthStore.getState().accessToken !== nextAccessToken) return; @@ -95,7 +88,7 @@ export const useAuthStore = create((set, get) => ({ set({ accessToken: nextAccessToken, authStatus: 'authenticated' }); void hydrateUser(nextAccessToken); } catch { - set({ user: null, accessToken: null, authStatus: 'anonymous' }); + get().clearAuth(); } finally { initializePromise = null; } @@ -122,5 +115,6 @@ export const useAuthStore = create((set, get) => ({ initializePromise = null; hydrateUserPromise = null; set({ user: null, accessToken: null, authStatus: 'anonymous' }); + postNativeMessage({ type: 'LOGOUT' }); }, })); diff --git a/src/utils/ts/nativeBridge.ts b/src/utils/ts/nativeBridge.ts new file mode 100644 index 0000000..2108720 --- /dev/null +++ b/src/utils/ts/nativeBridge.ts @@ -0,0 +1,12 @@ +type BridgeMessage = + | { type: 'TOKEN_REFRESH'; accessToken: string } + | { type: 'LOGIN_COMPLETE'; accessToken: string } + | { type: 'LOGOUT' }; + +export function postNativeMessage(message: BridgeMessage): void { + try { + window.ReactNativeWebView?.postMessage(JSON.stringify(message)); + } catch { + // 브릿지 전달 실패가 앱 흐름을 중단시키지 않도록 무시 + } +}