@xfilecom/xframe 0.1.37 → 0.1.39
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.
- package/bin/xframe.js +7 -1
- package/defaults.json +2 -2
- package/package.json +1 -1
- package/template/apps/api/src/main.ts +36 -1
- package/template/docs/SCAFFOLD_CHECKLIST.md +13 -0
- package/template/shared/README.md +1 -1
- package/template/shared/endpoint/endpoint.ts +23 -5
- package/template/web/admin/src/App.tsx +6 -12
- package/template/web/admin/src/main.tsx +10 -1
- package/template/web/admin/src/vite-env.d.ts +3 -2
- package/template/web/admin/vite.config.ts +2 -2
- package/template/web/client/src/App.tsx +4 -8
- package/template/web/client/src/FrontCoreShowcase.tsx +44 -1
- package/template/web/client/src/main.tsx +11 -1
- package/template/web/client/src/vite-env.d.ts +3 -2
- package/template/web/client/vite.config.ts +3 -2
- package/template/web/shared/README.md +5 -0
- package/template/web/shared/src/api/client.ts +69 -8
- package/template/web/shared/src/api/commonResponse.ts +53 -5
- package/template/web/shared/src/api/methods/health.method.ts +20 -1
- package/template/web/shared/src/api/methods/index.ts +8 -1
- package/template/web/shared/src/api/request.ts +33 -1
- package/template/web/shared/src/api/types.ts +10 -1
- package/template/web/shared/src/context/StoreProvider.tsx +82 -3
- package/template/web/shared/src/hooks/useAppStore.ts +28 -2
- package/template/web/shared/src/hooks/useHealthStatus.ts +35 -16
- package/template/web/shared/src/import-meta.d.ts +13 -0
- package/template/web/shared/src/methods/health.ts +16 -1
- package/template/web/shared/src/store/api-cache-store.ts +19 -0
- package/template/web/shared/src/store/root-store.ts +183 -11
- package/template/web/shared/src/store/session-store.ts +21 -2
- package/template/web/shared/src/styles/app.css +23 -0
- package/template/web/shared/src/types/ui.ts +7 -1
|
@@ -1,17 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* —
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* 공통 API 응답 봉투 — `{ code, data, error }`
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 전제 (백엔드와의 계약)
|
|
7
|
+
* ----------------------
|
|
8
|
+
* - **전송 성공**은 HTTP 상태로 표현: 클라이언트는 200/201 등 2xx를 “통신 OK”로 본다.
|
|
9
|
+
* - **비즈니스 성공/실패**는 본문의 숫자 `code`로 표현한다. (예: `0` = 성공)
|
|
10
|
+
* - 실패 시 사용자에게 보여 줄 메시지는 `error` 필드에 둔다 (문자열·객체·배열 등 가변).
|
|
11
|
+
*
|
|
12
|
+
* 이 모듈의 책임
|
|
13
|
+
* --------------
|
|
14
|
+
* - 봉투 형태인지 판별 (`isCommonResponsePayload`)
|
|
15
|
+
* - `error`를 사람이 읽을 문자열로 정규화 (`formatCommonErrorField`)
|
|
16
|
+
* - axios **성공** 인터셉터에서: 성공이면 `response.data`를 안쪽 `data`만 남기고,
|
|
17
|
+
* 실패면 콜백(토스트) 후 `CommonResponseRejectedError`로 끊기 (`applyCommonResponseEnvelope`)
|
|
18
|
+
*
|
|
19
|
+
* 주의
|
|
20
|
+
* ----
|
|
21
|
+
* - `code`가 **문자열** `"0"`인 JSON은 봉투로 인식하지 않는다 (`typeof b.code === 'number'`).
|
|
22
|
+
* 백엔드와 타입을 맞출 것.
|
|
4
23
|
*/
|
|
5
24
|
|
|
6
25
|
import type { AxiosResponse } from 'axios';
|
|
7
26
|
|
|
8
|
-
/** 백엔드와 동일한 “성공” 코드 */
|
|
27
|
+
/** 백엔드와 동일한 “성공” 코드 (비 0 이면 비즈니스 실패로 처리) */
|
|
9
28
|
export const COMMON_RESPONSE_SUCCESS_CODE = 0 as const;
|
|
10
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 봉투 검사를 통과했으나 `code !== 0` 인 경우, 성공 인터셉터에서 throw.
|
|
32
|
+
* AxiosError가 아니므로 `axios.isAxiosError`로는 구분되지 않음 → catch 분기에서 `instanceof` 사용.
|
|
33
|
+
*/
|
|
11
34
|
export class CommonResponseRejectedError extends Error {
|
|
12
35
|
readonly appCode: number;
|
|
13
36
|
readonly payload: unknown;
|
|
14
37
|
|
|
38
|
+
/**
|
|
39
|
+
* @param message - 사용자/토스트용 문구 (`formatCommonErrorField(error)` 등)
|
|
40
|
+
* @param appCode - 서버가 돌려준 비즈니스 코드 (0이 아님)
|
|
41
|
+
* @param payload - 원본 봉투 객체 (디버그·로깅용)
|
|
42
|
+
*/
|
|
15
43
|
constructor(message: string, appCode: number, payload: unknown) {
|
|
16
44
|
super(message);
|
|
17
45
|
this.name = 'CommonResponseRejectedError';
|
|
@@ -20,6 +48,11 @@ export class CommonResponseRejectedError extends Error {
|
|
|
20
48
|
}
|
|
21
49
|
}
|
|
22
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 최소한의 봉투 판별: `code`가 number이고 `data` 키가 존재하면 공통 응답으로 본다.
|
|
53
|
+
* (레거시 API는 이 조건을 만족하지 않으면 그대로 통과)
|
|
54
|
+
* @param body - axios `response.data` 등
|
|
55
|
+
*/
|
|
23
56
|
export function isCommonResponsePayload(
|
|
24
57
|
body: unknown,
|
|
25
58
|
): body is { code: number; data: unknown; error?: unknown } {
|
|
@@ -28,6 +61,10 @@ export function isCommonResponsePayload(
|
|
|
28
61
|
return typeof b.code === 'number' && 'data' in b;
|
|
29
62
|
}
|
|
30
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 중첩된 `message` 프로퍼티·문자열 배열 등에서 읽을 만한 첫 문자열을 꺼낸다.
|
|
66
|
+
* @returns 없으면 `null`
|
|
67
|
+
*/
|
|
31
68
|
function extractMessageString(value: unknown): string | null {
|
|
32
69
|
if (value == null) return null;
|
|
33
70
|
if (typeof value === 'string') return value;
|
|
@@ -42,7 +79,11 @@ function extractMessageString(value: unknown): string | null {
|
|
|
42
79
|
return null;
|
|
43
80
|
}
|
|
44
81
|
|
|
45
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* 서버 `error` 필드를 토스트/로그용 한 줄 문자열로 만든다.
|
|
84
|
+
* 구조가 복잡하면 JSON.stringify, 실패 시 fallback.
|
|
85
|
+
* @param error - 봉투의 `error` 필드 (타입 불명)
|
|
86
|
+
*/
|
|
46
87
|
export function formatCommonErrorField(error: unknown): string {
|
|
47
88
|
const extracted = extractMessageString(error);
|
|
48
89
|
if (extracted) return extracted;
|
|
@@ -60,7 +101,14 @@ export function formatCommonErrorField(error: unknown): string {
|
|
|
60
101
|
export type OnCommonBusinessError = (message: string, appCode: number) => void;
|
|
61
102
|
|
|
62
103
|
/**
|
|
63
|
-
* 성공
|
|
104
|
+
* axios 성공 콜백 안에서 호출한다.
|
|
105
|
+
*
|
|
106
|
+
* - 봉투가 아니면: 아무 것도 하지 않고 반환 (레거시 API).
|
|
107
|
+
* - 봉투 + `code !== 0`: `onBusinessError` 호출 후 `CommonResponseRejectedError` throw.
|
|
108
|
+
* - 봉투 + `code === 0`: `response.data`를 `body.data`로 **치환** (이후 파이프라인은 페이로드만 봄).
|
|
109
|
+
*
|
|
110
|
+
* @param response - axios 응답 (본문은 아직 봉투일 수 있음)
|
|
111
|
+
* @param onBusinessError - `code !== 0` 일 때 UI 알림 (예: 토스트)
|
|
64
112
|
*/
|
|
65
113
|
export function applyCommonResponseEnvelope(
|
|
66
114
|
response: AxiosResponse,
|
|
@@ -1,8 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* healthMethod — `execute` / `executeMethod('health')` 레지스트리 항목
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* `fetchHealth`와 차이
|
|
7
|
+
* ---------------------
|
|
8
|
+
* - 여기 경로는 **RootStore.execute → sendMessage**를 탄다.
|
|
9
|
+
* - `showIndicator: false`이면 `sendMessage`가 `skipGlobalIndicator: true`를 넘겨 **전역 로딩 바가 안 뜰** 수 있다.
|
|
10
|
+
* (조용한 폴링·백그라운드 동기화에 맞춤.)
|
|
11
|
+
*
|
|
12
|
+
* `unwrapDataArray: false`
|
|
13
|
+
* ------------------------
|
|
14
|
+
* - 코어 SDK 스타일 `{ data: [...] }` 언래핑을 하지 않고 응답을 그대로 쓴다.
|
|
15
|
+
* - health 본문이 단일 객체이면 그대로 두면 된다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
1
18
|
import type { HealthData } from '../../types/api';
|
|
2
19
|
import type { ApiMethodConfig, ApiMethodStore } from '../types';
|
|
3
20
|
import { healthEndpoint } from '@shared/endpoint';
|
|
4
21
|
|
|
5
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* `executeMethod('health')`용 설정. `showIndicator: false`로 전역 바 억제에 맞출 수 있음.
|
|
24
|
+
*/
|
|
6
25
|
export const healthMethod: ApiMethodConfig<void, HealthData, ApiMethodStore> = {
|
|
7
26
|
schema: {},
|
|
8
27
|
endpoint: healthEndpoint,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* API Method 레지스트리 + ParamStore 초기 스키마
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* - **methods**: `RootStore.executeMethod('health')` 등으로 이름 기반 호출할 워크플로 정의.
|
|
7
|
+
* 새 API 묶음은 `*.method.ts`로 추가하고 `methods` 객체에 키를 등록한다.
|
|
8
|
+
* - **screenParamSchemas**: `ParamStore`가 부팅 시 들고 있을 화면→필드 기본값.
|
|
9
|
+
* `execute` 전 `validate`에서 `paramStore.isScreenValid(...)` 같은 식으로 연동 가능.
|
|
3
10
|
*/
|
|
4
11
|
|
|
5
12
|
import type { ParamsSchema } from '../../params/types';
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* `sendRequest` — EndpointDef 기반 단발 HTTP 호출
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 이 함수는 **도메인 로직이 없다**. 오직 다음만 수행한다:
|
|
7
|
+
* - `path`의 `:param` 플레이스홀더 치환
|
|
8
|
+
* - querystring 조립
|
|
9
|
+
* - GET/DELETE vs POST/PUT/PATCH 에 따른 body 실을지 여부
|
|
10
|
+
* - `apiClient.request` 호출 → 인터셉터(토큰·봉투·로깅·전역 로딩) 전부 적용
|
|
11
|
+
* - 응답 본문에 `endpoint.post`가 있으면 한 번 더 가공 (레거시 언래핑 등)
|
|
12
|
+
*
|
|
13
|
+
* `RootStore.sendMessage`와 `fetchHealth` 같은 얇은 헬퍼는 모두 여기로 모인다.
|
|
14
|
+
*
|
|
15
|
+
* `skipGlobalIndicator`
|
|
16
|
+
* ---------------------
|
|
17
|
+
* `true`이면 해당 요청은 axios 인터셉터의 `onRequestStart`/`End`를 건너뛴다.
|
|
18
|
+
* `sendMessage`는 `showIndicator`와 연동해 `skipGlobalIndicator: !showIndicator`로 넘긴다.
|
|
3
19
|
*/
|
|
4
20
|
|
|
5
21
|
import type { EndpointDef } from '@shared/endpoint';
|
|
6
22
|
import { apiClient } from './client';
|
|
7
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `path` 안의 `:키` 토큰을 `params[키]` 값으로 치환한다.
|
|
26
|
+
* @param path - 예: `/users/:id`
|
|
27
|
+
* @param params - 예: `{ id: '42' }` → `/users/42` (값은 encodeURIComponent 처리)
|
|
28
|
+
*/
|
|
8
29
|
function buildPath(path: string, params?: Record<string, string>): string {
|
|
9
30
|
if (!params) return path;
|
|
10
31
|
let out = path;
|
|
@@ -14,6 +35,10 @@ function buildPath(path: string, params?: Record<string, string>): string {
|
|
|
14
35
|
return out;
|
|
15
36
|
}
|
|
16
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 객체를 `?a=1&b=2` 형태의 쿼리 문자열로 만든다.
|
|
40
|
+
* `undefined`·빈 문자열 키는 제외한다.
|
|
41
|
+
*/
|
|
17
42
|
function buildQuery(query?: Record<string, string | number | boolean | undefined>): string {
|
|
18
43
|
if (!query) return '';
|
|
19
44
|
const search = new URLSearchParams();
|
|
@@ -28,9 +53,16 @@ export interface RequestTransportOptions<T = unknown> {
|
|
|
28
53
|
params?: Record<string, string>;
|
|
29
54
|
query?: Record<string, string | number | boolean | undefined>;
|
|
30
55
|
body?: T;
|
|
56
|
+
/** true면 전역 HTTP 인디케이터(카운터)를 이 요청에 대해 쓰지 않음 */
|
|
31
57
|
skipGlobalIndicator?: boolean;
|
|
32
58
|
}
|
|
33
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 단일 엔드포인트에 대한 HTTP 요청을 수행하고, 응답 본문(가공 후)을 반환한다.
|
|
62
|
+
* @param endpoint - 메서드·경로·선택적 `post`·`timeoutMs`·`showIndicator`(sendMessage 경로에서만 의미)
|
|
63
|
+
* @param options - path 치환·쿼리·body·전역 인디케이터 스킵
|
|
64
|
+
* @returns `endpoint.post`가 있으면 그 반환값, 없으면 인터셉터까지 거친 `response.data`
|
|
65
|
+
*/
|
|
34
66
|
export async function sendRequest<T = unknown, R = unknown>(
|
|
35
67
|
endpoint: EndpointDef,
|
|
36
68
|
options: RequestTransportOptions<T> = {},
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* ApiMethodConfig 타입 — RootStore.execute 와 공유
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* `ApiMethodStore`는 **RootStore 전체가 아니라** execute 콜백에 필요한 최소 면만 노출한다.
|
|
7
|
+
* (types → store → types 순환 import 방지)
|
|
8
|
+
*
|
|
9
|
+
* - **CoreSdkWrapped**: 백엔드가 `{ data: T | T[] }` 형태로 한 겹 더 싸는 레거시 대응.
|
|
10
|
+
* - **ApiMethodUiConfig**: 인디케이터·토스트·unwrap 동작을 UI 정책으로 묶음.
|
|
11
|
+
* - **endpoints**: 단일 `endpoint` 대신 순차 호출할 서브 호출 배열 (다중 배치 시 전역 바 억제와 연동).
|
|
3
12
|
*/
|
|
4
13
|
|
|
5
14
|
import type { EndpointDef } from '@shared/endpoint';
|
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* StoreProvider — React 트리와 `rootStore`(MobX 싱글톤) 연결
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 왜 Provider가 있는가?
|
|
7
|
+
* ----------------------
|
|
8
|
+
* - `rootStore`는 모듈 싱글톤이라 원칙적으로 어디서든 import 가능하다.
|
|
9
|
+
* - 그래도 `useStore()`는 **Provider 안에서만** 쓰게 강제해, 테스트 시 mock 주입·
|
|
10
|
+
* “스토어 없는 트리” 실수를 막기 쉽게 했다.
|
|
11
|
+
*
|
|
12
|
+
* 최상위 UI (앱 전역, 페이지와 무관)
|
|
13
|
+
* ----------------------------------
|
|
14
|
+
* 1) **GlobalHttpIndicatorView** — `rootStore.indicator.loading`
|
|
15
|
+
* - axios 인터셉터의 `onRequestStart`/`End`가 `_requestDepth`를 올리고,
|
|
16
|
+
* 깊이가 0→1일 때 상단 얇은 로딩 바를 표시한다.
|
|
17
|
+
* - `skipGlobalIndicator` 또는 `execute` 다중 엔드포인트 배치 시에는 동작이 달라질 수 있음.
|
|
18
|
+
*
|
|
19
|
+
* 2) **GlobalToastStackView** — `rootStore.toasts` → `@xfilecom/front-core` `ToastList`
|
|
20
|
+
* - 행별 `dismissible`: **error**만 닫기. **info/success/warn**은 타이머 자동 제거만.
|
|
21
|
+
*
|
|
22
|
+
* 훅들
|
|
23
|
+
* -----
|
|
24
|
+
* - `useSendMessage` — `endpoint` + 옵션으로 `rootStore.sendMessage` 래핑
|
|
25
|
+
* - `useParamStore` / `useSessionStore` / `useRootStore` — 편의 접근자
|
|
4
26
|
*/
|
|
5
27
|
|
|
6
28
|
import React, { createContext, useCallback, useContext } from 'react';
|
|
29
|
+
import { ToastList } from '@xfilecom/front-core';
|
|
30
|
+
import { observer } from 'mobx-react-lite';
|
|
7
31
|
import type { EndpointDef } from '@shared/endpoint';
|
|
8
32
|
import type { SendMessageOptions } from '../types/ui';
|
|
9
33
|
import { rootStore } from '../store/root-store';
|
|
@@ -12,16 +36,68 @@ type RootStoreApi = typeof rootStore;
|
|
|
12
36
|
|
|
13
37
|
const StoreContext = createContext<RootStoreApi | null>(null);
|
|
14
38
|
|
|
39
|
+
/**
|
|
40
|
+
* `rootStore.indicator.loading`이 true일 때만 상단 고정 로딩 바를 렌더한다.
|
|
41
|
+
* @returns 없으면 `null`
|
|
42
|
+
*/
|
|
43
|
+
const GlobalHttpIndicatorView = observer(function GlobalHttpIndicatorView() {
|
|
44
|
+
if (!rootStore.indicator.loading) return null;
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className="xfc-global-http-indicator"
|
|
48
|
+
role="progressbar"
|
|
49
|
+
aria-busy="true"
|
|
50
|
+
aria-label="Loading"
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `ToastList` + `ToastEntry.dismissible` — error만 수동 닫기, 나머지는 RootStore 타이머.
|
|
57
|
+
*/
|
|
58
|
+
const GlobalToastStackView = observer(function GlobalToastStackView() {
|
|
59
|
+
const entries = rootStore.toasts.map((t) => ({
|
|
60
|
+
id: t.id,
|
|
61
|
+
severity: t.severity,
|
|
62
|
+
message: t.message,
|
|
63
|
+
dismissible: t.severity === 'error',
|
|
64
|
+
}));
|
|
65
|
+
return (
|
|
66
|
+
<ToastList
|
|
67
|
+
toasts={entries}
|
|
68
|
+
dismissAriaLabel="닫기"
|
|
69
|
+
onDismiss={(id) => {
|
|
70
|
+
rootStore.removeToast(id);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Provider에 주입된 `rootStore` 반환. Provider 밖이면 예외.
|
|
78
|
+
*/
|
|
15
79
|
export function useStore(): RootStoreApi {
|
|
16
80
|
const ctx = useContext(StoreContext);
|
|
17
81
|
if (!ctx) throw new Error('StoreProvider is required');
|
|
18
82
|
return ctx;
|
|
19
83
|
}
|
|
20
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 컨텍스트 + 전역 로딩 바 + 전역 토스트 + 자식 트리.
|
|
87
|
+
*/
|
|
21
88
|
export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
22
|
-
return
|
|
89
|
+
return (
|
|
90
|
+
<StoreContext.Provider value={rootStore}>
|
|
91
|
+
<GlobalHttpIndicatorView />
|
|
92
|
+
<GlobalToastStackView />
|
|
93
|
+
{children}
|
|
94
|
+
</StoreContext.Provider>
|
|
95
|
+
);
|
|
23
96
|
}
|
|
24
97
|
|
|
98
|
+
/**
|
|
99
|
+
* `rootStore.sendMessage`를 안정적인 콜백으로 반환.
|
|
100
|
+
*/
|
|
25
101
|
export function useSendMessage() {
|
|
26
102
|
const store = useStore();
|
|
27
103
|
return useCallback(
|
|
@@ -32,14 +108,17 @@ export function useSendMessage() {
|
|
|
32
108
|
);
|
|
33
109
|
}
|
|
34
110
|
|
|
111
|
+
/** 화면 파라미터 스토어 단축 접근 */
|
|
35
112
|
export function useParamStore() {
|
|
36
113
|
return useStore().paramStore;
|
|
37
114
|
}
|
|
38
115
|
|
|
116
|
+
/** 토큰·로그아웃 콜백 */
|
|
39
117
|
export function useSessionStore() {
|
|
40
118
|
return useStore().sessionStore;
|
|
41
119
|
}
|
|
42
120
|
|
|
121
|
+
/** `useStore()`와 동일 — 의미상 “전체 루트” 강조용 별칭 */
|
|
43
122
|
export function useRootStore(): RootStoreApi {
|
|
44
123
|
return useStore();
|
|
45
124
|
}
|
|
@@ -1,10 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* useAppStore — MobX `rootStore`를 React 18 `useSyncExternalStore`로 구독
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 사용 패턴
|
|
7
|
+
* ---------
|
|
8
|
+
* Zustand의 `useStore(selector)`와 비슷하게, **필요한 조각만** 구독해 리렌더 범위를 줄인다.
|
|
9
|
+
*
|
|
10
|
+
* const n = useAppStore((s) => s.healthData);
|
|
11
|
+
* const logout = useAppStore((s) => s.sessionStore.logout); // 주의: 아래 참고
|
|
12
|
+
*
|
|
13
|
+
* MobX `reaction`으로 `selector(rootStore)` 결과가 바뀔 때만 `onChange`를 호출한다.
|
|
14
|
+
*
|
|
15
|
+
* this 바인딩 주의
|
|
16
|
+
* ----------------
|
|
17
|
+
* **클래스 프로토타입 메서드**를 셀렉터로 꺼내 콜백에 넘기면 `this`가 undefined가 된다.
|
|
18
|
+
* 그 경우 스토어 쪽에서 **화살표 필드 메서드**로 정의하거나, 호출부에서 `.bind(store)` 할 것.
|
|
19
|
+
* (`setLastHealthAt` 등은 템플릿에서 화살표로 정의해 둠.)
|
|
20
|
+
*
|
|
21
|
+
* 대안
|
|
22
|
+
* ----
|
|
23
|
+
* 컴포넌트 전체를 `observer()`로 감싸고 `rootStore`를 직접 읽는 방식도 가능하다.
|
|
24
|
+
* 이 훅은 “일부 필드만” 구독할 때 유리하다.
|
|
25
|
+
*/
|
|
26
|
+
|
|
1
27
|
import { reaction } from 'mobx';
|
|
2
28
|
import { useSyncExternalStore } from 'react';
|
|
3
29
|
import { rootStore } from '../store/root-store';
|
|
4
30
|
|
|
5
31
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
32
|
+
* @param selector - `rootStore`에서 구독할 조각; 참조 동등이 아니라 MobX 추적 값이 바뀔 때 리렌더
|
|
33
|
+
* @returns selector의 현재 결과
|
|
8
34
|
*/
|
|
9
35
|
export function useAppStore<T>(selector: (store: typeof rootStore) => T): T {
|
|
10
36
|
return useSyncExternalStore(
|
|
@@ -1,38 +1,57 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* useHealthStatus — 데모용 `/health` 폴링 훅
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 하는 일
|
|
7
|
+
* --------
|
|
8
|
+
* - 마운트(및 `apiBaseUrl` 변경) 시 `fetchHealth()` 호출.
|
|
9
|
+
* - 성공/실패 결과를 `rootStore.healthData` / `rootStore.healthError`에 반영 (`runInAction`은 스토어 메서드 내부).
|
|
10
|
+
* - 컴포넌트는 `useAppStore`로 `data`·`error`를 구독해 화면에 쓸 수 있다.
|
|
11
|
+
*
|
|
12
|
+
* 로딩·에러 UI 정책 (템플릿 기본)
|
|
13
|
+
* ------------------------------
|
|
14
|
+
* - **로딩**: 별도 `isLoading` 상태를 두지 않는다. `fetchHealth`는 `sendRequest`를 타므로
|
|
15
|
+
* axios → `indicator` → `StoreProvider` 상단 바가 진행을 표시한다.
|
|
16
|
+
* - **HTTP/비즈니스 에러 메시지**: `client` 인터셉터의 `onError` / `onCommonBusinessError`가
|
|
17
|
+
* 전역 토스트로 이미 알릴 수 있다. `healthError`는 스토어에 남아 폼·디버그 등에 재사용 가능.
|
|
18
|
+
*
|
|
19
|
+
* 언마운트
|
|
20
|
+
* --------
|
|
21
|
+
* `cancelled` 플래그로 stale 응답이 스토어를 덮어쓰지 않게 한다.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useEffect } from 'react';
|
|
2
25
|
import { configureApi } from '../api/client';
|
|
3
26
|
import { fetchHealth } from '../methods/health';
|
|
4
|
-
import {
|
|
27
|
+
import { rootStore } from '../store/root-store';
|
|
5
28
|
import { useAppStore } from './useAppStore';
|
|
6
29
|
|
|
7
30
|
/**
|
|
8
|
-
* @param apiBaseUrl
|
|
31
|
+
* @param apiBaseUrl - 넘기면 effect 안에서 `configureApi`로 baseURL 갱신 (entry에서 이미 했으면 생략 가능)
|
|
32
|
+
* @returns `{ data, error }` — 둘 다 `rootStore` 기반 구독
|
|
9
33
|
*/
|
|
10
34
|
export function useHealthStatus(apiBaseUrl?: string) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const data = useAppStore((s) => s.healthData);
|
|
36
|
+
const error = useAppStore((s) => s.healthError);
|
|
14
37
|
|
|
15
38
|
useEffect(() => {
|
|
16
39
|
if (apiBaseUrl != null && apiBaseUrl !== '') {
|
|
17
40
|
configureApi({ baseURL: apiBaseUrl });
|
|
18
41
|
}
|
|
19
42
|
let cancelled = false;
|
|
20
|
-
|
|
21
|
-
setText('loading…');
|
|
43
|
+
rootStore.applyHealthFetchStart();
|
|
22
44
|
fetchHealth()
|
|
23
45
|
.then((body) => {
|
|
24
|
-
if (!cancelled)
|
|
25
|
-
setText(safeJsonStringify(body, 2));
|
|
26
|
-
setLastHealthAt(Date.now());
|
|
27
|
-
}
|
|
46
|
+
if (!cancelled) rootStore.applyHealthFetchSuccess(body);
|
|
28
47
|
})
|
|
29
|
-
.catch((e:
|
|
30
|
-
if (!cancelled)
|
|
48
|
+
.catch((e: unknown) => {
|
|
49
|
+
if (!cancelled) rootStore.applyHealthFetchFailure(e);
|
|
31
50
|
});
|
|
32
51
|
return () => {
|
|
33
52
|
cancelled = true;
|
|
34
53
|
};
|
|
35
|
-
}, [apiBaseUrl
|
|
54
|
+
}, [apiBaseUrl]);
|
|
36
55
|
|
|
37
|
-
return {
|
|
56
|
+
return { data, error };
|
|
38
57
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite로 번들될 때 `DEV`/`PROD`가 정적으로 치환된다. `web/shared` 단독 `tsc`용 최소 선언.
|
|
3
|
+
* (소비 앱의 `vite/client`와 필드가 겹쳐도 병합된다.)
|
|
4
|
+
*/
|
|
5
|
+
interface ImportMetaEnv {
|
|
6
|
+
readonly DEV: boolean;
|
|
7
|
+
readonly PROD: boolean;
|
|
8
|
+
readonly MODE: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ImportMeta {
|
|
12
|
+
readonly env: ImportMetaEnv;
|
|
13
|
+
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* fetchHealth — `/health` 직접 호출 (스토어·토스트 없음)
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* `useHealthStatus`처럼 UI에서 부를 때 사용한다.
|
|
7
|
+
* - `sendRequest` → `apiClient` → 인터셉터(토큰·봉투·전역 로딩·로그) 전부 탄다.
|
|
8
|
+
* - `RootStore.sendMessage`를 쓰지 않으므로 **sendMessage 전용** `showIndicator` 기본값 해석은
|
|
9
|
+
* `healthEndpoint.showIndicator`에만 의존하지 않고, `skipGlobalIndicator` 미전달 시 **전역 바**가 뜬다.
|
|
10
|
+
*
|
|
11
|
+
* 반환 타입 `HealthData`는 공통 봉투가 제거된 뒤의 본문 형태를 가정한다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
1
14
|
import { sendRequest } from '../api/request';
|
|
2
15
|
import { healthEndpoint } from '@shared/endpoint';
|
|
3
16
|
import type { HealthData } from '../types/api';
|
|
4
17
|
|
|
5
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* @returns 봉투 제거 후 `/health` JSON 본문 (`HealthData`)
|
|
20
|
+
*/
|
|
6
21
|
export async function fetchHealth(): Promise<HealthData> {
|
|
7
22
|
return sendRequest<unknown, HealthData>(healthEndpoint, {});
|
|
8
23
|
}
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* ApiCacheStore — 메모리 캐시 (싱글톤, TTL 선택)
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* `RootStore.getCachedOrExecute`가 사용한다.
|
|
7
|
+
* - `ttlMs`를 주면 `staleAt = now + ttl` 이후 `get` 시 엔트리 삭제 후 `undefined`.
|
|
8
|
+
* - `ttlMs` 생략 시 `staleAt === 0`으로 “만료 없음”(명시적 `clear` 전까지 유지).
|
|
9
|
+
*
|
|
10
|
+
* 영속 저장소나 SWR과는 별개 — 가벼운 중복 요청 방지용.
|
|
11
|
+
*/
|
|
12
|
+
|
|
1
13
|
import { makeAutoObservable } from 'mobx';
|
|
2
14
|
|
|
3
15
|
interface CacheEntry<T = unknown> {
|
|
@@ -12,6 +24,9 @@ export class ApiCacheStore {
|
|
|
12
24
|
makeAutoObservable(this);
|
|
13
25
|
}
|
|
14
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 키에 대한 값 반환. TTL이 지났으면 엔트리 삭제 후 `undefined`.
|
|
29
|
+
*/
|
|
15
30
|
get<T = unknown>(key: string): T | undefined {
|
|
16
31
|
const entry = this._entries.get(key);
|
|
17
32
|
if (!entry) return undefined;
|
|
@@ -22,11 +37,15 @@ export class ApiCacheStore {
|
|
|
22
37
|
return entry.value as T;
|
|
23
38
|
}
|
|
24
39
|
|
|
40
|
+
/**
|
|
41
|
+
* @param ttlMs - 생략 시 만료 없음 (`staleAt === 0`)
|
|
42
|
+
*/
|
|
25
43
|
set<T = unknown>(key: string, value: T, ttlMs?: number): void {
|
|
26
44
|
const staleAt = ttlMs != null ? Date.now() + ttlMs : 0;
|
|
27
45
|
this._entries.set(key, { value, staleAt });
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
/** 전 엔트리 삭제 (로그아웃 등) */
|
|
30
49
|
clear(): void {
|
|
31
50
|
this._entries.clear();
|
|
32
51
|
}
|