Skip to content
Open
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
31 changes: 14 additions & 17 deletions apps/app/components/ShareBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,50 +116,47 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) {
return;
}

const { wish, item, accessToken } = result;
const { wish, accessToken } = result;

/** 응답 body 를 못 읽으면 파싱 안내 없이 기존 성공 화면 */
if (!wish || !item) {
if (!wish) {
setSheetState({ status: 'success' });
return;
}

/** reused 등으로 이미 종결된 아이템 — SSE 생략 */
if (applyParsedItem(item, wish.id)) return;

/** PENDING·PROCESSING — 로딩을 유지한 채 파싱 결과를 기다린다 */
const sseResult = await subscribeItemParsingFromShare({
itemId: item.id,
/** PARSING — 로딩을 유지한 채 파싱 결과를 기다린다 */
const { status: sseStatus, wishId: resultWishId } = await subscribeItemParsingFromShare({
wishId: wish.id,
accessToken,
signal: abortController.signal,
});

if (!isMounted) return;

if (sseResult === 'READY') {
if (sseStatus === 'READY' || sseStatus === 'MERGED') {
/** SSE payload 에는 상품명·가격이 없어 상세를 재조회해 카드를 채운다 */
const detailItem = await getWishFromShare(wish.id, accessToken);
const detailItem = await getWishFromShare(resultWishId, accessToken);

if (!isMounted) return;

if (detailItem && applyParsedItem(detailItem, wish.id)) return;
if (detailItem && applyParsedItem(detailItem, resultWishId)) return;
/** 재조회 실패 — 저장은 확정이므로 기존 성공 화면으로 폴백 */
setSheetState({ status: 'success', wishId: wish.id });
setSheetState({ status: 'success', wishId: resultWishId });
return;
}

if (sseResult === 'INCOMPLETE') {
setSheetState({ status: 'incomplete', wishId: wish.id });
if (sseStatus === 'INCOMPLETE') {
setSheetState({ status: 'incomplete', wishId: resultWishId });
return;
}

if (sseResult === 'FAILED') {
setSheetState({ status: 'failed', wishId: wish.id });
if (sseStatus === 'FAILED') {
setSheetState({ status: 'failed', wishId: resultWishId });
return;
}

/** 타임아웃·연결 실패 — 기존 성공 화면 폴백 */
setSheetState({ status: 'success', wishId: wish.id });
setSheetState({ status: 'success', wishId: resultWishId });
};

void registerWish();
Expand Down
31 changes: 8 additions & 23 deletions apps/app/utils/postWishLinkFromShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { TokenStorage } from './tokenStorage';
*/
export type ShareFailureReasonT = 'unauthenticated' | 'sessionExpired' | 'network' | 'server';

const SHARE_ITEM_STATUSES = ['PENDING', 'PROCESSING', 'READY', 'INCOMPLETE', 'FAILED'] as const;
const SHARE_ITEM_STATUSES = ['PARSING', 'READY', 'INCOMPLETE', 'FAILED'] as const;

export type ShareItemStatusT = (typeof SHARE_ITEM_STATUSES)[number];

Expand All @@ -24,7 +24,7 @@ export type ShareWishT = {
};

export type ShareItemT = {
id: number;
id: number | null;
status: ShareItemStatusT;
name: string | null;
price: number | null;
Expand All @@ -37,7 +37,6 @@ export type PostWishLinkFromShareResultT =
ok: true;
/** 응답 body 를 못 읽으면 null — 저장은 성공이므로 시트는 기존 성공 화면으로 폴백 */
wish: ShareWishT | null;
item: ShareItemT | null;
/** refresh 까지 끝난 시점의 토큰 — SSE 구독·위시 재조회에 재사용 */
accessToken: string;
}
Expand Down Expand Up @@ -76,24 +75,15 @@ const readErrorCode = async (response: Response): Promise<string | null> => {
};

/**
* 성공 응답에서 wish·item 을 뽑는다. 모양이 어긋나면 null —
* 성공 응답에서 wish 를 뽑는다. 모양이 어긋나면 null —
* 저장 자체는 성공이므로 호출부는 파싱 안내 없이 기존 성공 화면으로 폴백한다.
*/
const readSuccessBody = async (
response: Response
): Promise<{ wish: ShareWishT; item: ShareItemT } | null> => {
const readSuccessBody = async (response: Response): Promise<ShareWishT | null> => {
try {
const body = (await response.json()) as {
data?: { wish?: ShareWishT | null; item?: ShareItemT | null } | null;
};
const body = (await response.json()) as { data?: { wish?: ShareWishT | null } | null };
const wish = body.data?.wish;
const item = body.data?.item;

if (typeof wish?.id !== 'number' || typeof item?.id !== 'number') return null;
/** 모르는 status 는 종결 여부를 판단할 수 없다 — SSE 로 넘기지 않고 성공 폴백 */
if (!isShareItemStatus(item.status)) return null;

return { wish, item };
return typeof wish?.id === 'number' ? wish : null;
} catch {
return null;
}
Expand Down Expand Up @@ -146,14 +136,9 @@ export const postWishLinkFromShare = async (
return { ok: false, reason, retryable: reason === 'server', ...(code ? { code } : {}) };
}

const successBody = await readSuccessBody(postWishResponse);
const wish = await readSuccessBody(postWishResponse);

return {
ok: true,
wish: successBody?.wish ?? null,
item: successBody?.item ?? null,
accessToken: activeToken,
};
return { ok: true, wish, accessToken: activeToken };
} catch {
return { ok: false, reason: 'network', retryable: true };
}
Expand Down
42 changes: 31 additions & 11 deletions apps/app/utils/subscribeItemParsingFromShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,32 @@ import type { ShareItemStatusT } from './postWishLinkFromShare';
* SSE 구독 결과. `FALLBACK` 은 타임아웃·연결 실패·abort 등 —
* 저장은 이미 성공한 상태라 호출부는 기존 성공 화면으로 조용히 폴백한다.
*/
export type ItemParsingSseResultT =
type ItemParsingSseStatusT =
| Extract<ShareItemStatusT, 'READY' | 'INCOMPLETE' | 'FAILED'>
| 'MERGED'
| 'FALLBACK';

type ItemParsingSseResultT = {
status: ItemParsingSseStatusT;
/** 결과 화면에 보여 줄 위시. MERGED인 경우, 살아남은 기존 wishId */
wishId: number;
};

/** 시트가 로딩에 고착되지 않도록 이벤트가 없으면 성공 화면으로 폴백 */
const SSE_TIMEOUT_MS = 10_000;

const PARSING_EVENT_TO_RESULT: Record<string, ItemParsingSseResultT> = {
const PARSING_EVENT_TO_STATUS: Record<string, ItemParsingSseStatusT> = {
ITEM_PARSING_COMPLETED: 'READY',
ITEM_PARSING_INCOMPLETE: 'INCOMPLETE',
ITEM_PARSING_FAILED: 'FAILED',
ITEM_PARSING_MERGED: 'MERGED',
};

type NotificationSsePayloadT = {
type?: string;
kind?: string;
wishId?: number | null;
/** MERGED 에서는 합쳐져 사라진 요청 위시 id */
refId?: number;
};

Expand Down Expand Up @@ -106,8 +116,8 @@ const concatBytes = (left: Uint8Array, right: Uint8Array): Uint8Array => {
};

type SubscribeItemParsingParamsT = {
/** 이 아이템의 ITEM_PARSING_* 알림만 소비한다 (refId === itemId) */
itemId: number;
/** 이 위시의 ITEM_PARSING_* 알림만 소비한다 */
wishId: number;
/** postWishLinkFromShare 가 refresh 까지 끝낸 시점의 토큰 */
accessToken: string;
/** 시트 close·호스트 앱 이동 시 연결을 즉시 끊기 위한 신호 */
Expand All @@ -120,11 +130,13 @@ type SubscribeItemParsingParamsT = {
* SSE 는 보조 채널 — 어떤 오류(연결 실패·401·타임아웃·abort)든 throw 없이 FALLBACK 을 반환한다.
*/
export const subscribeItemParsingFromShare = async ({
itemId,
wishId,
accessToken,
signal,
timeoutMs = SSE_TIMEOUT_MS,
}: SubscribeItemParsingParamsT): Promise<ItemParsingSseResultT> => {
const fallback: ItemParsingSseResultT = { status: 'FALLBACK', wishId };

const controller = new AbortController();
const abortConnection = () => controller.abort();

Expand All @@ -144,14 +156,14 @@ export const subscribeItemParsingFromShare = async ({
}
);

if (!response.ok || !response.body) return 'FALLBACK';
if (!response.ok || !response.body) return fallback;

const reader = response.body.getReader();
let buffer: Uint8Array = new Uint8Array(0);

while (true) {
const { done, value } = await reader.read();
if (done) return 'FALLBACK';
if (done) return fallback;

buffer = concatBytes(buffer, value);
const { frames, rest } = extractFrames(buffer);
Expand All @@ -163,17 +175,25 @@ export const subscribeItemParsingFromShare = async ({

try {
const payload = JSON.parse(data) as NotificationSsePayloadT;
const result = payload.type ? PARSING_EVENT_TO_RESULT[payload.type] : null;

if (result && payload.kind === 'WISH' && payload.refId === itemId) return result;
const status = payload.type ? PARSING_EVENT_TO_STATUS[payload.type] : null;
/** 합침 알림의 wishId 는 남는 위시라 요청한 위시는 따로 실려 옴 */
const requestedWishId = status === 'MERGED' ? payload.refId : payload.wishId;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (
status &&
payload.kind === 'WISH' &&
requestedWishId === wishId &&
typeof payload.wishId === 'number'
)
return { status, wishId: payload.wishId };
} catch {
/** malformed JSON — 다음 이벤트를 기다린다 */
}
}
}
} catch {
/** 연결 실패·타임아웃·abort — 저장 성공은 이미 확정이라 조용히 폴백 */
return 'FALLBACK';
return fallback;
} finally {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', abortConnection);
Expand Down
Loading