/** * @generated * 최초 1회 생성되며, 이후에는 덮어쓰지 않습니다. * 필요시 직접 수정할 수 있습니다. */ /* oxlint-disable react-hooks/exhaustive-deps */ // shared /* fetch */ import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import qs from "qs"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; import { type core, z } from "zod"; import { type InfiniteData } from "@tanstack/react-query"; import { getCurrentLocale } from "@/i18n/sd.generated"; import { ExpoEventSource as EventSource } from "@falcondev-oss/expo-event-source-polyfill"; // AbortSignal.timeout polyfill for React Native if (typeof AbortSignal !== "undefined" && !AbortSignal.timeout) { AbortSignal.timeout = (ms: number): AbortSignal => { const controller = new AbortController(); setTimeout(() => controller.abort(), ms); return controller.signal; }; } // ISO 8601 및 타임존 포맷의 날짜 문자열을 Date 객체로 변환하는 reviver function dateReviver(_key: string, value: any): any { if (typeof value === "string") { // ISO 8601 형식: 2024-01-15T09:30:00.000Z 또는 2024-01-15T09:30:00+09:00 const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/; // SQL Datetime 형식 (타임존 포함): 2024-01-15 09:30:00+09:00 const datetimeWithTimezoneRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/; // SQL Datetime 형식 (타임존 없음): 2024-01-15 09:30:00 const datetimeRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; if ( (isoRegex.test(value) || datetimeWithTimezoneRegex.test(value) || datetimeRegex.test(value)) && new Date(value).toString() !== "Invalid Date" ) { return new Date(value); } } return value; } axios.defaults.transformResponse = [ (data) => { if (typeof data === "string") { try { return JSON.parse(data, dateReviver); } catch (_e) { return data; } } return data; }, ]; // Axios + React Native FormData 호환성: Content-Type을 multipart/form-data로 명시 설정하고 // transformRequest를 우회하여 Android에서 application/x-www-form-urlencoded로 잘못 설정되는 문제 방지 // ref: https://github.com/axios/axios/issues/4800 axios.interceptors.request.use((config) => { config.headers["Accept-Language"] = getCurrentLocale(); if (config.data instanceof FormData) { if (config.headers instanceof axios.AxiosHeaders) { config.headers.setContentType("multipart/form-data"); } config.transformRequest = [(data: unknown) => data]; } return config; }); export async function fetch(options: AxiosRequestConfig) { try { const res = await axios({ ...options, }); return res.data; } catch (e: unknown) { if (axios.isAxiosError(e) && e.response?.data) { const d = e.response.data as { message: string; issues: core.$ZodIssue[]; }; throw new SonamuError(e.response.status, d.message, d.issues); } throw e; } } export function toFormData( obj: Record, formData = new FormData(), prefix = "", ): FormData { for (const [key, value] of Object.entries(obj)) { const formKey = prefix ? `${prefix}[${key}]` : key; if (value instanceof File || value instanceof Blob) { formData.append(formKey, value); } else if (Array.isArray(value)) { value.forEach((item, index) => { toFormData({ [index]: item }, formData, formKey); }); } else if (value !== null && value !== undefined && typeof value === "object") { toFormData(value as Record, formData, formKey); // 재귀로 펼치기 } else if (value !== null && value !== undefined) { formData.append(formKey, String(value)); } } return formData; } export class SonamuError extends Error { isSonamuError: boolean; constructor( public code: number, public message: string, public issues: z.ZodIssue[], ) { super(message); this.isSonamuError = true; } } export function isSonamuError(e: any): e is SonamuError { return e && e.isSonamuError === true; } export function defaultCatch(e: any) { if (isSonamuError(e)) { console.log(e); Alert.alert(e.message); } else { Alert.alert("에러 발생"); } } /* Isomorphic Types */ // semanticQuery가 있으면 similarity를 추가하는 조건부 타입 type WithSimilarity = LP extends { semanticQuery: Record } ? T & { similarity: number } : T; export type ListResult< LP extends { queryMode?: SonamuQueryMode }, T, > = LP["queryMode"] extends "list" ? { rows: WithSimilarity[] } : LP["queryMode"] extends "count" ? { total: number } : { rows: WithSimilarity[]; total: number }; export const SonamuQueryMode = z.enum(["both", "list", "count"]); export type SonamuQueryMode = z.infer; /* Filter Types */ // Prop 타입별 허용 연산자 export const operatorsByPropType = { string: ["eq", "ne", "contains", "startsWith", "endsWith", "in", "notIn", "isNull", "isNotNull"], integer: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "between", "isNull", "isNotNull"], numeric: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "between", "isNull", "isNotNull"], boolean: ["eq", "ne", "isNull", "isNotNull"], date: ["eq", "ne", "before", "after", "between", "isNull", "isNotNull"], datetime: ["eq", "ne", "before", "after", "between", "isNull", "isNotNull"], enum: ["eq", "ne", "in", "notIn", "isNull", "isNotNull"], json: ["isNull", "isNotNull"], } as const; // Prop 타입별 기본 연산자 export const defaultOperatorByPropType = { string: "contains", integer: "eq", numeric: "eq", boolean: "eq", date: "eq", datetime: "eq", enum: "eq", json: "isNull", } as const; // operatorsByPropType에서 파생되는 타입들 export type FilterPropType = keyof typeof operatorsByPropType; export type FilterOperator = (typeof operatorsByPropType)[keyof typeof operatorsByPropType][number]; // 특정 prop 타입에 허용되는 연산자 유니온 type OperatorForPropType = (typeof operatorsByPropType)[TPropType][number]; // 연산자별 기대 값 타입 type OperatorValue = K extends "in" | "notIn" ? T[] : K extends "between" ? [T, T] : K extends "isNull" | "isNotNull" ? boolean : T; // 특정 연산자 집합에 대한 필터 조건 타입 type ConditionForOperators = | T | { [K in TOps]?: OperatorValue }; /** * 필터 조건 - 타입에 따라 사용 가능한 연산자가 제한 */ export type FilterCondition = NonNullable extends number ? ConditionForOperators, OperatorForPropType<"integer">> : NonNullable extends string ? ConditionForOperators, OperatorForPropType<"string">> : NonNullable extends Date ? ConditionForOperators, OperatorForPropType<"date">> : NonNullable extends boolean ? ConditionForOperators, OperatorForPropType<"boolean">> : // Fallback: 비원시 타입은 null 체크만 허용 ConditionForOperators, OperatorForPropType<"json">>; /** * 필터 쿼리 * 엔티티의 각 필드에 대한 필터 조건 정의 */ export type FilterQuery = { [K in keyof TEntity]?: K extends TNumericKeys ? ConditionForOperators, OperatorForPropType<"numeric">> : FilterCondition; }; /** * Sonamu 필터 적용 타입 * Entity에서 제외할 필드와 numeric 필드를 받아서 최종 FilterQuery 타입을 생성 */ export type ApplySonamuFilter< TEntity, TOmitKeys extends keyof TEntity = never, TNumericKeys extends Exclude = never, > = FilterQuery, TNumericKeys>; /* Semantic Query */ export const SonamuSemanticParams = z .object({ semanticQuery: z.object({ embedding: z.array(z.number()).min(1024).max(1024), threshold: z.number().optional(), method: z.enum(["cosine", "l2", "inner_product"]).optional(), which: z.string(), }), }) .partial(); export type SonamuSemanticParams = z.infer; /** * SonamuFile Types */ export interface SonamuFile { name: string; url: string; mime_type: string; size: number; } export const SonamuFileSchema = z.object({ name: z.string(), url: z.string(), mime_type: z.string(), size: z.number(), }); export const SonamuFileArraySchema = z.array(SonamuFileSchema); /* SWR */ export type SwrOptions = { conditional?: () => boolean; }; export type SWRError = { name: string; message: string; statusCode: number; }; export async function swrFetcher(args: [string, object]): Promise { try { const [url, params] = args; const res = await axios.get(`${url}?${qs.stringify(params)}`); return res.data; } catch (e: any) { const error: any = new Error(e.response.data.message ?? e.response.message ?? "Unknown"); error.statusCode = e.response?.data.statusCode ?? e.response.status; throw error; } } export async function swrPostFetcher(args: [string, object]): Promise { try { const [url, params] = args; const res = await axios.post(url, params); return res.data; } catch (e: any) { const error: any = new Error(e.response.data.message ?? e.response.message ?? "Unknown"); error.statusCode = e.response?.data.statusCode ?? e.response.status; throw error; } } export function handleConditional( args: [string, object], conditional?: () => boolean, ): [string, object] | null { if (conditional) { return conditional() ? args : null; } return args; } /* Utils */ export function zArrayable( shape: T, ): z.ZodUnion]> { return z.union([shape, shape.array()]); } /* Custom Scalars */ export const SQLDateTimeString = z .string() .regex(/([0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}:[0-9]{2})*)$/, { message: "잘못된 SQLDate 타입", }) .min(10) .max(19) .describe("SQLDateTimeString"); export type SQLDateTimeString = z.infer; /* Stream */ export type SSEStreamOptions = { enabled?: boolean; retry?: number; retryInterval?: number; }; export type SSEStreamState = { isConnected: boolean; error: string | null; retryCount: number; isEnded: boolean; }; export type WebSocketChannelOptions = { enabled?: boolean; retry?: number; retryInterval?: number; protocols?: string | string[]; headers?: Record; }; export type WebSocketChannelState> = { isConnected: boolean; error: string | null; retryCount: number; readyState: number; send(event: K, data: TSend[K]): void; close(code?: number, reason?: string): void; }; // outbound event 전체를 강제하지 않도록 handler를 optional map으로 둠 export type EventHandlers = { [K in keyof T]?: (data: T[K]) => void; }; export function useSSEStream>( url: string, params: Record, handlers: { [K in keyof T]?: (data: T[K]) => void; }, options: SSEStreamOptions = {}, ): SSEStreamState { const { enabled = true, retry = 3, retryInterval = 3000 } = options; const [state, setState] = useState({ isConnected: false, error: null, retryCount: 0, isEnded: false, }); const eventSourceRef = useRef(null); const retryTimeoutRef = useRef | null>(null); const connectionIdRef = useRef(0); const handlersRef = useRef(handlers); // handlers를 ref로 관리해서 재연결 없이 업데이트 useEffect(() => { handlersRef.current = handlers; }, [handlers]); // 연결 함수 const connect = () => { if (!enabled) return; const myConnectionId = ++connectionIdRef.current; try { // 기존 연결이 있으면 정리 if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } // 재시도 타이머 정리 if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } // URL에 파라미터 추가 const queryString = qs.stringify(params); const fullUrl = queryString ? `${axios.defaults.baseURL}${url}?${queryString}` : `${axios.defaults.baseURL}${url}`; const eventSource = new EventSource(fullUrl, { headers: { "Accept-Language": getCurrentLocale(), "Cookie": authClient.getCookie(), }, credentials: "include", }); eventSourceRef.current = eventSource; // 연결 시도 중 상태 표시 setState((prev) => ({ ...prev, isConnected: false, error: null, isEnded: false, })); eventSource.addEventListener("open", () => { setState((prev) => ({ ...prev, isConnected: true, error: null, retryCount: 0, isEnded: false, })); }); eventSource.addEventListener("error", (_event) => { // 이미 다른 연결로 교체되었는지 확인 if (eventSourceRef.current !== eventSource) { return; // 이미 새로운 연결이 있으면 무시 } // EventSource 내장 자동 재연결 방지를 위해 즉시 close eventSource.close(); setState((prev) => ({ ...prev, isConnected: false, error: "Connection failed", isEnded: false, })); // 자체 재연결 로직 (EventSource 내장 재연결 대신) setState((prev) => { if (prev.retryCount < retry) { retryTimeoutRef.current = setTimeout(() => { if (connectionIdRef.current !== myConnectionId) return; if (retryTimeoutRef.current !== null) { setState((inner) => ({ ...inner, retryCount: inner.retryCount + 1, isEnded: false, })); connect(); } }, retryInterval); return prev; } else { eventSourceRef.current = null; return { ...prev, error: `Connection failed after ${retry} attempts`, }; } }); }); // 공통 'end' 이벤트 처리 (사용자 정의 이벤트와 별도) eventSource.addEventListener("end", () => { if (eventSourceRef.current === eventSource) { eventSource.close(); eventSourceRef.current = null; setState((prev) => ({ ...prev, isConnected: false, error: null, // 정상 종료 isEnded: true, })); if (handlersRef.current.end) { const endHandler = handlersRef.current.end; endHandler("end" as T[string]); } } }); // 각 이벤트 타입별 리스너 등록 Object.keys(handlersRef.current).forEach((eventType) => { const handler = handlersRef.current[eventType as keyof T]; if (handler) { eventSource.addEventListener(eventType, (event) => { // 여전히 현재 연결인지 확인 if (eventSourceRef.current !== eventSource) { return; // 이미 새로운 연결로 교체되었으면 무시 } try { const data = JSON.parse(event.data as string, dateReviver); handler(data); } catch (error) { console.error(`Failed to parse SSE data for event ${eventType}:`, error); } setState((prev) => ({ ...prev, isEnded: false, })); }); } }); // 기본 message 이벤트 처리 (event 타입이 없는 경우) eventSource.addEventListener("message", (event) => { // 여전히 현재 연결인지 확인 if (eventSourceRef.current !== eventSource) { return; } try { const data = JSON.parse(event.data as string, dateReviver); // 'message' 핸들러가 있으면 호출 const messageHandler = handlersRef.current["message" as keyof T]; if (messageHandler) { messageHandler(data); } } catch (error) { console.error("Failed to parse SSE message:", error); } }); } catch (error) { setState((prev) => ({ ...prev, error: error instanceof Error ? error.message : "Unknown error", isConnected: false, isEnded: false, })); } }; // 연결 시작 (단일 effect로 연결 lifecycle 관리) useEffect(() => { if (enabled) { // state 초기화 setState({ isConnected: false, error: null, retryCount: 0, isEnded: false, }); connect(); } return () => { connectionIdRef.current++; if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } }; }, [url, JSON.stringify(params), enabled]); return state; } export function useWebSocketChannel< TReceive extends Record, TSend extends Record, >( url: string, params: Record, handlers: EventHandlers, options: WebSocketChannelOptions = {}, ): WebSocketChannelState { const { enabled = true, retry = 3, retryInterval = 3000, protocols, headers } = options; const [state, setState] = useState, "send" | "close">>({ isConnected: false, error: null, retryCount: 0, readyState: 3, }); const socketRef = useRef(null); const retryTimeoutRef = useRef | null>(null); const handlersRef = useRef(handlers); const manualCloseRef = useRef(false); // 최신 연결 식별자를 따로 두어 stale socket 이벤트가 상태를 덮지 못하게 함 const connectionIdRef = useRef(0); useEffect(() => { handlersRef.current = handlers; }, [handlers]); const close = (code?: number, reason?: string) => { manualCloseRef.current = true; if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } if (socketRef.current) { socketRef.current.close(code, reason); } }; const send = (event: K, data: TSend[K]) => { const socket = socketRef.current; if (!socket || socket.readyState !== 1) { setState((prev) => ({ ...prev, error: "WebSocket is not connected", })); return; } socket.send( JSON.stringify({ event, data, }), ); }; const connect = () => { if (!enabled) { return; } const connectionId = ++connectionIdRef.current; manualCloseRef.current = false; if (socketRef.current) { socketRef.current.close(); socketRef.current = null; } if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } const queryString = qs.stringify(params); // 앱 shared는 Node/Native 실행도 염두에 두므로 baseURL과 추가 headers를 함께 반영함 const baseUrl = axios.defaults.baseURL ?? "$[[baseUrl]]"; const wsBaseUrl = baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); const fullUrl = new URL(queryString ? `${url}?${queryString}` : url, wsBaseUrl).toString(); // RN의 WebSocket 생성자는 3번째 인자로 { headers } 옵션을 받지만 lib.dom 타입엔 없음. // 명시 cast로 우회 — 결과 socket은 WebSocket 타입 그대로 유지됨. const RNWebSocket = WebSocket as new ( url: string | URL, protocols?: string | string[], options?: { headers?: Record }, ) => WebSocket; const socket = new RNWebSocket(fullUrl, protocols, { headers: { "Accept-Language": getCurrentLocale(), ...(headers ?? {}), }, }); socketRef.current = socket; setState((prev) => ({ ...prev, isConnected: false, error: null, readyState: socket.readyState, })); // socketRef.current !== socket 가드는 이전 연결의 늦은 콜백을 무시하기 위한 장치임 socket.addEventListener("open", () => { if (socketRef.current !== socket) { return; } setState((prev) => ({ ...prev, isConnected: true, error: null, retryCount: 0, readyState: socket.readyState, })); }); socket.addEventListener("message", (event) => { if (socketRef.current !== socket) { return; } try { const payload = JSON.parse(event.data as string, dateReviver) as { event: keyof TReceive; data: TReceive[keyof TReceive]; }; const handler = handlersRef.current[payload.event]; if (handler) { handler(payload.data); } } catch (error) { console.error("Failed to parse WebSocket message:", error); } }); socket.addEventListener("error", () => { if (socketRef.current !== socket) { return; } setState((prev) => ({ ...prev, isConnected: false, error: "WebSocket connection failed", readyState: socket.readyState, })); }); socket.addEventListener("close", (event) => { if (socketRef.current !== socket) { return; } socketRef.current = null; setState((prev) => ({ ...prev, isConnected: false, readyState: socket.readyState, })); if (manualCloseRef.current || connectionIdRef.current !== connectionId) { return; } // 정책 위반/과대 payload close는 재시도보다 명시적 에러 노출이 우선임 if (!isRetryableWebSocketCloseCode(event.code)) { setState((prev) => ({ ...prev, error: event.code === 1008 || event.code === 1009 ? `WebSocket rejected by server (code: ${event.code})` : `WebSocket closed (code: ${event.code})`, })); return; } setState((prev) => { if (prev.retryCount >= retry) { return { ...prev, error: `Connection failed after ${retry} attempts`, }; } retryTimeoutRef.current = setTimeout(() => { if (connectionIdRef.current !== connectionId) { return; } setState((inner) => ({ ...inner, retryCount: inner.retryCount + 1, })); connect(); }, retryInterval); return prev; }); }); }; useEffect(() => { if (enabled) { setState({ isConnected: false, error: null, retryCount: 0, readyState: 3, }); connect(); } return () => { connectionIdRef.current += 1; manualCloseRef.current = true; if (socketRef.current) { socketRef.current.close(); socketRef.current = null; } if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } }; }, [url, JSON.stringify(params), enabled, JSON.stringify(protocols), JSON.stringify(headers)]); return { ...state, send, close, }; } function isRetryableWebSocketCloseCode(code: number): boolean { if (code === 1000) { return false; } return ![1002, 1003, 1007, 1008, 1009].includes(code); } /* Dictionary Helper */ $[[dictUtils]] /* Query helpers */ type InfinitePage = { rows: TRow[]; total: number }; type DedupedInfiniteData = InfiniteData> & { rows: TRow[]; total: number; }; // useInfiniteQuery의 select에 꽂아 pages/pageParams 원본은 유지하면서 // 평탄화된 rows와 첫 페이지의 total을 data에 함께 노출합니다. // 각 row가 id를 갖는 경우 id 기준으로 중복 제거합니다. id가 없으면 그대로 유지합니다. export function dedupeAndFlatten( data: InfiniteData>, ): DedupedInfiniteData { const seen = new Set(); const rows: TRow[] = []; for (const page of data.pages) { for (const row of page?.rows ?? []) { const id = row?.id; if (id !== null) { if (seen.has(id)) { continue; } seen.add(id); } rows.push(row); } } const total = data.pages[0]?.total ?? 0; return { pages: data.pages, pageParams: data.pageParams, rows, total, }; } // TanStack Query 결과에 수동 refresh 진입점과 새로고침 중 상태를 덧붙여 줍니다. // isRefreshing은 query.isFetching과 독립적으로 이 함수 호출로 발생한 새로고침에 한정됩니다. export function useRefreshable Promise }>( query: T, ): T & { refresh: () => Promise; isRefreshing: boolean } { const [isRefreshing, setIsRefreshing] = useState(false); const refresh = useCallback(async () => { setIsRefreshing(true); try { await query.refetch(); } finally { setIsRefreshing(false); } }, [query]); return { ...query, refresh, isRefreshing }; }