@xfilecom/xframe 0.1.34 → 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 (37) 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/main.tsx +12 -1
  13. package/template/web/client/tsconfig.json +1 -0
  14. package/template/web/client/vite.config.ts +4 -0
  15. package/template/web/shared/package.json +3 -1
  16. package/template/web/shared/src/api/client.ts +132 -0
  17. package/template/web/shared/src/api/methods/health.method.ts +14 -0
  18. package/template/web/shared/src/api/methods/index.ts +31 -0
  19. package/template/web/shared/src/api/request.ts +65 -0
  20. package/template/web/shared/src/api/types.ts +80 -0
  21. package/template/web/shared/src/context/StoreProvider.tsx +45 -0
  22. package/template/web/shared/src/hooks/useAppStore.ts +23 -0
  23. package/template/web/shared/src/hooks/useHealthStatus.ts +12 -3
  24. package/template/web/shared/src/index.ts +53 -1
  25. package/template/web/shared/src/lib/http.ts +9 -6
  26. package/template/web/shared/src/methods/health.ts +5 -4
  27. package/template/web/shared/src/params/param-store.ts +178 -0
  28. package/template/web/shared/src/params/types.ts +21 -0
  29. package/template/web/shared/src/params/validations.ts +32 -0
  30. package/template/web/shared/src/store/api-cache-store.ts +35 -0
  31. package/template/web/shared/src/store/index.ts +4 -0
  32. package/template/web/shared/src/store/root-store.ts +358 -0
  33. package/template/web/shared/src/store/session-store.ts +28 -0
  34. package/template/web/shared/src/types/ui.ts +37 -0
  35. package/template/web/shared/tsconfig.json +4 -1
  36. package/template/yarn.lock +237 -13
  37. package/template/web/shared/src/stores/appStore.ts +0 -11
package/defaults.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "backendCore": "^1.0.14",
3
- "frontCore": "^0.2.20"
2
+ "backendCore": "^1.0.15",
3
+ "frontCore": "^0.2.21"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfilecom/xframe",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Scaffold full-stack app: Nest + @xfilecom/backend-core, Vite/React + @xfilecom/front-core",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -25,7 +25,7 @@ Nest API (`@xfilecom/backend-core`) + Vite/React client·admin (`@xfilecom/front
25
25
  ## `shared/` (설정·스키마·SQL·endpoint)
26
26
 
27
27
  - **`shared/config/api`**, **`shared/config/web/client`**, **`shared/config/web/admin`**: YAML (`application.yml` + `application-<env>.yml`)
28
- - **`web/shared`**: `components`, `hooks`, `stores` (zustand), `methods`, `lib`, `types`, `constants`, `utils`
28
+ - **`web/shared`**: `store/` (RootStore·session·cache), `params/` (ParamStore), `api/` (client·request·methods), `context/` (StoreProvider), `components`, `hooks`, `methods`, `lib`, `types`, `constants`, `utils`
29
29
  - **`shared/schema`**, **`shared/sql`**, **`shared/endpoint`**: 팀이 채우는 자산(README 참고)
30
30
 
31
31
  자세한 설명은 `shared/README.md` 참고.
@@ -1,4 +1,33 @@
1
1
  # Endpoint / API 계약
2
2
 
3
- - OpenAPI `openapi.yaml`, 공유 요청·응답 타입, 라우트 prefix 규칙 등을 둡니다.
3
+ - **`endpoint.ts`** `EndpointDef`, `HttpMethod`, 엔드포인트 상수(예: `healthEndpoint`). 프론트 `sendRequest`·백엔드 라우트와 동일 경로를 맞출 때 사용합니다.
4
+
5
+ ### `EndpointDef` 필드 요약
6
+
7
+ | 필드 | Nest | 프론트 |
8
+ |------|------|--------|
9
+ | `method`, `path` | 라우트와 맞추기 | `sendRequest` URL·HTTP 메서드 |
10
+ | `post` | 무시 | **응답 본문**(`axios`의 `response.data`와 동일 한 덩어리)만 인자로 받아 가공 후 반환 |
11
+ | `showIndicator` | 무시 | `sendMessage` 시 옵션 미지정이면 전역 로딩 기본값 |
12
+ | `timeoutMs` | 무시 | 해당 요청만 axios `timeout` |
13
+
14
+ **응답 가공**은 엔드포인트 단위 `post` 와, `web/shared` 의 `ApiMethodConfig`·`RootStore.execute` 의 `unwrapDataArray` / `post(result, stores)` 를 함께 쓸 수 있습니다. 같은 데이터를 두 번 벗기지 않도록 한쪽만 쓰는 것을 권장합니다.
15
+
16
+ `post` 예시 — 인자는 **HTTP 본문 JSON**이지, axios `Response` 객체가 아닙니다.
17
+
18
+ ```ts
19
+ import { unwrapResponseData, type EndpointDef } from '@shared/endpoint';
20
+
21
+ export const itemEndpoint: EndpointDef = {
22
+ method: 'GET',
23
+ path: '/items/:id',
24
+ post: unwrapResponseData, // 또는 (body) => (body as { data: Item }).data
25
+ showIndicator: true,
26
+ timeoutMs: 10_000,
27
+ };
28
+ ```
29
+
30
+ - OpenAPI `openapi.yaml`, 공유 요청·응답 타입, 라우트 prefix 규칙 등도 이 디렉터리·형제 파일에 둘 수 있습니다.
4
31
  - 실제 Nest `Controller`는 `apps/api/src`에 두고, 여기서는 **계약·문서**를 단일 소스로 유지하는 패턴을 권장합니다.
32
+
33
+ 임포트: `import { healthEndpoint, type EndpointDef } from '@shared/endpoint';`
@@ -0,0 +1,55 @@
1
+ /**
2
+ * HTTP API 엔드포인트 정의 — Nest(`@shared/*`)와 Vite(`@shared/*` alias) 공통.
3
+ * `web/` 옆 루트 `shared/endpoint/` 에 두어 백엔드 라우트·프론트 axios 경로를 한곳에서 맞춥니다.
4
+ *
5
+ * Nest 는 주로 `method`·`path` 만 쓰고, `post`·`showIndicator`·`timeoutMs` 등은 프론트 `sendRequest` / RootStore `sendMessage` 가 참고합니다.
6
+ */
7
+
8
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
9
+
10
+ /**
11
+ * 백엔드 `CommonResponseDto` 류 `{ data: T, code?, error? }` 에서 `data` 만 꺼냄.
12
+ * 해당 필드가 없으면 본문 그대로 반환.
13
+ */
14
+ export function unwrapResponseData(body: unknown): unknown {
15
+ if (body !== null && typeof body === 'object' && 'data' in body) {
16
+ return (body as { data: unknown }).data;
17
+ }
18
+ return body;
19
+ }
20
+
21
+ export interface EndpointDef {
22
+ method: HttpMethod;
23
+ /** 상대 경로. `:id` 는 sendRequest 의 params 로 치환 */
24
+ path: string;
25
+
26
+ /**
27
+ * axios 가 파싱한 **응답 본문**(`response.data`)만 넘어옵니다. AxiosResponse 전체가 아닙니다.
28
+ * `{ data: T }` 언래핑은 `unwrapResponseData` 또는 `(body) => (body as { data: T }).data` 패턴을 쓰세요.
29
+ */
30
+ post?: (body: unknown) => unknown;
31
+
32
+ /**
33
+ * RootStore.sendMessage 에서 `options.showIndicator` 가 없을 때 기본값.
34
+ * `sendRequest` 직호출 시에는 axios 훅만 쓰므로 이 필드는 무시됩니다.
35
+ */
36
+ showIndicator?: boolean;
37
+
38
+ /** 이 요청만 다른 타임아웃(ms). 미설정 시 apiClient 기본값 */
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ export const healthEndpoint: EndpointDef = {
43
+ method: 'GET',
44
+ path: '/health',
45
+ /** health 는 인디케이터 없이 조용히 호출하는 경우가 많음 */
46
+ showIndicator: false,
47
+ };
48
+
49
+ export const appMetaEndpoint: EndpointDef = {
50
+ method: 'GET',
51
+ path: '/app-meta',
52
+ post: unwrapResponseData,
53
+ showIndicator: false,
54
+ timeoutMs: 10_000,
55
+ };
@@ -0,0 +1,2 @@
1
+ export type { HttpMethod, EndpointDef } from './endpoint';
2
+ export { healthEndpoint, appMetaEndpoint, unwrapResponseData } from './endpoint';
@@ -1,15 +1,10 @@
1
1
  import { Badge, Text } from '@xfilecom/front-core';
2
- import {
3
- Shell,
4
- useHealthStatus,
5
- FALLBACK_API_BASE_URL,
6
- } from '__WEB_SHARED_WORKSPACE__';
2
+ import { Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
7
3
 
8
- const apiBase = import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL;
9
4
  const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__ (admin)';
10
5
 
11
6
  export function App() {
12
- const { text, error } = useHealthStatus(apiBase);
7
+ const { text, error } = useHealthStatus();
13
8
 
14
9
  return (
15
10
  <Shell
@@ -4,10 +4,21 @@ import '@xfilecom/front-core/tokens.css';
4
4
  import '@xfilecom/front-core/base.css';
5
5
  import '__WEB_SHARED_WORKSPACE__/styles/xfc-theme.css';
6
6
  import '__WEB_SHARED_WORKSPACE__/styles/app.css';
7
+ import {
8
+ StoreProvider,
9
+ configureApi,
10
+ FALLBACK_API_BASE_URL,
11
+ } from '__WEB_SHARED_WORKSPACE__';
7
12
  import { App } from './App';
8
13
 
14
+ configureApi({
15
+ baseURL: import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL,
16
+ });
17
+
9
18
  ReactDOM.createRoot(document.getElementById('root')!).render(
10
19
  <React.StrictMode>
11
- <App />
20
+ <StoreProvider>
21
+ <App />
22
+ </StoreProvider>
12
23
  </React.StrictMode>,
13
24
  );
@@ -10,6 +10,7 @@
10
10
  "paths": {
11
11
  "__WEB_SHARED_WORKSPACE__": ["../shared/src/index.ts"],
12
12
  "__WEB_SHARED_WORKSPACE__/*": ["../shared/src/*"],
13
+ "@shared/*": ["../../shared/*"],
13
14
  "@xfilecom/front-core": ["../../../../front-core/src/index.ts"]
14
15
  },
15
16
  "allowImportingTsExtensions": true,
@@ -14,6 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const frontCoreSrc = path.resolve(__dirname, '../../../..', 'front-core', 'src');
15
15
 
16
16
  const webSharedSrc = path.resolve(__dirname, '../shared/src');
17
+ const rootSharedSrc = path.resolve(__dirname, '../../shared');
17
18
 
18
19
  function readYaml(file: string): Record<string, unknown> {
19
20
  if (!existsSync(file)) return {};
@@ -70,6 +71,7 @@ export default defineConfig(({ mode }) => {
70
71
  resolve: {
71
72
  alias: {
72
73
  '@xfilecom/front-core': frontCoreSrc,
74
+ '@shared': rootSharedSrc,
73
75
  '__WEB_SHARED_WORKSPACE__': webSharedSrc,
74
76
  },
75
77
  },
@@ -1,20 +1,15 @@
1
1
  import { useState } from 'react';
2
2
  import { Badge, Button, Stack, Text } from '@xfilecom/front-core';
3
- import {
4
- Shell,
5
- useHealthStatus,
6
- FALLBACK_API_BASE_URL,
7
- } from '__WEB_SHARED_WORKSPACE__';
3
+ import { Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
8
4
  import { FrontCoreShowcase } from './FrontCoreShowcase';
9
5
 
10
- const apiBase = import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL;
11
6
  const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__';
12
7
 
13
8
  type Tab = 'api' | 'atoms';
14
9
 
15
10
  export function App() {
16
11
  const [tab, setTab] = useState<Tab>('atoms');
17
- const { text, error } = useHealthStatus(apiBase);
12
+ const { text, error } = useHealthStatus();
18
13
 
19
14
  return (
20
15
  <Shell
@@ -4,10 +4,21 @@ import '@xfilecom/front-core/tokens.css';
4
4
  import '@xfilecom/front-core/base.css';
5
5
  import '__WEB_SHARED_WORKSPACE__/styles/xfc-theme.css';
6
6
  import '__WEB_SHARED_WORKSPACE__/styles/app.css';
7
+ import {
8
+ StoreProvider,
9
+ configureApi,
10
+ FALLBACK_API_BASE_URL,
11
+ } from '__WEB_SHARED_WORKSPACE__';
7
12
  import { App } from './App';
8
13
 
14
+ configureApi({
15
+ baseURL: import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL,
16
+ });
17
+
9
18
  ReactDOM.createRoot(document.getElementById('root')!).render(
10
19
  <React.StrictMode>
11
- <App />
20
+ <StoreProvider>
21
+ <App />
22
+ </StoreProvider>
12
23
  </React.StrictMode>,
13
24
  );
@@ -10,6 +10,7 @@
10
10
  "paths": {
11
11
  "__WEB_SHARED_WORKSPACE__": ["../shared/src/index.ts"],
12
12
  "__WEB_SHARED_WORKSPACE__/*": ["../shared/src/*"],
13
+ "@shared/*": ["../../shared/*"],
13
14
  "@xfilecom/front-core": ["../../../../front-core/src/index.ts"]
14
15
  },
15
16
  "allowImportingTsExtensions": true,
@@ -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
+ }