Files
sam-react-prod/src/lib/api/fetch-wrapper.ts

281 lines
9.2 KiB
TypeScript
Raw Normal View History

/**
* Fetch Wrapper
*
* Server Actions에서 fetch
* - 401
* -
* -
*/
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createErrorResponse, type ApiErrorResponse } from './errors';
import { refreshAccessToken } from './refresh-token';
/**
* ( )
*
* 중요: redirect('/login')
* "인증됨"
*/
async function clearTokenCookies() {
const cookieStore = await cookies();
// 토큰 쿠키 삭제
cookieStore.delete('access_token');
cookieStore.delete('refresh_token');
cookieStore.delete('token_refreshed_at');
cookieStore.delete('is_authenticated');
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료 (무한 루프 방지)');
}
/**
*
*/
async function setNewTokenCookies(tokens: {
accessToken?: string;
refreshToken?: string;
expiresIn?: number;
}) {
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production';
if (tokens.accessToken) {
cookieStore.set('access_token', tokens.accessToken, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: tokens.expiresIn || 7200,
});
// 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용)
// HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함
cookieStore.set('token_refreshed_at', Date.now().toString(), {
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60, // 1분 후 자동 삭제
});
console.log('🔔 [setNewTokenCookies] token_refreshed_at 신호 쿠키 설정');
}
if (tokens.refreshToken) {
cookieStore.set('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 604800, // 7 days
});
}
}
/**
* API (Server Side)
*
* 🆕
* - pre-refresh request headers에 'x-refreshed-access-token'
* - Set-Cookie는 cookies()
* - request headers를
*/
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
// 🆕 미들웨어에서 전달한 새 토큰 먼저 확인
const headerStore = await headers();
const refreshedAccessToken = headerStore.get('x-refreshed-access-token');
const cookieStore = await cookies();
const accessToken = token || refreshedAccessToken || cookieStore.get('access_token')?.value;
// 디버깅: 어떤 토큰을 사용하는지 로그
if (refreshedAccessToken) {
console.log('🔵 [getServerApiHeaders] Using refreshed token from middleware headers');
}
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': accessToken ? `Bearer ${accessToken}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
/**
* Server Action용 Fetch Wrapper
*
* 🔄 :
* 1. access_token으로
* 2. 401 refresh_token으로
* 3.
* 4.
*
* @example
* ```typescript
* const { response, error } = await serverFetch(url, { method: 'GET' });
* if (error) return error; // 에러 응답 반환 (클라이언트에서 처리)
* // response 사용...
* ```
*/
export async function serverFetch(
url: string,
options?: RequestInit & {
skipAuthCheck?: boolean;
}
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
try {
// 🆕 미들웨어에서 전달한 새 refresh_token 먼저 확인
const headerStore = await headers();
const refreshedRefreshToken = headerStore.get('x-refreshed-refresh-token');
const cookieStore = await cookies();
const refreshToken = refreshedRefreshToken || cookieStore.get('refresh_token')?.value;
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
const isFormData = options?.body instanceof FormData;
const requestHeaders: HeadersInit = isFormData
? {
Accept: baseHeaders.Accept,
Authorization: baseHeaders.Authorization,
'X-API-KEY': baseHeaders['X-API-KEY'],
}
: baseHeaders;
let response = await fetch(url, {
...options,
headers: {
...requestHeaders,
...options?.headers,
},
cache: options?.cache || 'no-store',
});
// 🔄 401 응답 시 토큰 갱신 후 재시도
if (response.status === 401 && !options?.skipAuthCheck && refreshToken) {
console.log('🔄 [serverFetch] Got 401, attempting token refresh...');
const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch');
if (refreshResult.success && refreshResult.accessToken) {
console.log('✅ [serverFetch] Token refreshed, retrying original request...');
// 새 토큰을 쿠키에 저장
await setNewTokenCookies(refreshResult);
// 새 토큰으로 원래 요청 재시도
const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record<string, string>;
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
const newRequestHeaders: HeadersInit = isFormData
? {
Accept: newBaseHeaders.Accept,
Authorization: newBaseHeaders.Authorization,
'X-API-KEY': newBaseHeaders['X-API-KEY'],
}
: newBaseHeaders;
response = await fetch(url, {
...options,
headers: {
...newRequestHeaders,
...options?.headers,
},
cache: options?.cache || 'no-store',
});
console.log('🔵 [serverFetch] Retry response status:', response.status);
// 재시도도 401이면 로그인으로
if (response.status === 401) {
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
} else {
// 리프레시 실패 → 로그인 페이지로
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
} else if (response.status === 401 && !options?.skipAuthCheck) {
// refresh_token이 없는 경우
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
// 403 Forbidden
if (response.status === 403) {
console.warn(`[serverFetch] 403 Forbidden: ${url}`);
return {
response: null,
error: createErrorResponse(403, '접근 권한이 없습니다.', 'FORBIDDEN'),
};
}
return { response, error: null };
} catch (error) {
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함
if (isNextRedirectError(error)) throw error;
console.error(`[serverFetch] Network error: ${url}`, error);
return {
response: null,
error: createErrorResponse(500, '네트워크 오류가 발생했습니다.', 'NETWORK_ERROR'),
};
}
}
/**
* JSON
*/
export async function parseJsonResponse<T>(response: Response): Promise<T | null> {
try {
return await response.json();
} catch {
console.error('[parseJsonResponse] JSON 파싱 실패');
return null;
}
}
/**
* API (fetch + JSON )
*
* @example
* ```typescript
* const { data, error } = await serverApiCall<UserResponse>(url, { method: 'GET' });
* if (error) return error;
* return data;
* ```
*/
export async function serverApiCall<T>(
url: string,
options?: RequestInit
): Promise<{ data: T | null; error: ApiErrorResponse | null }> {
const { response, error } = await serverFetch(url, options);
if (error || !response) {
return { data: null, error };
}
if (!response.ok) {
const errorData = await parseJsonResponse<{ message?: string; code?: string }>(response);
return {
data: null,
error: createErrorResponse(
response.status,
errorData?.message || '요청 처리 중 오류가 발생했습니다.',
errorData?.code
),
};
}
// 204 No Content
if (response.status === 204) {
return { data: null, error: null };
}
const data = await parseJsonResponse<T>(response);
return { data, error: null };
}