@xfilecom/xframe 0.1.38 → 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.
Files changed (32) hide show
  1. package/bin/xframe.js +7 -1
  2. package/defaults.json +2 -2
  3. package/package.json +1 -1
  4. package/template/apps/api/src/main.ts +36 -1
  5. package/template/docs/SCAFFOLD_CHECKLIST.md +13 -0
  6. package/template/shared/README.md +1 -1
  7. package/template/shared/endpoint/endpoint.ts +22 -5
  8. package/template/web/admin/src/App.tsx +1 -1
  9. package/template/web/admin/src/main.tsx +10 -1
  10. package/template/web/admin/src/vite-env.d.ts +3 -2
  11. package/template/web/admin/vite.config.ts +2 -2
  12. package/template/web/client/src/App.tsx +1 -1
  13. package/template/web/client/src/FrontCoreShowcase.tsx +44 -1
  14. package/template/web/client/src/main.tsx +11 -1
  15. package/template/web/client/src/vite-env.d.ts +3 -2
  16. package/template/web/client/vite.config.ts +3 -2
  17. package/template/web/shared/README.md +5 -0
  18. package/template/web/shared/src/api/client.ts +66 -7
  19. package/template/web/shared/src/api/commonResponse.ts +53 -5
  20. package/template/web/shared/src/api/methods/health.method.ts +20 -1
  21. package/template/web/shared/src/api/methods/index.ts +8 -1
  22. package/template/web/shared/src/api/request.ts +33 -1
  23. package/template/web/shared/src/api/types.ts +10 -1
  24. package/template/web/shared/src/context/StoreProvider.tsx +44 -3
  25. package/template/web/shared/src/hooks/useAppStore.ts +28 -3
  26. package/template/web/shared/src/hooks/useHealthStatus.ts +25 -2
  27. package/template/web/shared/src/import-meta.d.ts +13 -0
  28. package/template/web/shared/src/methods/health.ts +16 -1
  29. package/template/web/shared/src/store/api-cache-store.ts +19 -0
  30. package/template/web/shared/src/store/root-store.ts +142 -8
  31. package/template/web/shared/src/store/session-store.ts +21 -2
  32. package/template/web/shared/src/types/ui.ts +7 -1
@@ -1,5 +1,47 @@
1
1
  /**
2
- * Root store — session, param, cache, indicator, execute/sendMessage (business-promotion 패턴 축소판)
2
+ * =============================================================================
3
+ * RootStore — 프론트 “앱 오케스트레이션” 허브 (MobX)
4
+ * =============================================================================
5
+ *
6
+ * 포함 관계
7
+ * ---------
8
+ * - **sessionStore** (싱글톤): JWT 등 — `api/client`가 Authorization 헤더에 반영.
9
+ * - **apiCacheStore** (싱글톤): `getCachedOrExecute`용 짧은 TTL 캐시.
10
+ * - **paramStore** (인스턴스): 화면별 URL/폼 파라미터 스키마. `getStore: () => this`로
11
+ * 검증 시 RootStore에 접근 가능.
12
+ *
13
+ * HTTP / UI 연동 (가장 중요)
14
+ * ---------------------------
15
+ * 생성자에서 `setApiHooks`로 axios와 연결한다:
16
+ *
17
+ * | 훅 | 호출 시점 | 템플릿 기본 동작 |
18
+ * |----|-----------|------------------|
19
+ * | onRequestStart/End | 각 apiClient 요청 (skip 아닐 때) | `_requestDepth` 증감 → `indicator.loading` |
20
+ * | onError | HTTP 실패 응답·네트워크 등 | `addToast(..., 'error')` |
21
+ * | onCommonBusinessError | HTTP 2xx + 본문 `code !== 0` | 동일 |
22
+ *
23
+ * 따라서 **대부분의 API 에러 알림은 페이지가 아니라 여기서 토스트**로 처리된다.
24
+ * `sendMessage` / `execute`의 catch에서는 `axios.isAxiosError`·`CommonResponseRejectedError`일 때
25
+ * **중복 토스트를 피하도록** 이미 인터셉터에서 처리된 경우를 건너뛴다.
26
+ *
27
+ * 토스트 UX (템플릿 기본)
28
+ * ----------------------
29
+ * - **error**: 자동 제거 없음 — 사용자가 닫기만 가능 (`StoreProvider`에서 `dismissible` true).
30
+ * - **info / success / warn**: `TOAST_AUTO_DISMISS_MS` 후 자동 제거, 닫기 버튼 없음.
31
+ *
32
+ * execute / sendMessage
33
+ * ----------------------
34
+ * - **sendMessage**: 단일 `EndpointDef` + 옵션. `showIndicator`에 따라 (1) 로컬 `setIndicator`와
35
+ * (2) axios `skipGlobalIndicator`를 맞춘다. 전역 바만 쓰려면 엔드포인트/`showIndicator` 정책을 통일할 것.
36
+ * - **execute**: `ApiMethodConfig` 기반 워크플로 — 검증, `sendMessage` 또는 다중 `sendRequest`,
37
+ * `post` 훅, `redirectPath`, 토스트 옵션 등.
38
+ * - **다중 엔드포인트 배치** (`config.endpoints`): `_multiEndpointBatch`로 axios 전역 카운터를
39
+ * 잠시 억제하는 패턴이 있음(배치 중 개별 요청이 바를 깜빡이지 않게).
40
+ *
41
+ * Health 데모 필드
42
+ * ----------------
43
+ * `healthData`, `healthError`, `lastHealthAt` — `useHealthStatus` + 샘플 UI용.
44
+ * 프로덕션 앱에서는 도메인 스토어로 옮기거나 제거해도 된다.
3
45
  */
4
46
 
5
47
  import axios from 'axios';
@@ -31,10 +73,17 @@ import type { ApiErrorItem, IndicatorState, SendMessageOptions, ToastItem } from
31
73
  import type { ToastSeverity } from '../types/ui';
32
74
  import type { HealthData } from '../types/api';
33
75
 
76
+ /** 토스트·에러 항목 등에 쓰는 짧은 고유 id (타임스탬프 + 랜덤) */
34
77
  function genId() {
35
78
  return `id_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
36
79
  }
37
80
 
81
+ /**
82
+ * 코어 SDK 스타일 `{ data: T | T[] }` 래핑을 한 겹 벗긴다.
83
+ * 배열이면 첫 요소를 대표값으로 쓰는 레거시 규칙이 있음 (`unwrapDataArray`).
84
+ * @param res - API 응답 본문(또는 이미 펼친 T)
85
+ * @param unwrapDataArray - false면 `res`를 그대로 T로 취급
86
+ */
38
87
  function unwrapCoreSdk<T>(res: T | CoreSdkWrapped<T>, unwrapDataArray: boolean): T | null {
39
88
  if (!unwrapDataArray) return res as T;
40
89
  const r = res as CoreSdkWrapped<T>;
@@ -45,6 +94,9 @@ function unwrapCoreSdk<T>(res: T | CoreSdkWrapped<T>, unwrapDataArray: boolean):
45
94
  return res as T;
46
95
  }
47
96
 
97
+ /**
98
+ * API 에러 본문 등에서 사람이 읽을 문자열 후보를 재귀적으로 추출한다.
99
+ */
48
100
  function extractMessageString(value: unknown): string | null {
49
101
  if (value == null) return null;
50
102
  if (typeof value === 'string') return value;
@@ -59,6 +111,10 @@ function extractMessageString(value: unknown): string | null {
59
111
  return null;
60
112
  }
61
113
 
114
+ /**
115
+ * `sendMessage`/`execute` catch 등에서 토스트에 넣을 문구를 만든다.
116
+ * @param e - AxiosError, CommonResponseRejectedError, 일반 Error 등
117
+ */
62
118
  function getErrorMessage(e: unknown): string {
63
119
  if (e instanceof CommonResponseRejectedError) return e.message;
64
120
  const err = e as { response?: { data?: unknown }; message?: string };
@@ -81,10 +137,17 @@ export class RootStore {
81
137
  getStore: () => this,
82
138
  });
83
139
 
140
+ /** 전역 로딩 + (선택) 메시지, 현재 실행 중 method 이름 등 */
84
141
  private _indicator: IndicatorState = { loading: false, methodLoading: null };
142
+ /** 패널용 에러 리스트 — 토스트와 별도 */
85
143
  private _errors: ApiErrorItem[] = [];
144
+ /** StoreProvider 전역 토스트 스택 데이터 소스 */
86
145
  private _toasts: ToastItem[] = [];
146
+ /** info/success/warn 자동 제거용 — `removeToast`·타이머 콜백에서 정리 */
147
+ private _toastAutoDismissTimers = new Map<string, ReturnType<typeof setTimeout>>();
148
+ /** axios onRequestStart가 중첩 호출될 때를 위한 깊이 카운터 */
87
149
  private _requestDepth = 0;
150
+ /** execute 다중 엔드포인트 루프 중 전역 인디케이터 억제 */
88
151
  private _multiEndpointBatch = false;
89
152
 
90
153
  /** 데모·useHealthStatus 용 타임스탬프 */
@@ -94,6 +157,9 @@ export class RootStore {
94
157
  healthData: HealthData | null = null;
95
158
  healthError: string | null = null;
96
159
 
160
+ /**
161
+ * MobX 관찰 가능 필드로 등록하고, 세션 로그아웃 시 캐시 비우기·axios 훅을 `rootStore`에 연결한다.
162
+ */
97
163
  constructor() {
98
164
  makeAutoObservable(this);
99
165
  this.sessionStore.registerLogoutCallback(() => {
@@ -119,30 +185,40 @@ export class RootStore {
119
185
  });
120
186
  }
121
187
 
188
+ /** 전역 로딩·옵션 메시지·현재 method 로딩 키 */
122
189
  get indicator(): IndicatorState {
123
190
  return this._indicator;
124
191
  }
125
192
 
193
+ /** 패널/리스트용 에러 히스토리 (토스트와 별도) */
126
194
  get errors(): ApiErrorItem[] {
127
195
  return this._errors;
128
196
  }
129
197
 
198
+ /** `GlobalToastStackView`가 구독하는 스택 */
130
199
  get toasts(): ToastItem[] {
131
200
  return this._toasts;
132
201
  }
133
202
 
134
- /** useAppStore 셀렉터로 넘길 때 this 유지 */
203
+ /**
204
+ * 마지막 health 성공 시각(밀리초). 화살표 필드라 `useAppStore(s => s.setLastHealthAt)`로 꺼내 호출해도 안전.
205
+ * @param t - `Date.now()` 등
206
+ */
135
207
  setLastHealthAt = (t: number) => {
136
208
  this.lastHealthAt = t;
137
209
  };
138
210
 
139
- /** 로딩 표시는 `indicator`(axios 훅) 여기선 에러만 초기화 */
211
+ /** `/health` 요청 직전: `healthError`만 초기화 (진행 표시는 axios `indicator`) */
140
212
  applyHealthFetchStart = () => {
141
213
  runInAction(() => {
142
214
  this.healthError = null;
143
215
  });
144
216
  };
145
217
 
218
+ /**
219
+ * `/health` 성공 시 스토어에 페이로드 반영.
220
+ * @param data - 인터셉터에서 봉투 제거된 뒤의 본문
221
+ */
146
222
  applyHealthFetchSuccess = (data: HealthData) => {
147
223
  runInAction(() => {
148
224
  this.healthError = null;
@@ -151,12 +227,16 @@ export class RootStore {
151
227
  });
152
228
  };
153
229
 
230
+ /**
231
+ * `/health` 실패 시 `healthError`에 정규화된 메시지 저장 (토스트는 인터셉터가 담당할 수 있음).
232
+ */
154
233
  applyHealthFetchFailure = (err: unknown) => {
155
234
  runInAction(() => {
156
235
  this.healthError = getErrorMessage(err);
157
236
  });
158
237
  };
159
238
 
239
+ /** axios `onRequestStart`에서 호출: 중첩 요청 시 깊이만 증가, 첫 요청에서만 `loading: true` */
160
240
  private _startRequest() {
161
241
  this._requestDepth++;
162
242
  if (this._requestDepth === 1) {
@@ -164,6 +244,7 @@ export class RootStore {
164
244
  }
165
245
  }
166
246
 
247
+ /** axios `onRequestEnd`에서 호출: 깊이 0이 되면 `loading: false` */
167
248
  private _endRequest() {
168
249
  this._requestDepth = Math.max(0, this._requestDepth - 1);
169
250
  if (this._requestDepth === 0) {
@@ -171,41 +252,80 @@ export class RootStore {
171
252
  }
172
253
  }
173
254
 
255
+ /**
256
+ * `sendMessage` 등에서 쓰는 로컬 인디케이터(전역 바와 별개로 메시지 동반 가능).
257
+ */
174
258
  setIndicator(loading: boolean, message?: string) {
175
259
  this._indicator = { ...this._indicator, loading, message };
176
260
  }
177
261
 
262
+ /**
263
+ * `executeMethod`가 어떤 키를 실행 중인지 UI에 표시할 때 사용.
264
+ */
178
265
  setMethodLoading(methodName: string | null) {
179
266
  this._indicator = { ...this._indicator, methodLoading: methodName };
180
267
  }
181
268
 
269
+ /**
270
+ * 토스트가 아닌 “에러 패널”용 항목 추가 (최대 50건 유지).
271
+ */
182
272
  addError(message: string, code?: string | number) {
183
273
  this._errors = [{ id: genId(), message, code, createdAt: Date.now() }, ...this._errors].slice(0, 50);
184
274
  }
185
275
 
276
+ /** `addError`로 넣은 한 건 제거 */
186
277
  clearError(id: string) {
187
278
  this._errors = this._errors.filter((e) => e.id !== id);
188
279
  }
189
280
 
281
+ /** 에러 패널 전체 비우기 */
190
282
  clearAllErrors() {
191
283
  this._errors = [];
192
284
  }
193
285
 
194
286
  private static readonly TOAST_AUTO_DISMISS_MS = 2000;
195
287
 
288
+ /**
289
+ * 동일 메시지+심각도 연속 스팸 방지.
290
+ * - **error**: 타이머 없음 — 닫기만 제거 (`GlobalToastStackView`에서 error만 `dismissible`).
291
+ * - **그 외**: `TOAST_AUTO_DISMISS_MS` 후 자동 제거.
292
+ * @param message - 토스트 본문
293
+ * @param severity - `info` | `success` | `warn` | `error`
294
+ */
196
295
  addToast(message: string, severity: ToastSeverity = 'info') {
197
296
  if (this._toasts.some((t) => t.message === message && t.severity === severity)) return;
198
297
  const id = genId();
199
298
  this._toasts = [...this._toasts, { id, message, severity, createdAt: Date.now() }].slice(-20);
200
- setTimeout(() => {
201
- runInAction(() => this.removeToast(id));
202
- }, RootStore.TOAST_AUTO_DISMISS_MS);
299
+ if (severity !== 'error') {
300
+ const tid = setTimeout(() => {
301
+ runInAction(() => {
302
+ this._toastAutoDismissTimers.delete(id);
303
+ this.removeToast(id);
304
+ });
305
+ }, RootStore.TOAST_AUTO_DISMISS_MS);
306
+ this._toastAutoDismissTimers.set(id, tid);
307
+ }
203
308
  }
204
309
 
310
+ /** 닫기 클릭 또는 비-error 자동 제거 타이머에서 호출 */
205
311
  removeToast(id: string) {
312
+ const pending = this._toastAutoDismissTimers.get(id);
313
+ if (pending !== undefined) {
314
+ clearTimeout(pending);
315
+ this._toastAutoDismissTimers.delete(id);
316
+ }
206
317
  this._toasts = this._toasts.filter((t) => t.id !== id);
207
318
  }
208
319
 
320
+ /**
321
+ * 등록된 `ApiMethodConfig` 워크플로 실행.
322
+ * - `endpoints` 배열이 있으면 순차 `sendRequest` (다중 배치 플래그 사용).
323
+ * - 아니면 단일 `sendMessage` 후 unwrap/post/redirect.
324
+ *
325
+ * @param config - 엔드포인트·UI 옵션·validate·post·redirectPath 등
326
+ * @param payload - 메서드 인자(바디/쿼리 빌더에서 사용)
327
+ * @param ctx - 라우터 `navigate` 등 실행 컨텍스트
328
+ */
209
329
  async execute<TPayload = unknown, TResult = unknown>(
210
330
  config: ApiMethodConfig<TPayload, TResult>,
211
331
  payload?: TPayload,
@@ -322,6 +442,10 @@ export class RootStore {
322
442
  return finalResult;
323
443
  }
324
444
 
445
+ /**
446
+ * `methods` 레지스트리에 등록된 키로 `execute` 호출. `indicator.methodLoading` 설정.
447
+ * @param methodName - 예: `'health'`
448
+ */
325
449
  async executeMethod<K extends MethodName>(
326
450
  methodName: K,
327
451
  payload?: (typeof methods)[K] extends ApiMethodConfig<infer P, unknown> ? P : never,
@@ -343,6 +467,11 @@ export class RootStore {
343
467
  }
344
468
  }
345
469
 
470
+ /**
471
+ * 캐시 히트 시 네트워크 생략, 미스 시 `executeMethod` 후 저장.
472
+ * @param cacheKey - 호출자 정의 문자열(화면+파라미터 해시 등)
473
+ * @param ttlMs - 만료(ms); 생략 시 만료 없음
474
+ */
346
475
  async getCachedOrExecute<K extends MethodName>(
347
476
  cacheKey: string,
348
477
  methodName: K,
@@ -360,8 +489,13 @@ export class RootStore {
360
489
  }
361
490
 
362
491
  /**
363
- * `EndpointDef.post`·`timeoutMs` `sendRequest` 에서 적용.
364
- * `showIndicator` options → `endpoint.showIndicator` → `true` 순으로 결정.
492
+ * 단일 엔드포인트 호출 래퍼.
493
+ * - `showIndicator`: 옵션 → `endpoint.showIndicator` → 기본 true.
494
+ * - `skipGlobalIndicator: !showIndicator` 로 axios 전역 바와 로컬 setIndicator를 정렬.
495
+ * - 에러 시: Axios/봉투 비즈니스 에러는 인터셉터가 이미 토스트 → catch에서는 중복 방지.
496
+ *
497
+ * @param endpoint - shared `EndpointDef`
498
+ * @param options - 쿼리·바디·토스트·인디케이터 옵션
365
499
  */
366
500
  async sendMessage<T = unknown, R = unknown>(endpoint: EndpointDef, options: SendMessageOptions<T> = {}): Promise<R> {
367
501
  const {
@@ -1,25 +1,44 @@
1
+ /**
2
+ * =============================================================================
3
+ * SessionStore — 인증 세션 (싱글톤)
4
+ * =============================================================================
5
+ *
6
+ * - `token`이 설정되면 `api/client` 요청 인터셉터가 `Authorization: Bearer …`를 붙인다.
7
+ * - `logout()` / `setToken(null)` 시 `registerLogoutCallback`으로 등록된 루틴 실행
8
+ * (템플릿에서는 RootStore가 `apiCacheStore.clear()` 연동).
9
+ *
10
+ * 앱별로 refresh 토큰·저장소(localStorage) 동기화는 소비자 앱에서 확장한다.
11
+ */
12
+
1
13
  import { makeAutoObservable } from 'mobx';
2
14
 
3
- /** Bearer 토큰 등 — api/client 가 Authorization 헤더에 반영 */
4
15
  export class SessionStore {
5
16
  token: string | null = null;
6
17
  private _onLogout?: () => void;
7
18
 
19
+ /** MobX 관찰 필드로 등록 */
8
20
  constructor() {
9
21
  makeAutoObservable(this);
10
22
  }
11
23
 
12
- /** RootStore 등에서 한 번 등록 — 토큰 제거 시 캐시 비우기 등 */
24
+ /**
25
+ * 로그아웃 시 한 번 호출할 콜백 등록 (중복 등록 시 마지막 것이 유효).
26
+ * @param cb - 예: API 캐시 클리어
27
+ */
13
28
  registerLogoutCallback(cb: () => void) {
14
29
  this._onLogout = cb;
15
30
  }
16
31
 
32
+ /**
33
+ * 액세스 토큰 설정. `null`로 두면 Bearer 제거; 이전에 토큰이 있었는데 `null`이면 `_onLogout` 실행.
34
+ */
17
35
  setToken(token: string | null) {
18
36
  const had = this.token != null;
19
37
  this.token = token;
20
38
  if (had && token == null) this._onLogout?.();
21
39
  }
22
40
 
41
+ /** `setToken(null)`과 동일 */
23
42
  logout() {
24
43
  this.setToken(null);
25
44
  }
@@ -1,5 +1,11 @@
1
1
  /**
2
- * indicator / toast / sendMessage 옵션 — RootStore·api/client 와 공유
2
+ * =============================================================================
3
+ * UI 상태 타입 — RootStore·StoreProvider·sendMessage 옵션과 공유
4
+ * =============================================================================
5
+ *
6
+ * - **ToastItem**: MobX 배열 → `GlobalToastStackView`에서 front-core `Toast`로 매핑 (error만 닫기 버튼).
7
+ * - **IndicatorState**: 전역 로딩 바(`loading`) + (선택) 문구, 현재 method 이름 표시용 슬롯.
8
+ * - **SendMessageOptions**: `sendRequest`에 그대로 안 넘어가고 RootStore가 해석 후 일부만 전달.
3
9
  */
4
10
 
5
11
  export type ToastSeverity = 'info' | 'success' | 'warn' | 'error';