Skip to content

Commit b8a3e6b

Browse files
authored
Merge pull request #58 from Nexters/fix/api-call-logic
fix: 액세스 토큰, api 호출 로직 수정
2 parents 4509daa + 49619c3 commit b8a3e6b

File tree

9 files changed

+271
-31
lines changed

9 files changed

+271
-31
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
'use client';
2+
import TokenProvider from '@/shared/components/TokenProvider';
3+
4+
export default function InvitesLayout({ children }: { children: React.ReactNode }) {
5+
return <TokenProvider>{children}</TokenProvider>;
6+
}

apps/tuk-web/src/app/invite/meet/[meetId]/src/service/proposal-api.service.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ export class ProposalAPIService {
55
constructor(private fetch: RestAPIProtocol) {}
66

77
getProposalDetail(proposalId: number) {
8-
throw new Error('error');
98
return this.fetch.get({
109
url: ':proposalId',
1110
param: {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
'use client';
3+
4+
import { createContext, useContext, useEffect, useRef, useState } from 'react';
5+
6+
import { readTokenOnce, waitToken } from '@/shared/lib/api/auth/token-gateway';
7+
8+
type Ctx = { token: string | null; ready: boolean; setToken: (t: string) => void };
9+
const TokenCtx = createContext<Ctx | null>(null);
10+
11+
export function useToken() {
12+
const ctx = useContext(TokenCtx);
13+
if (!ctx) throw new Error('TokenProvider missing');
14+
return ctx;
15+
}
16+
17+
export default function TokenProvider({ children }: { children: React.ReactNode }) {
18+
const [ready, setReady] = useState(false);
19+
const [token, setTokenState] = useState<string | null>(null);
20+
const mounted = useRef(false);
21+
22+
useEffect(() => {
23+
mounted.current = true;
24+
// 1) 최초 대기
25+
waitToken({ timeoutMs: 10000 })
26+
.then(t => {
27+
if (!mounted.current) return;
28+
setTokenState(t);
29+
setReady(true);
30+
})
31+
.catch(() => {
32+
// 타임아웃이어도 혹시 그 사이 들어왔는지 마지막 확인
33+
const t = readTokenOnce();
34+
setTokenState(t);
35+
setReady(true); // ready = true로 바꿔서 UI는 열지만, token 없으면 enabled=false로 쿼리 막힘
36+
});
37+
38+
// 2) 이후 네이티브 갱신 이벤트를 계속 반영
39+
const onEvent = (e: Event) => {
40+
const t = (e as CustomEvent).detail?.token as string | undefined;
41+
if (t) setTokenState(t);
42+
};
43+
window.addEventListener('native-token-refreshed', onEvent as any);
44+
return () => {
45+
mounted.current = false;
46+
window.removeEventListener('native-token-refreshed', onEvent as any);
47+
};
48+
}, []);
49+
50+
const setToken = (t: string) => {
51+
try {
52+
sessionStorage.setItem('accessToken', t);
53+
} catch {
54+
/* empty */
55+
}
56+
setTokenState(t);
57+
};
58+
59+
return (
60+
<TokenCtx.Provider value={{ token, ready, setToken }}>
61+
{/* ready=false면 아무것도 렌더하지 않음(최초 쿼리 발사 차단) */}
62+
{ready ? children : null}
63+
</TokenCtx.Provider>
64+
);
65+
}

apps/tuk-web/src/shared/lib/api/auth/refreshManager.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,24 @@ const state: RefreshState = {
1010
promise: null,
1111
};
1212

13+
/**
14+
* 네이티브에 토큰 갱신을 요청하고, 싱글플라이트로 새 토큰을 기다린다.
15+
* - 세션스토리지를 선삭제하지 않는다(레이스 생성 금지).
16+
* - 이미 갱신 중이면 기존 프로미스를 그대로 반환.
17+
*/
1318
export function requestTokenRefresh(trigger: () => void, timeoutMs?: number) {
1419
if (state.refreshing && state.promise) return state.promise;
1520

16-
sessionStorage.removeItem('accessToken');
17-
1821
state.refreshing = true;
19-
trigger();
22+
try {
23+
trigger(); // 네이티브에 "토큰 갱신" 요청
24+
} catch {
25+
// 트리거 실패해도 대기는 시도
26+
}
2027

21-
state.promise = waitForNativeToken(timeoutMs).finally(() => {
28+
state.promise = waitForNativeToken({ timeoutMs }).finally(() => {
2229
state.refreshing = false;
30+
// 바로 null로 만들면 동시 tick에서 참조 레이스가 있을 수 있어 다음 마이크로태스크로 밀기
2331
setTimeout(() => {
2432
state.promise = null;
2533
}, 0);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
declare global {
3+
interface Window {
4+
Android?: { getAccessToken?: () => string | null | undefined };
5+
}
6+
}
7+
8+
type WaitOpts = { timeoutMs?: number; pollMs?: number };
9+
10+
export function readTokenOnce(): string | null {
11+
if (typeof window === 'undefined') return null;
12+
try {
13+
const b = window.Android?.getAccessToken?.();
14+
if (b) return String(b);
15+
} catch {
16+
/* empty */
17+
}
18+
try {
19+
const s = window.sessionStorage.getItem('accessToken');
20+
if (s) return s;
21+
} catch {
22+
/* empty */
23+
}
24+
return null;
25+
}
26+
27+
export function waitToken({ timeoutMs = 10000, pollMs = 30 }: WaitOpts = {}): Promise<string> {
28+
return new Promise((resolve, reject) => {
29+
if (typeof window === 'undefined') return reject(new Error('SSR'));
30+
let done = false;
31+
const finish = (t: string) => {
32+
if (!done) {
33+
done = true;
34+
cleanup();
35+
resolve(t);
36+
}
37+
};
38+
const fail = (e: Error) => {
39+
if (!done) {
40+
done = true;
41+
cleanup();
42+
reject(e);
43+
}
44+
};
45+
46+
const onEvent = (e: Event) => {
47+
const t = (e as CustomEvent).detail?.token;
48+
if (t) finish(String(t));
49+
};
50+
window.addEventListener('native-token-refreshed', onEvent as any, { once: true });
51+
52+
const immediate = readTokenOnce();
53+
if (immediate) return finish(immediate);
54+
55+
const poller = setInterval(() => {
56+
const t = readTokenOnce();
57+
if (t) finish(t);
58+
}, pollMs);
59+
60+
const timer = setTimeout(() => fail(new Error('Token timeout')), timeoutMs);
61+
62+
function cleanup() {
63+
clearInterval(poller);
64+
clearTimeout(timer);
65+
window.removeEventListener('native-token-refreshed', onEvent as any);
66+
}
67+
});
68+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
declare global {
3+
interface Window {
4+
Android?: { getAccessToken?: () => string | null | undefined };
5+
}
6+
}
7+
8+
/** 네이티브 브리지 → 세션스토리지 순으로 토큰을 읽는다 */
9+
export function readAccessToken(): string | null {
10+
if (typeof window === 'undefined') return null;
11+
try {
12+
const viaBridge = window.Android?.getAccessToken?.();
13+
if (viaBridge) return String(viaBridge);
14+
} catch {
15+
/* empty */
16+
}
17+
try {
18+
const viaSession = window.sessionStorage.getItem('accessToken');
19+
if (viaSession) return viaSession;
20+
} catch {
21+
/* empty */
22+
}
23+
return null;
24+
}
25+
26+
/** Authorization 헤더 생성 (없으면 빈 객체) */
27+
export function makeAuthHeader(): { Authorization: string } | Record<string, never> {
28+
const t = readAccessToken();
29+
return t ? { Authorization: `Bearer ${t}` } : {};
30+
}
Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,93 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22

3-
export function waitForNativeToken(timeoutMs = 10000): Promise<string> {
3+
declare global {
4+
interface Window {
5+
Android?: { getAccessToken?: () => string | null | undefined };
6+
}
7+
}
8+
9+
type WaitTokenOptions = {
10+
timeoutMs?: number; // default 10s
11+
pollMs?: number; // default 30ms
12+
};
13+
14+
/**
15+
* 네이티브 이벤트(native-token-refreshed) + 브리지 + sessionStorage를
16+
* 동시에 기다렸다가, 가장 먼저 들어오는 토큰을 반환한다.
17+
*/
18+
export function waitForNativeToken(opts: WaitTokenOptions = {}): Promise<string> {
19+
const { timeoutMs = 10000, pollMs = 30 } = opts;
20+
421
return new Promise((resolve, reject) => {
5-
const onToken = (e: Event) => {
6-
const token = (e as CustomEvent).detail?.token ?? sessionStorage.getItem('accessToken');
7-
cleanup();
8-
if (token) resolve(token);
9-
else reject(new Error('Token event received but empty'));
22+
if (typeof window === 'undefined') {
23+
reject(new Error('window is undefined (SSR)'));
24+
return;
25+
}
26+
27+
let done = false;
28+
let timer: ReturnType<typeof setTimeout> | null = null;
29+
let poller: ReturnType<typeof setInterval> | null = null;
30+
31+
const cleanup = () => {
32+
if (timer) clearTimeout(timer);
33+
if (poller) clearInterval(poller);
34+
window.removeEventListener('native-token-refreshed', onEvent as any);
1035
};
11-
const onTimeout = () => {
36+
37+
const resolveOnce = (token: string) => {
38+
if (done) return;
39+
done = true;
1240
cleanup();
13-
reject(new Error('Token refresh timeout'));
41+
resolve(token);
1442
};
15-
const cleanup = () => {
16-
window.removeEventListener('native-token-refreshed', onToken as any);
17-
clearTimeout(timer);
43+
44+
const rejectOnce = (err: Error) => {
45+
if (done) return;
46+
done = true;
47+
cleanup();
48+
reject(err);
1849
};
1950

20-
window.addEventListener('native-token-refreshed', onToken as any, { once: true });
51+
// 1) 이벤트 리스너 (네이티브가 새 토큰을 던져줌)
52+
const onEvent = (e: Event) => {
53+
const token = (e as CustomEvent).detail?.token ?? null;
54+
if (token) resolveOnce(String(token));
55+
};
56+
window.addEventListener('native-token-refreshed', onEvent as any, { once: true });
2157

22-
const existing = sessionStorage.getItem('accessToken');
23-
if (existing) {
24-
cleanup();
25-
resolve(existing);
26-
return;
58+
// 2) 즉시 가용 토큰 체크 (브리지 → 세션스토리지 순)
59+
try {
60+
const viaBridge = window.Android?.getAccessToken?.();
61+
if (viaBridge) return resolveOnce(String(viaBridge));
62+
} catch {
63+
/* empty */
2764
}
65+
try {
66+
const viaSession = window.sessionStorage.getItem('accessToken');
67+
if (viaSession) return resolveOnce(viaSession);
68+
} catch {
69+
/* empty */
70+
}
71+
72+
// 3) 폴링 (브리지/세션스토리지)
73+
poller = setInterval(() => {
74+
try {
75+
const br = window.Android?.getAccessToken?.();
76+
if (br) return resolveOnce(String(br));
77+
} catch {
78+
/* empty */
79+
}
80+
try {
81+
const ss = window.sessionStorage.getItem('accessToken');
82+
if (ss) return resolveOnce(ss);
83+
} catch {
84+
/* empty */
85+
}
86+
}, pollMs);
2887

29-
const timer = setTimeout(onTimeout, timeoutMs);
88+
// 4) 타임아웃
89+
timer = setTimeout(() => {
90+
rejectOnce(new Error('Token refresh timeout'));
91+
}, timeoutMs);
3092
});
3193
}

apps/tuk-web/src/shared/lib/api/rest/index.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RestAPI, RestAPIInstance } from './rest';
22
import type { RestAPIConfig } from './types';
33

4+
import { makeAuthHeader } from '@/shared/lib/api/auth/token';
45
import { getBridgeSender } from '@/shared/lib/app-bridge/bridgeSender';
56

67
export { RestAPI } from './rest';
@@ -9,12 +10,14 @@ export type { RestAPIConfig, RestAPIProtocol } from './types';
910
const defaultJsonInstance = (baseURL: string) =>
1011
new RestAPIInstance(baseURL, {
1112
withCredentials: false,
12-
authHeader: () => {
13-
if (typeof window === 'undefined') return null;
14-
const token = sessionStorage.getItem('accessToken');
15-
return token ? { Authorization: `Bearer ${token}` } : null;
16-
},
17-
bridgeSend: msg => getBridgeSender()?.(msg),
13+
// authHeader: () => {
14+
// if (typeof window === 'undefined') return null;
15+
// const token = sessionStorage.getItem('accessToken');
16+
// return token ? { Authorization: `Bearer ${token}` } : null;
17+
// },
18+
authHeader: () => makeAuthHeader(),
19+
headers: { Accept: 'application/json' },
20+
// bridgeSend: msg => getBridgeSender()?.(msg),
1821
});
1922

2023
// const defaultJsonInstance = (baseURL: string) =>

apps/tuk-web/src/shared/lib/api/rest/rest.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,6 @@ export class RestAPI implements RestAPIProtocol {
138138
const res = await this.instance.request(fullUrl, reqInit);
139139
if (!res.ok) {
140140
if (res.status === 401) {
141-
sessionStorage.removeItem('accessToken');
142-
143141
const bridge = this.instance.getBridgeSend();
144142
if (bridge) {
145143
try {
@@ -158,6 +156,7 @@ export class RestAPI implements RestAPIProtocol {
158156
...reqInit,
159157
headers: refreshedHdr,
160158
});
159+
161160
if (!retry.ok) {
162161
const text = await retry.text().catch(() => '');
163162
throw new APIError(text || retry.statusText, {

0 commit comments

Comments
 (0)