@xfilecom/xframe 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/defaults.json +2 -2
  2. package/package.json +1 -1
  3. package/template/README.md +1 -1
  4. package/template/shared/endpoint/README.md +30 -1
  5. package/template/shared/endpoint/endpoint.ts +55 -0
  6. package/template/shared/endpoint/index.ts +2 -0
  7. package/template/web/admin/src/App.tsx +2 -7
  8. package/template/web/admin/src/main.tsx +12 -1
  9. package/template/web/admin/tsconfig.json +1 -0
  10. package/template/web/admin/vite.config.ts +2 -0
  11. package/template/web/client/src/App.tsx +2 -7
  12. package/template/web/client/src/FrontCoreShowcase.tsx +240 -15
  13. package/template/web/client/src/main.tsx +12 -1
  14. package/template/web/client/tsconfig.json +1 -0
  15. package/template/web/client/vite.config.ts +4 -0
  16. package/template/web/shared/package.json +3 -1
  17. package/template/web/shared/src/api/client.ts +132 -0
  18. package/template/web/shared/src/api/methods/health.method.ts +14 -0
  19. package/template/web/shared/src/api/methods/index.ts +31 -0
  20. package/template/web/shared/src/api/request.ts +65 -0
  21. package/template/web/shared/src/api/types.ts +80 -0
  22. package/template/web/shared/src/context/StoreProvider.tsx +45 -0
  23. package/template/web/shared/src/hooks/useAppStore.ts +23 -0
  24. package/template/web/shared/src/hooks/useHealthStatus.ts +12 -3
  25. package/template/web/shared/src/index.ts +53 -1
  26. package/template/web/shared/src/lib/http.ts +9 -6
  27. package/template/web/shared/src/methods/health.ts +5 -4
  28. package/template/web/shared/src/params/param-store.ts +178 -0
  29. package/template/web/shared/src/params/types.ts +21 -0
  30. package/template/web/shared/src/params/validations.ts +32 -0
  31. package/template/web/shared/src/store/api-cache-store.ts +35 -0
  32. package/template/web/shared/src/store/index.ts +4 -0
  33. package/template/web/shared/src/store/root-store.ts +358 -0
  34. package/template/web/shared/src/store/session-store.ts +28 -0
  35. package/template/web/shared/src/types/ui.ts +37 -0
  36. package/template/web/shared/tsconfig.json +4 -1
  37. package/template/yarn.lock +237 -13
  38. package/template/web/shared/src/stores/appStore.ts +0 -11
@@ -16,6 +16,9 @@ const frontCoreSrc = path.resolve(__dirname, '../../../..', 'front-core', 'src')
16
16
  /** file:../shared 복사본이 아닌 워크스페이스 web/shared/src (플레이스홀더 패키지 이름과 동일 키) */
17
17
  const webSharedSrc = path.resolve(__dirname, '../shared/src');
18
18
 
19
+ /** 루트 `shared/` — `web/` 과 동일 레벨 (엔드포인트·스키마 등) */
20
+ const rootSharedSrc = path.resolve(__dirname, '../../shared');
21
+
19
22
  function readYaml(file: string): Record<string, unknown> {
20
23
  if (!existsSync(file)) return {};
21
24
  const parsed = yaml.load(readFileSync(file, 'utf8'));
@@ -71,6 +74,7 @@ export default defineConfig(({ mode }) => {
71
74
  resolve: {
72
75
  alias: {
73
76
  '@xfilecom/front-core': frontCoreSrc,
77
+ '@shared': rootSharedSrc,
74
78
  '__WEB_SHARED_WORKSPACE__': webSharedSrc,
75
79
  },
76
80
  },
@@ -19,7 +19,9 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@xfilecom/front-core": "__FRONT_CORE_SPEC__",
22
- "zustand": "^5.0.3"
22
+ "axios": "^1.7.9",
23
+ "mobx": "^6.13.5",
24
+ "mobx-react-lite": "^4.0.7"
23
25
  },
24
26
  "peerDependencies": {
25
27
  "react": "^18.0.0",
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Axios — baseURL, 로깅 인터셉터, 전역 훅(onRequestStart/End), Bearer
3
+ */
4
+
5
+ import axios, {
6
+ type AxiosError,
7
+ type AxiosResponse,
8
+ type InternalAxiosRequestConfig,
9
+ } from 'axios';
10
+ import { sessionStore } from '../store/session-store';
11
+
12
+ const LOG_PREFIX = '[xframe/http]';
13
+
14
+ export type OnRequestStart = () => void;
15
+ export type OnRequestEnd = () => void;
16
+ export type OnError = (message: string, code?: string | number) => void;
17
+
18
+ let onRequestStart: OnRequestStart = () => {};
19
+ let onRequestEnd: OnRequestEnd = () => {};
20
+ let onError: OnError = () => {};
21
+
22
+ export function setApiHooks(hooks: {
23
+ onRequestStart?: OnRequestStart;
24
+ onRequestEnd?: OnRequestEnd;
25
+ onError?: OnError;
26
+ }) {
27
+ if (hooks.onRequestStart) onRequestStart = hooks.onRequestStart;
28
+ if (hooks.onRequestEnd) onRequestEnd = hooks.onRequestEnd;
29
+ if (hooks.onError) onError = hooks.onError;
30
+ }
31
+
32
+ export const apiClient = axios.create({
33
+ timeout: 30_000,
34
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
35
+ });
36
+
37
+ export function configureApi(options: { baseURL: string }) {
38
+ apiClient.defaults.baseURL = options.baseURL.replace(/\/$/, '');
39
+ }
40
+
41
+ export function getApiBaseUrl(): string {
42
+ return apiClient.defaults.baseURL ?? '';
43
+ }
44
+
45
+ type ExtConfig = InternalAxiosRequestConfig & { skipGlobalIndicator?: boolean };
46
+
47
+ function logRequest(config: InternalAxiosRequestConfig) {
48
+ console.log(`${LOG_PREFIX} request`, {
49
+ method: config.method?.toUpperCase(),
50
+ url: config.url,
51
+ baseURL: config.baseURL,
52
+ params: config.params,
53
+ data: config.data,
54
+ headers: config.headers,
55
+ });
56
+ }
57
+
58
+ function logResponseSuccess(response: AxiosResponse) {
59
+ console.log(`${LOG_PREFIX} response`, {
60
+ status: response.status,
61
+ statusText: response.statusText,
62
+ url: response.config.url,
63
+ data: response.data,
64
+ });
65
+ }
66
+
67
+ function logResponseError(error: unknown) {
68
+ if (axios.isAxiosError(error)) {
69
+ console.log(`${LOG_PREFIX} response error`, {
70
+ message: error.message,
71
+ code: error.code,
72
+ status: error.response?.status,
73
+ statusText: error.response?.statusText,
74
+ url: error.config?.url,
75
+ data: error.response?.data,
76
+ });
77
+ } else {
78
+ console.log(`${LOG_PREFIX} response error`, error);
79
+ }
80
+ }
81
+
82
+ apiClient.interceptors.request.use(
83
+ (config: InternalAxiosRequestConfig) => {
84
+ logRequest(config);
85
+ const c = config as ExtConfig;
86
+ const token = sessionStore.token;
87
+ if (token && c.headers) {
88
+ c.headers.Authorization = `Bearer ${token}`;
89
+ }
90
+ if (c.data instanceof FormData && c.headers) {
91
+ delete c.headers['Content-Type'];
92
+ }
93
+ if (!c.skipGlobalIndicator) onRequestStart();
94
+ return c;
95
+ },
96
+ (err) => {
97
+ console.log(`${LOG_PREFIX} request error`, err);
98
+ return Promise.reject(err);
99
+ },
100
+ );
101
+
102
+ apiClient.interceptors.response.use(
103
+ (response: AxiosResponse) => {
104
+ const c = response.config as ExtConfig;
105
+ if (!c.skipGlobalIndicator) onRequestEnd();
106
+ logResponseSuccess(response);
107
+ return response;
108
+ },
109
+ (err: AxiosError) => {
110
+ const c = err.config as ExtConfig | undefined;
111
+ if (!c?.skipGlobalIndicator) onRequestEnd();
112
+ logResponseError(err);
113
+ const res = err?.response as
114
+ | { data?: { message?: unknown; error?: unknown; code?: string | number }; status?: number }
115
+ | undefined;
116
+ const raw = res?.data?.message ?? res?.data?.error ?? (err as Error)?.message ?? 'Request failed';
117
+ const message =
118
+ typeof raw === 'string'
119
+ ? raw
120
+ : Array.isArray(raw)
121
+ ? raw.map((x) => (typeof x === 'string' ? x : String(x))).join(', ')
122
+ : typeof raw === 'object' &&
123
+ raw != null &&
124
+ 'message' in raw &&
125
+ typeof (raw as { message: unknown }).message === 'string'
126
+ ? (raw as { message: string }).message
127
+ : JSON.stringify(raw);
128
+ const code = res?.status ?? (res?.data?.code as string | number | undefined);
129
+ onError(message, code);
130
+ return Promise.reject(err);
131
+ },
132
+ );
@@ -0,0 +1,14 @@
1
+ import type { CommonEnvelope, HealthData } from '../../types/api';
2
+ import type { ApiMethodConfig, ApiMethodStore } from '../types';
3
+ import { healthEndpoint } from '@shared/endpoint';
4
+
5
+ /** GET /health — params·body 없음, 스토어는 validate/post 에서 사용 가능 */
6
+ export const healthMethod: ApiMethodConfig<void, CommonEnvelope<HealthData>, ApiMethodStore> = {
7
+ schema: {},
8
+ endpoint: healthEndpoint,
9
+ ui: {
10
+ showIndicator: false,
11
+ showErrorToast: true,
12
+ unwrapDataArray: false,
13
+ },
14
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 화면별 workflow registry — 새 method 는 *.method.ts 추가 후 여기 등록
3
+ */
4
+
5
+ import type { ParamsSchema } from '../../params/types';
6
+ import { healthMethod } from './health.method';
7
+
8
+ export type {
9
+ ApiMethodConfig,
10
+ ApiMethodStore,
11
+ ApiMethodEndpointCall,
12
+ CoreSdkWrapped,
13
+ ExecuteContext,
14
+ ApiMethodUiConfig,
15
+ ScreenMeta,
16
+ ScreenAuthType,
17
+ } from '../types';
18
+
19
+ export const methods = {
20
+ health: healthMethod,
21
+ } as const;
22
+
23
+ /** ParamStore 초기 스키마 — 화면명 → 필드 → value + valid 규칙 */
24
+ export const screenParamSchemas: ParamsSchema = {
25
+ /** 예시: 검색어 (선택) — execute 전 validate 에서 isScreenValid 로 검사 가능 */
26
+ demo: {
27
+ q: { value: '', valid: [] },
28
+ },
29
+ };
30
+
31
+ export type MethodName = keyof typeof methods;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * EndpointDef — path `:param` 치환, query/body, skipGlobalIndicator
3
+ */
4
+
5
+ import type { EndpointDef } from '@shared/endpoint';
6
+ import { apiClient } from './client';
7
+
8
+ function buildPath(path: string, params?: Record<string, string>): string {
9
+ if (!params) return path;
10
+ let out = path;
11
+ for (const [k, v] of Object.entries(params)) {
12
+ out = out.replace(`:${k}`, encodeURIComponent(String(v)));
13
+ }
14
+ return out;
15
+ }
16
+
17
+ function buildQuery(query?: Record<string, string | number | boolean | undefined>): string {
18
+ if (!query) return '';
19
+ const search = new URLSearchParams();
20
+ for (const [k, v] of Object.entries(query)) {
21
+ if (v !== undefined && v !== '') search.set(k, String(v));
22
+ }
23
+ const s = search.toString();
24
+ return s ? `?${s}` : '';
25
+ }
26
+
27
+ export interface RequestTransportOptions<T = unknown> {
28
+ params?: Record<string, string>;
29
+ query?: Record<string, string | number | boolean | undefined>;
30
+ body?: T;
31
+ skipGlobalIndicator?: boolean;
32
+ }
33
+
34
+ export async function sendRequest<T = unknown, R = unknown>(
35
+ endpoint: EndpointDef,
36
+ options: RequestTransportOptions<T> = {},
37
+ ): Promise<R> {
38
+ const { params, query, body, skipGlobalIndicator } = options;
39
+ const url = buildPath(endpoint.path, params) + buildQuery(query);
40
+ const config: {
41
+ method: string;
42
+ url: string;
43
+ data?: T;
44
+ skipGlobalIndicator?: boolean;
45
+ } = {
46
+ method: endpoint.method,
47
+ url,
48
+ skipGlobalIndicator,
49
+ };
50
+ if (
51
+ body !== undefined &&
52
+ (endpoint.method === 'POST' || endpoint.method === 'PUT' || endpoint.method === 'PATCH')
53
+ ) {
54
+ config.data = body;
55
+ }
56
+ const axiosConfig = {
57
+ ...config,
58
+ ...(endpoint.timeoutMs != null ? { timeout: endpoint.timeoutMs } : {}),
59
+ } as Parameters<typeof apiClient.request>[0];
60
+
61
+ const response = await apiClient.request<R>(axiosConfig);
62
+ const raw = response.data;
63
+ const shaped = endpoint.post ? endpoint.post(raw) : raw;
64
+ return shaped as R;
65
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * methods.ts / RootStore.execute — RootStore 직접 참조 없이 순환 방지
3
+ */
4
+
5
+ import type { EndpointDef } from '@shared/endpoint';
6
+ import type { ParamScreenSchema, ParamsSchema } from '../params/types';
7
+ import type { ParamStore } from '../params/param-store';
8
+ import type { SessionStore } from '../store/session-store';
9
+
10
+ /** methods 콜백에서 받는 store — RootStore 가 이 형태를 만족 */
11
+ export interface ApiMethodStore {
12
+ paramStore: ParamStore;
13
+ sessionStore: SessionStore;
14
+ }
15
+
16
+ export type { ParamsSchema, ParamScreenSchema };
17
+
18
+ export type CoreSdkWrapped<T> = {
19
+ data?: T[] | T;
20
+ code?: number;
21
+ error?: unknown;
22
+ success?: boolean;
23
+ message?: string;
24
+ };
25
+
26
+ export interface ExecuteContext {
27
+ redirectTo?: string;
28
+ navigate?: (path: string, options?: { replace?: boolean }) => void;
29
+ }
30
+
31
+ export interface ApiMethodUiConfig {
32
+ indicatorMessage?: string;
33
+ successMessage?: string;
34
+ errorMessage?: string;
35
+ showIndicator?: boolean;
36
+ showSuccessToast?: boolean;
37
+ showErrorToast?: boolean;
38
+ unwrapDataArray?: boolean;
39
+ }
40
+
41
+ export type ScreenAuthType = 'guest' | 'protected';
42
+
43
+ export interface ScreenMeta {
44
+ path: string;
45
+ auth: ScreenAuthType;
46
+ index?: boolean;
47
+ page: string;
48
+ bottomNav?: boolean;
49
+ label?: string;
50
+ }
51
+
52
+ export interface ApiMethodEndpointCall<TPayload = unknown, TStore = ApiMethodStore> {
53
+ endpoint: EndpointDef;
54
+ body?: (stores: TStore, payload?: TPayload) => unknown;
55
+ query?: (
56
+ stores: TStore,
57
+ payload?: TPayload,
58
+ ) => Record<string, string | number | boolean | undefined>;
59
+ params?: (stores: TStore, payload?: TPayload) => Record<string, string>;
60
+ }
61
+
62
+ export interface ApiMethodConfig<TPayload = unknown, TResult = unknown, TStore = ApiMethodStore> {
63
+ endpoint?: EndpointDef;
64
+ endpoints?: ApiMethodEndpointCall<TPayload, TStore>[];
65
+ schema: ParamScreenSchema;
66
+ screen?: ScreenMeta;
67
+ ui?: ApiMethodUiConfig;
68
+ body?: (stores: TStore, payload?: TPayload) => unknown;
69
+ query?: (
70
+ stores: TStore,
71
+ payload?: TPayload,
72
+ ) => Record<string, string | number | boolean | undefined>;
73
+ params?: (stores: TStore, payload?: TPayload) => Record<string, string>;
74
+ validate?: (stores: TStore, payload?: TPayload) => boolean;
75
+ post?: (result: TResult, stores: TStore, payload?: TPayload) => void;
76
+ send?: (store: TStore) => Promise<unknown> | unknown;
77
+ redirectPath?:
78
+ | string
79
+ | ((result: TResult, payload?: TPayload, ctx?: ExecuteContext) => string);
80
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * RootStore 컨텍스트 — useStore / useParamStore / useSendMessage 등
3
+ * (싱글톤 rootStore 이므로 Provider 없이도 rootStore 직접 import 가능)
4
+ */
5
+
6
+ import React, { createContext, useCallback, useContext } from 'react';
7
+ import type { EndpointDef } from '@shared/endpoint';
8
+ import type { SendMessageOptions } from '../types/ui';
9
+ import { rootStore } from '../store/root-store';
10
+
11
+ type RootStoreApi = typeof rootStore;
12
+
13
+ const StoreContext = createContext<RootStoreApi | null>(null);
14
+
15
+ export function useStore(): RootStoreApi {
16
+ const ctx = useContext(StoreContext);
17
+ if (!ctx) throw new Error('StoreProvider is required');
18
+ return ctx;
19
+ }
20
+
21
+ export function StoreProvider({ children }: { children: React.ReactNode }) {
22
+ return <StoreContext.Provider value={rootStore}>{children}</StoreContext.Provider>;
23
+ }
24
+
25
+ export function useSendMessage() {
26
+ const store = useStore();
27
+ return useCallback(
28
+ function sendMessage<T = unknown, R = unknown>(endpoint: EndpointDef, options?: SendMessageOptions<T>) {
29
+ return store.sendMessage<T, R>(endpoint, options ?? {});
30
+ },
31
+ [store],
32
+ );
33
+ }
34
+
35
+ export function useParamStore() {
36
+ return useStore().paramStore;
37
+ }
38
+
39
+ export function useSessionStore() {
40
+ return useStore().sessionStore;
41
+ }
42
+
43
+ export function useRootStore(): RootStoreApi {
44
+ return useStore();
45
+ }
@@ -0,0 +1,23 @@
1
+ import { reaction } from 'mobx';
2
+ import { useSyncExternalStore } from 'react';
3
+ import { rootStore } from '../store/root-store';
4
+
5
+ /**
6
+ * Zustand 스타일 셀렉터 — RootStore(MobX) 구독
7
+ * 예: `useAppStore((s) => s.lastHealthAt)`, `useAppStore((s) => s.setLastHealthAt)`
8
+ */
9
+ export function useAppStore<T>(selector: (store: typeof rootStore) => T): T {
10
+ return useSyncExternalStore(
11
+ (onChange) => {
12
+ const dispose = reaction(
13
+ () => selector(rootStore),
14
+ () => {
15
+ onChange();
16
+ },
17
+ );
18
+ return () => dispose();
19
+ },
20
+ () => selector(rootStore),
21
+ () => selector(rootStore),
22
+ );
23
+ }
@@ -1,16 +1,25 @@
1
1
  import { useEffect, useState } from 'react';
2
+ import { configureApi } from '../api/client';
2
3
  import { fetchHealth } from '../methods/health';
3
4
  import { safeJsonStringify } from '../utils/safeJsonStringify';
4
- import { useAppStore } from '../stores/appStore';
5
+ import { useAppStore } from './useAppStore';
5
6
 
6
- export function useHealthStatus(apiBaseUrl: string) {
7
+ /**
8
+ * @param apiBaseUrl 선택 — 넘기면 `configureApi` 로 baseURL 갱신. 앱 entry 에서 이미 `configureApi` 했다면 생략 가능.
9
+ */
10
+ export function useHealthStatus(apiBaseUrl?: string) {
7
11
  const setLastHealthAt = useAppStore((s) => s.setLastHealthAt);
8
12
  const [text, setText] = useState('loading…');
9
13
  const [error, setError] = useState<string | null>(null);
10
14
 
11
15
  useEffect(() => {
16
+ if (apiBaseUrl != null && apiBaseUrl !== '') {
17
+ configureApi({ baseURL: apiBaseUrl });
18
+ }
12
19
  let cancelled = false;
13
- fetchHealth(apiBaseUrl)
20
+ setError(null);
21
+ setText('loading…');
22
+ fetchHealth()
14
23
  .then((body) => {
15
24
  if (!cancelled) {
16
25
  setText(safeJsonStringify(body, 2));
@@ -1,8 +1,60 @@
1
1
  export { Shell, type ShellProps } from './components/Shell';
2
2
  export { useHealthStatus } from './hooks/useHealthStatus';
3
- export { useAppStore } from './stores/appStore';
3
+ export { useAppStore } from './hooks/useAppStore';
4
+ export {
5
+ StoreProvider,
6
+ useStore,
7
+ useRootStore,
8
+ useSendMessage,
9
+ useParamStore,
10
+ useSessionStore,
11
+ } from './context/StoreProvider';
12
+
13
+ export { configureApi, getApiBaseUrl, setApiHooks, apiClient } from './api/client';
14
+ export { sendRequest, type RequestTransportOptions } from './api/request';
15
+ export type { EndpointDef, HttpMethod } from '@shared/endpoint';
16
+ export { healthEndpoint, appMetaEndpoint, unwrapResponseData } from '@shared/endpoint';
17
+ export type {
18
+ ApiMethodConfig,
19
+ ApiMethodStore,
20
+ ApiMethodEndpointCall,
21
+ CoreSdkWrapped,
22
+ ExecuteContext,
23
+ ApiMethodUiConfig,
24
+ ScreenMeta,
25
+ ScreenAuthType,
26
+ } from './api/types';
27
+ export {
28
+ methods,
29
+ screenParamSchemas,
30
+ type MethodName,
31
+ } from './api/methods';
32
+
33
+ export {
34
+ rootStore,
35
+ RootStore,
36
+ sessionStore,
37
+ SessionStore,
38
+ apiCacheStore,
39
+ ApiCacheStore,
40
+ observer,
41
+ } from './store';
42
+
43
+ /** 이전 `appStore` 싱글톤 명칭 호환 */
44
+ export { rootStore as appStore } from './store';
45
+
46
+ export { ParamStore, type ParamStoreOptions, type ParamFieldState } from './params/param-store';
47
+ export type { ParamsSchema, ParamScreenSchema, ParamFieldDef, ValidRule } from './params/types';
48
+
4
49
  export { fetchHealth } from './methods/health';
5
50
  export { getJson } from './lib/http';
6
51
  export { safeJsonStringify } from './utils/safeJsonStringify';
7
52
  export { FALLBACK_API_BASE_URL } from './constants/app';
8
53
  export type { CommonEnvelope, HealthData } from './types/api';
54
+ export type {
55
+ ToastSeverity,
56
+ ToastItem,
57
+ ApiErrorItem,
58
+ IndicatorState,
59
+ SendMessageOptions,
60
+ } from './types/ui';
@@ -1,7 +1,10 @@
1
- export async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
2
- const res = await fetch(url, { ...init, headers: { Accept: 'application/json', ...init?.headers } });
3
- if (!res.ok) {
4
- throw new Error(`HTTP ${res.status} ${res.statusText}`);
5
- }
6
- return res.json() as Promise<T>;
1
+ import type { AxiosRequestConfig } from 'axios';
2
+ import { apiClient } from '../api/client';
3
+
4
+ export async function getJson<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
5
+ const res = await apiClient.get<T>(url, {
6
+ ...config,
7
+ headers: { Accept: 'application/json', ...config?.headers },
8
+ });
9
+ return res.data;
7
10
  }
@@ -1,7 +1,8 @@
1
- import { getJson } from '../lib/http';
1
+ import { sendRequest } from '../api/request';
2
+ import { healthEndpoint } from '@shared/endpoint';
2
3
  import type { CommonEnvelope, HealthData } from '../types/api';
3
4
 
4
- export async function fetchHealth(apiBase: string): Promise<CommonEnvelope<HealthData>> {
5
- const base = apiBase.replace(/\/$/, '');
6
- return getJson<CommonEnvelope<HealthData>>(`${base}/health`);
5
+ /** 직접 호출용 (토스트 없음) UI 훅은 보통 이 함수 사용 */
6
+ export async function fetchHealth(): Promise<CommonEnvelope<HealthData>> {
7
+ return sendRequest<unknown, CommonEnvelope<HealthData>>(healthEndpoint, {});
7
8
  }