Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 102 additions & 137 deletions src/apis/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,8 +20,6 @@ interface FetchOptions<P extends object = Record<string, QueryParamValue>> exten
requiresAuth?: boolean;
}

let refreshPromise: Promise<string> | null = null;

export const apiClient = {
get: <T = unknown, P extends object = Record<string, QueryParamValue>>(
endPoint: string,
Expand Down Expand Up @@ -128,78 +127,114 @@ function buildQuery(params: Record<string, QueryParamValue>) {
return usp.toString();
}

async function sendRequest<T = unknown, P extends object = Record<string, QueryParamValue>>(
endPoint: string,
options: FetchOptions<P> = {},
timeout: number = 10000
): Promise<T> {
function buildUrl(endPoint: string, params?: Record<string, QueryParamValue>): 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<P extends object>(
options: FetchOptions<P> & { 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<string, string> = {
...(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<string, QueryParamValue>);
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<P extends object>(
endPoint: string,
options: FetchOptions<P> & { method: string },
timeout: number
): Promise<{ response: Response; timeoutId: ReturnType<typeof setTimeout> }> {
const url = buildUrl(endPoint, options.params as Record<string, QueryParamValue> | undefined);

const abortController = new AbortController();
let didTimeout = false;
const timeoutId = setTimeout(() => {
didTimeout = true;
abortController.abort();
}, timeout);

const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData);

const buildHeaders = (): Record<string, string> => {
const h: Record<string, string> = {
...(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<T = unknown, P extends object = Record<string, QueryParamValue>>(
endPoint: string,
options: FetchOptions<P> = {},
timeout: number = 10000,
allowRetry: boolean = true
): Promise<T> {
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<P>(
endPoint,
options as FetchOptions<P> & { 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<T, P>(endPoint, options, timeout);
}

if (!response.ok) {
return await throwApiError(response);
}

return parseResponse<T>(response);
return await parseResponse<T>(response);
} catch (error) {
rethrowFetchError(error, url, didTimeout);
if (error instanceof Error && error.name === 'AbortError') {
rethrowFetchError(error, url, true);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
Expand All @@ -213,98 +248,16 @@ async function handleUnauthorized<T = unknown, P extends object = Record<string,
let newAccessToken: string;

try {
if (!refreshPromise) {
refreshPromise = refreshAccessToken();
}
newAccessToken = await refreshPromise;
newAccessToken = await refreshAccessToken();
} catch {
// refresh 실패 → 인증 만료, 로그아웃 처리
useAuthStore.getState().clearAuth();
throw new Error('인증이 만료되었습니다.');
} finally {
refreshPromise = null;
}

useAuthStore.getState().setAccessToken(newAccessToken);
postNativeMessage({ type: 'TOKEN_REFRESH', accessToken: newAccessToken });

try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'TOKEN_REFRESH', accessToken: newAccessToken }));
}
} catch {
// 브릿지 전달 실패가 인증 흐름을 중단시키지 않도록 무시
}

// retry 실패는 그대로 throw (로그아웃 처리 안 함)
return await sendRequestWithoutRetry<T, P>(endPoint, options, timeout);
}

async function sendRequestWithoutRetry<T = unknown, P extends object = Record<string, QueryParamValue>>(
endPoint: string,
options: FetchOptions<P> = {},
timeout: number = 10000
): Promise<T> {
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<string, QueryParamValue>);
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<string, string> = {
...(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<T>(response);
} catch (error) {
rethrowFetchError(error, url, didTimeout);
} finally {
clearTimeout(timeoutId);
}
return await sendRequest<T, P>(endPoint, options, timeout, false);
}

async function parseErrorResponse(response: Response): Promise<ApiErrorResponse | null> {
Expand All @@ -320,16 +273,28 @@ async function parseErrorResponse(response: Response): Promise<ApiErrorResponse
}

async function parseResponse<T = unknown>(response: Response): Promise<T> {
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;
}
15 changes: 3 additions & 12 deletions src/components/notification/hooks/useInboxNotificationStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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('인증이 만료되었습니다.');
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 0 additions & 7 deletions src/pages/User/MyPage/hooks/useLogout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
},
Expand Down
Loading
Loading