@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,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
- /** GET /health — params·body 없음, 스토어는 validate/post 에서 사용 가능 */
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
- * 화면별 workflow registry — 새 method 는 *.method.ts 추가 후 여기 등록
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
- * EndpointDef — path `:param` 치환, query/body, skipGlobalIndicator
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
- * methods.ts / RootStore.execute — RootStore 직접 참조 없이 순환 방지
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,8 +1,28 @@
1
1
  /**
2
- * RootStore 컨텍스트 — useStore / useParamStore / useSendMessage 등
3
- * (싱글톤 rootStore 이므로 Provider 없이도 rootStore 직접 import 가능)
2
+ * =============================================================================
3
+ * StoreProvider React 트리와 `rootStore`(MobX 싱글톤) 연결
4
+ * =============================================================================
4
5
  *
5
- * 최상위 레이어: HTTP 로딩 바(`indicator`) · `rootStore.toasts` → ToastList
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` — 편의 접근자
6
26
  */
7
27
 
8
28
  import React, { createContext, useCallback, useContext } from 'react';
@@ -16,6 +36,10 @@ type RootStoreApi = typeof rootStore;
16
36
 
17
37
  const StoreContext = createContext<RootStoreApi | null>(null);
18
38
 
39
+ /**
40
+ * `rootStore.indicator.loading`이 true일 때만 상단 고정 로딩 바를 렌더한다.
41
+ * @returns 없으면 `null`
42
+ */
19
43
  const GlobalHttpIndicatorView = observer(function GlobalHttpIndicatorView() {
20
44
  if (!rootStore.indicator.loading) return null;
21
45
  return (
@@ -28,15 +52,20 @@ const GlobalHttpIndicatorView = observer(function GlobalHttpIndicatorView() {
28
52
  );
29
53
  });
30
54
 
55
+ /**
56
+ * `ToastList` + `ToastEntry.dismissible` — error만 수동 닫기, 나머지는 RootStore 타이머.
57
+ */
31
58
  const GlobalToastStackView = observer(function GlobalToastStackView() {
32
59
  const entries = rootStore.toasts.map((t) => ({
33
60
  id: t.id,
34
61
  severity: t.severity,
35
62
  message: t.message,
63
+ dismissible: t.severity === 'error',
36
64
  }));
37
65
  return (
38
66
  <ToastList
39
67
  toasts={entries}
68
+ dismissAriaLabel="닫기"
40
69
  onDismiss={(id) => {
41
70
  rootStore.removeToast(id);
42
71
  }}
@@ -44,12 +73,18 @@ const GlobalToastStackView = observer(function GlobalToastStackView() {
44
73
  );
45
74
  });
46
75
 
76
+ /**
77
+ * Provider에 주입된 `rootStore` 반환. Provider 밖이면 예외.
78
+ */
47
79
  export function useStore(): RootStoreApi {
48
80
  const ctx = useContext(StoreContext);
49
81
  if (!ctx) throw new Error('StoreProvider is required');
50
82
  return ctx;
51
83
  }
52
84
 
85
+ /**
86
+ * 컨텍스트 + 전역 로딩 바 + 전역 토스트 + 자식 트리.
87
+ */
53
88
  export function StoreProvider({ children }: { children: React.ReactNode }) {
54
89
  return (
55
90
  <StoreContext.Provider value={rootStore}>
@@ -60,6 +95,9 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
60
95
  );
61
96
  }
62
97
 
98
+ /**
99
+ * `rootStore.sendMessage`를 안정적인 콜백으로 반환.
100
+ */
63
101
  export function useSendMessage() {
64
102
  const store = useStore();
65
103
  return useCallback(
@@ -70,14 +108,17 @@ export function useSendMessage() {
70
108
  );
71
109
  }
72
110
 
111
+ /** 화면 파라미터 스토어 단축 접근 */
73
112
  export function useParamStore() {
74
113
  return useStore().paramStore;
75
114
  }
76
115
 
116
+ /** 토큰·로그아웃 콜백 */
77
117
  export function useSessionStore() {
78
118
  return useStore().sessionStore;
79
119
  }
80
120
 
121
+ /** `useStore()`와 동일 — 의미상 “전체 루트” 강조용 별칭 */
81
122
  export function useRootStore(): RootStoreApi {
82
123
  return useStore();
83
124
  }
@@ -1,11 +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
- * Zustand 스타일 셀렉터 RootStore(MobX) 구독
7
- * 예: `useAppStore((s) => s.lastHealthAt)`, `useAppStore((s) => s.setLastHealthAt)`
8
- * (프로토타입 메서드는 분리 시 this 가 깨지므로, 콜백으로 쓸 메서드는 스토어에서 화살표 필드로 두거나 `bind` 하세요.)
32
+ * @param selector - `rootStore`에서 구독할 조각; 참조 동등이 아니라 MobX 추적 값이 바뀔 때 리렌더
33
+ * @returns selector의 현재 결과
9
34
  */
10
35
  export function useAppStore<T>(selector: (store: typeof rootStore) => T): T {
11
36
  return useSyncExternalStore(
@@ -1,3 +1,26 @@
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
+
1
24
  import { useEffect } from 'react';
2
25
  import { configureApi } from '../api/client';
3
26
  import { fetchHealth } from '../methods/health';
@@ -5,8 +28,8 @@ import { rootStore } from '../store/root-store';
5
28
  import { useAppStore } from './useAppStore';
6
29
 
7
30
  /**
8
- * @param apiBaseUrl 선택 넘기면 `configureApi` baseURL 갱신. entry 에서 이미 `configureApi` 했다면 생략 가능.
9
- * `data`·`error` `rootStore` 저장·`runInAction`; 로딩 표시는 `StoreProvider` 전역 바 + `indicator`(axios).
31
+ * @param apiBaseUrl - 넘기면 effect 안에서 `configureApi`로 baseURL 갱신 (entry에서 이미 했으면 생략 가능)
32
+ * @returns `{ data, error }` `rootStore` 기반 구독
10
33
  */
11
34
  export function useHealthStatus(apiBaseUrl?: string) {
12
35
  const data = useAppStore((s) => s.healthData);
@@ -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
- /** 직접 호출용 (토스트 없음) — UI 훅은 보통 이 함수 사용 */
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
  }