@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
package/bin/xframe.js CHANGED
@@ -645,7 +645,13 @@ Next:
645
645
  yarn dev # 또는 npm run dev
646
646
  → API :3000/health · Client :3001 · Admin :3002
647
647
  (설치 생략: npx @xfilecom/xframe <dir> --no-install)
648
- 최신 스캐폴드: npx --yes @xfilecom/xframe@latest <dir>`);
648
+ 최신 스캐폴드: npx --yes @xfilecom/xframe@latest <dir>
649
+
650
+ 엔드포인트 추가 시 (상세: docs/SCAFFOLD_CHECKLIST.md):
651
+ • shared/endpoint → 계약(EndpointDef)
652
+ • apps/api/src → Controller·Module 라우트
653
+ • web/shared/src/api/methods → ApiMethodConfig + index 등록
654
+ • web/shared/src/methods → (선택) sendRequest 헬퍼`);
649
655
  }
650
656
 
651
657
  main().catch((err) => {
package/defaults.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "backendCore": "^1.0.18",
3
- "frontCore": "^0.2.24"
2
+ "backendCore": "^1.0.19",
3
+ "frontCore": "^0.2.25"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfilecom/xframe",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "Scaffold full-stack app: Nest + @xfilecom/backend-core, Vite/React + @xfilecom/front-core",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -1,3 +1,21 @@
1
+ /**
2
+ * =============================================================================
3
+ * Nest API 부트스트랩 (xframe 템플릿)
4
+ * =============================================================================
5
+ *
6
+ * 실행 순서
7
+ * ---------
8
+ * 1. `config.loader` — YAML/ENV 병합 후 `appConfig` 싱글톤 (DB URL, CORS, globalPrefix 등).
9
+ * 2. `AppModule` — `@xfilecom/backend-core` `CoreModule` 및 앱 라우트 등록.
10
+ * 3. `applyNestAppFromConfig` — CORS, `setGlobalPrefix` 등 Nest 인스턴스 레벨 설정만 적용.
11
+ * (인터셉터·필터·DB는 모듈 쪽 책임)
12
+ *
13
+ * 프론트와의 계약
14
+ * ---------------
15
+ * - 공통 응답 봉투 `{ code, data, error }`는 보통 CoreModule/인터셉터에서 맞춘다.
16
+ * - 프론트 `shared/endpoint`의 `path`와 여기 `globalPrefix` 조합이 실제 URL이 된다.
17
+ */
18
+
1
19
  import './config.loader';
2
20
  import 'reflect-metadata';
3
21
  import { NestFactory } from '@nestjs/core';
@@ -5,12 +23,17 @@ import type { INestApplication } from '@nestjs/common';
5
23
  import { AppModule } from './app.module';
6
24
  import { appConfig } from './config.loader';
7
25
 
26
+ /**
27
+ * 양의 유한 숫자면 그 값, 아니면 `undefined`.
28
+ */
8
29
  function parsePositivePort(v: string | number | undefined): number | undefined {
9
30
  const n = typeof v === 'number' ? v : Number(v);
10
31
  return Number.isFinite(n) && n > 0 ? n : undefined;
11
32
  }
12
33
 
13
- /** `PORT`(syncEnvKeys) → `server.port`(YAML) → 기본값 */
34
+ /**
35
+ * `PORT`(syncEnvKeys) → `server.port`(YAML) → 기본값 `3000`.
36
+ */
14
37
  function resolveListenPort(): number {
15
38
  return (
16
39
  parsePositivePort(process.env.PORT) ??
@@ -19,6 +42,9 @@ function resolveListenPort(): number {
19
42
  );
20
43
  }
21
44
 
45
+ /**
46
+ * `appConfig.cors`에 따라 `enableCors` 호출. `enabled: false`면 아무 것도 안 함.
47
+ */
22
48
  function applyCorsFromConfig(app: INestApplication): void {
23
49
  const cors = appConfig.cors;
24
50
  if (cors?.enabled === false) {
@@ -33,6 +59,9 @@ function applyCorsFromConfig(app: INestApplication): void {
33
59
  app.enableCors({ origin: origin === true, credentials });
34
60
  }
35
61
 
62
+ /**
63
+ * `globalPrefix`가 있으면 앞뒤 슬래시 정리 후 `setGlobalPrefix`.
64
+ */
36
65
  function applyHttpFromConfig(app: INestApplication): void {
37
66
  const prefix = appConfig.http?.globalPrefix;
38
67
  if (prefix != null && String(prefix).trim() !== '') {
@@ -49,6 +78,9 @@ function applyNestAppFromConfig(app: INestApplication): void {
49
78
  applyHttpFromConfig(app);
50
79
  }
51
80
 
81
+ /**
82
+ * 한 줄 요약 로그 (포트·환경·CORS·DB·JWT·sql 엔드포인트 플래그).
83
+ */
52
84
  function logStartupFromConfig(port: number): void {
53
85
  const base = appConfig.http?.publicBaseUrl?.replace(/\/$/, '');
54
86
  const pfx = appConfig.http?.globalPrefix
@@ -74,6 +106,9 @@ function logStartupFromConfig(port: number): void {
74
106
  );
75
107
  }
76
108
 
109
+ /**
110
+ * Nest 앱 생성 → 설정 적용 → 리슨 → 스타트업 로그.
111
+ */
77
112
  async function bootstrap() {
78
113
  const app = await NestFactory.create(AppModule);
79
114
  applyNestAppFromConfig(app);
@@ -0,0 +1,13 @@
1
+ # 스캐폴드 직후 — API·프론트 연결 체크리스트
2
+
3
+ 새 앱을 `npx @xfilecom/xframe <dir>` 로 만든 뒤, 엔드포인트를 추가할 때 아래 순서를 참고하면 된다.
4
+
5
+ 1. **`shared/endpoint/`** — 경로·메서드·타입·`EndpointDef`(및 OpenAPI가 있으면 동기화).
6
+ 2. **`apps/api/src/`** — Nest `Controller` / `Service` / `Module`에 라우트 등록·의존성 연결.
7
+ 3. **`web/shared/src/api/methods/`** — `ApiMethodConfig` 정의 후 `index.ts`의 `methods` 맵에 등록.
8
+ 4. **`web/shared/src/methods/`** — `sendRequest`만 쓰는 얇은 헬퍼가 필요하면 추가(예: `health.ts`).
9
+ 5. **`shared/config/api`** — 공개 URL·글로벌 prefix 등 API 쪽 YAML이 바뀌면 반영.
10
+ 6. **`shared/config/web/client` · `admin`** — 프록시·API 베이스가 달라지면 각 `application*.yml` 수정.
11
+ 7. **`shared/schema/` · `sql/`** — DB 스키마·마이그레이션이 도메인에 필요하면 여기서 정리 후 `apps/api`와 맞춘다.
12
+
13
+ 자세한 DB·설정 흐름은 **[DATABASE.md](./DATABASE.md)**, 웹 콘솔·번들은 **[WEB_DEV_CONSOLE.md](./WEB_DEV_CONSOLE.md)** 를 본다.
@@ -5,7 +5,7 @@
5
5
  | 경로 | 용도 |
6
6
  |------|------|
7
7
  | **`config/api`** | Nest API YAML (`application.yml`, `application-<env>.yml`) |
8
- | **`config/web/client`**, **`config/web/admin`** | 각 Vite 앱별 YAML (`VITE_API_BASE_URL`, `VITE_APP_TITLE` 주입) |
8
+ | **`config/web/client`**, **`config/web/admin`** | 각 Vite 앱별 YAML `vite.config` `define`으로 `import.meta.env.XFC_*`에 주입 (`.env` 미사용) |
9
9
  | **`schema/`** | Drizzle 등 DB 스키마(TS). `apps/api`의 `tsconfig.build.json`에 경로를 포함하거나, 스키마만 `apps/api/src/database`에 두고 여기서 re-export 하는 식으로 맞추면 됩니다. Nest 에서의 주입·쿼리 패턴은 루트 **[docs/DATABASE.md](../docs/DATABASE.md)** 참고. |
10
10
  | **`sql/`** | 마이그레이션·시드·원시 SQL; **`sql/sql.ts`** (`databaseSqlManifest`) 로 경로·절차 메타 정리 |
11
11
  | **`endpoint/`** | HTTP 계약(OpenAPI yaml, 공유 DTO 타입, 라우트 메타) — 제품에 맞게 확장 |
@@ -1,15 +1,31 @@
1
1
  /**
2
- * HTTP API 엔드포인트 정의 — Nest(`@shared/*`)와 Vite(`@shared/*` alias) 공통.
3
- * `web/` 옆 루트 `shared/endpoint/` 두어 백엔드 라우트·프론트 axios 경로를 한곳에서 맞춥니다.
2
+ * =============================================================================
3
+ * shared/endpoint Nest·Vite 공통 API 경로 정의 (`EndpointDef`)
4
+ * =============================================================================
4
5
  *
5
- * Nest 주로 `method`·`path` 만 쓰고, `post`·`showIndicator`·`timeoutMs` 등은 프론트 `sendRequest` / RootStore `sendMessage` 가 참고합니다.
6
+ * shared에 두는가?
7
+ * --------------------
8
+ * - 백엔드(Nest) 컨트롤러의 `@Get('health')` 와 프론트 axios URL을 **문자열 하나로** 맞추기 위함.
9
+ * - `packages/.../template` 기준으로 `web/`과 `apps/api`가 같은 파일(또는 복사본)을 바라보게 스캐폴딩한다.
10
+ *
11
+ * 필드 의미
12
+ * ---------
13
+ * - **method / path**: HTTP 메서드와 상대 경로 (`apiClient.defaults.baseURL`에 붙음).
14
+ * - **post**: 이름이 HTTP POST와 무관하다. `sendRequest`가 axios 응답 본문(`response.data`)을 받은 **뒤**
15
+ * 한 번 더 변환할 때 쓰는 함수. (예: 레거시 `{ data: T }` 한 겹 제거)
16
+ * ※ 공통 봉투 `{ code, data, error }`는 **먼저** `api/client` 인터셉터에서 안쪽 `data`로 치환되므로,
17
+ * 여기서의 `post`는 그 **이후**의 값을 받는다. 이중 언래핑에 주의.
18
+ * - **showIndicator**: `RootStore.sendMessage`가 `options.showIndicator`를 생략했을 때 쓰는 기본값.
19
+ * `sendRequest`만 직접 호출할 때는 이 필드가 자동 적용되지 않는다 (전역 바만 axios config로 제어).
20
+ * - **timeoutMs**: 해당 요청만 axios timeout 덮어쓰기.
6
21
  */
7
22
 
8
23
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
9
24
 
10
25
  /**
11
- * 백엔드 `CommonResponseDto` `{ data: T, code?, error? }` 에서 `data` 만 꺼냄.
12
- * 해당 필드가 없으면 본문 그대로 반환.
26
+ * 객체에 `data` 키가 있으면 값만 반환, 없으면 인자 그대로 반환.
27
+ * `EndpointDef.post`에서 레거시 래핑 제거용. (공통 봉투는 axios 쪽에서 먼저 처리)
28
+ * @param body - `sendRequest`가 넘기는 응답 본문
13
29
  */
14
30
  export function unwrapResponseData(body: unknown): unknown {
15
31
  if (body !== null && typeof body === 'object' && 'data' in body) {
@@ -44,6 +60,7 @@ export const healthEndpoint: EndpointDef = {
44
60
  path: '/health',
45
61
  /** health 는 인디케이터 없이 조용히 호출하는 경우가 많음 */
46
62
  showIndicator: false,
63
+ /** 공통 봉투를 쓰지 않는 레거시 응답이면 안쪽 data 추출에 유용. 봉투만 쓰는 API면 생략 가능. */
47
64
  post: unwrapResponseData,
48
65
  };
49
66
 
@@ -1,7 +1,7 @@
1
1
  import { Badge, Text } from '@xfilecom/front-core';
2
2
  import { safeJsonStringify, Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
3
3
 
4
- const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__ (admin)';
4
+ const title = import.meta.env.XFC_APP_TITLE || '__PACKAGE_NAME__ (admin)';
5
5
 
6
6
  export function App() {
7
7
  const { data } = useHealthStatus();
@@ -1,3 +1,12 @@
1
+ /**
2
+ * =============================================================================
3
+ * Vite + React 엔트리 (admin)
4
+ * =============================================================================
5
+ *
6
+ * client와 동일한 shared 패키지·`configureApi`·`StoreProvider` 구성을 공유한다.
7
+ * API base·앱 제목은 `.env`가 아니라 `shared/config/web/admin` YAML → `vite.config` `define` 주입.
8
+ */
9
+
1
10
  import React from 'react';
2
11
  import ReactDOM from 'react-dom/client';
3
12
  import '@xfilecom/front-core/tokens.css';
@@ -12,7 +21,7 @@ import {
12
21
  import { App } from './App';
13
22
 
14
23
  configureApi({
15
- baseURL: import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL,
24
+ baseURL: import.meta.env.XFC_API_BASE_URL || FALLBACK_API_BASE_URL,
16
25
  });
17
26
 
18
27
  ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -1,8 +1,9 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  interface ImportMetaEnv {
4
- readonly VITE_API_BASE_URL: string;
5
- readonly VITE_APP_TITLE: string;
4
+ /** `vite.config` `define` — 원본은 `shared/config/web/admin/application*.yml` (`.env` 미사용) */
5
+ readonly XFC_API_BASE_URL: string;
6
+ readonly XFC_APP_TITLE: string;
6
7
  }
7
8
 
8
9
  interface ImportMeta {
@@ -69,8 +69,8 @@ export default defineConfig(({ mode }) => {
69
69
  },
70
70
  },
71
71
  define: {
72
- 'import.meta.env.VITE_API_BASE_URL': JSON.stringify(apiBase),
73
- 'import.meta.env.VITE_APP_TITLE': JSON.stringify(title),
72
+ 'import.meta.env.XFC_API_BASE_URL': JSON.stringify(apiBase),
73
+ 'import.meta.env.XFC_APP_TITLE': JSON.stringify(title),
74
74
  },
75
75
  server: {
76
76
  port: 3002,
@@ -3,7 +3,7 @@ import { Badge, Button, Stack, Text } from '@xfilecom/front-core';
3
3
  import { safeJsonStringify, Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
4
4
  import { FrontCoreShowcase } from './FrontCoreShowcase';
5
5
 
6
- const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__';
6
+ const title = import.meta.env.XFC_APP_TITLE || '__PACKAGE_NAME__';
7
7
 
8
8
  type Tab = 'api' | 'atoms';
9
9
 
@@ -5,13 +5,17 @@ import {
5
5
  Box,
6
6
  Button,
7
7
  Card,
8
+ Checkbox,
8
9
  ConfirmDialog,
9
10
  Dialog,
11
+ Field,
10
12
  Input,
11
13
  InlineErrorList,
12
14
  LoadingOverlay,
15
+ Select,
13
16
  Stack,
14
17
  Text,
18
+ Textarea,
15
19
  Toast,
16
20
  ToastList,
17
21
  ToastSeverityIcon,
@@ -71,6 +75,9 @@ export function FrontCoreShowcase() {
71
75
  const [confirmCustomOpen, setConfirmCustomOpen] = useState(false);
72
76
  const [sheetOpen, setSheetOpen] = useState(false);
73
77
  const [sheetRichOpen, setSheetRichOpen] = useState(false);
78
+ const [fieldBio, setFieldBio] = useState('');
79
+ const [fieldPlan, setFieldPlan] = useState('a');
80
+ const [fieldAgree, setFieldAgree] = useState(false);
74
81
 
75
82
  const pushToast = useCallback(
76
83
  (severity: ToastSeverity) => {
@@ -173,7 +180,7 @@ export function FrontCoreShowcase() {
173
180
  open={confirmOpen}
174
181
  onOpenChange={setConfirmOpen}
175
182
  title="ConfirmDialog"
176
- message="일반 확인창입니다. 취소 onCancel 닫힙니다."
183
+ message="Default labels: OK / Cancel. Cancel runs onCancel then closes."
177
184
  onConfirm={() => setConfirmOpen(false)}
178
185
  />
179
186
 
@@ -354,6 +361,42 @@ export function FrontCoreShowcase() {
354
361
  </Stack>
355
362
  </Card>
356
363
 
364
+ <Card>
365
+ <Stack direction="column" gap="md" align="stretch">
366
+ <SectionTitle>Field · Textarea · Select · Checkbox</SectionTitle>
367
+ <Field
368
+ htmlFor={`${uid}-field-bio`}
369
+ label="Bio"
370
+ hint="Short description"
371
+ error={fieldBio.length > 12 ? 'Keep it under 12 characters' : undefined}
372
+ invalid={fieldBio.length > 12}
373
+ >
374
+ <Textarea
375
+ id={`${uid}-field-bio`}
376
+ rows={3}
377
+ placeholder="Type here…"
378
+ value={fieldBio}
379
+ onChange={(e) => setFieldBio(e.target.value)}
380
+ />
381
+ </Field>
382
+ <Field htmlFor={`${uid}-field-plan`} label="Plan">
383
+ <Select
384
+ id={`${uid}-field-plan`}
385
+ value={fieldPlan}
386
+ onChange={(e) => setFieldPlan(e.target.value)}
387
+ >
388
+ <option value="a">Plan A</option>
389
+ <option value="b">Plan B</option>
390
+ </Select>
391
+ </Field>
392
+ <Checkbox
393
+ label="I agree to the terms"
394
+ checked={fieldAgree}
395
+ onChange={(e) => setFieldAgree(e.target.checked)}
396
+ />
397
+ </Stack>
398
+ </Card>
399
+
357
400
  <Card>
358
401
  <Stack direction="column" gap="md" align="stretch">
359
402
  <SectionTitle>Box · Stack · 레이아웃 props</SectionTitle>
@@ -1,3 +1,13 @@
1
+ /**
2
+ * =============================================================================
3
+ * Vite + React 엔트리 (client)
4
+ * =============================================================================
5
+ *
6
+ * - 스타일: design tokens → base → xframe 테마 → 앱 레이아웃(`app.css`) 순서 유지.
7
+ * - `configureApi`: axios `baseURL` — `vite.config` `define`으로 주입(`shared/config/web/client` YAML). 없으면 `FALLBACK_API_BASE_URL`.
8
+ * - `StoreProvider`: 전역 로딩 바·토스트 스택·MobX `rootStore` 컨텍스트.
9
+ */
10
+
1
11
  import React from 'react';
2
12
  import ReactDOM from 'react-dom/client';
3
13
  import '@xfilecom/front-core/tokens.css';
@@ -12,7 +22,7 @@ import {
12
22
  import { App } from './App';
13
23
 
14
24
  configureApi({
15
- baseURL: import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL,
25
+ baseURL: import.meta.env.XFC_API_BASE_URL || FALLBACK_API_BASE_URL,
16
26
  });
17
27
 
18
28
  ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -1,8 +1,9 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  interface ImportMetaEnv {
4
- readonly VITE_API_BASE_URL: string;
5
- readonly VITE_APP_TITLE: string;
4
+ /** `vite.config` `define` — 원본은 `shared/config/web/client/application*.yml` (`.env` 미사용) */
5
+ readonly XFC_API_BASE_URL: string;
6
+ readonly XFC_APP_TITLE: string;
6
7
  }
7
8
 
8
9
  interface ImportMeta {
@@ -71,9 +71,10 @@ export default defineConfig(({ mode }) => {
71
71
  '__WEB_SHARED_WORKSPACE__': webSharedSrc,
72
72
  },
73
73
  },
74
+ // `.env` 없음 — 값은 위 `loadWebConfig`(YAML)에서만 온다. `define`으로 번들에 고정 주입.
74
75
  define: {
75
- 'import.meta.env.VITE_API_BASE_URL': JSON.stringify(apiBase),
76
- 'import.meta.env.VITE_APP_TITLE': JSON.stringify(title),
76
+ 'import.meta.env.XFC_API_BASE_URL': JSON.stringify(apiBase),
77
+ 'import.meta.env.XFC_APP_TITLE': JSON.stringify(title),
77
78
  },
78
79
  server: {
79
80
  port: 3001,
@@ -37,3 +37,8 @@ import { Shell } from '__WEB_SHARED_WORKSPACE__';
37
37
  ### `web/shared` CSS·TS가 수정해도 반영되지 않을 때
38
38
 
39
39
  `file:../shared` 가 `node_modules`에 **복사본**으로 설치되면 Vite는 낡은 파일을 씁니다. `web/client`·`web/admin`의 Vite 설정에 **`__WEB_SHARED_WORKSPACE__` → `../shared/src`** alias가 있어 워크스페이스 소스를 직접 가리킵니다.
40
+
41
+ ## HTTP 클라이언트·토스트
42
+
43
+ - **`api/client`**: 요청·성공·에러 응답 **콘솔 로그**는 `import.meta.env.DEV`일 때만 (`devHttpLog`). 프로덕션 번들에서는 해당 분기가 제거된다.
44
+ - **전역 토스트**: `error`는 **수동 닫기만**(자동 제거 없음). `info` / `success` / `warn`은 일정 시간 후 자동 제거이며 닫기 버튼은 없다. front-core 기본 닫기 레이블은 영어(`Dismiss`); 필요 시 `ToastList` `dismissAriaLabel`로 변경.
@@ -1,6 +1,24 @@
1
1
  /**
2
- * Axios — baseURL, 로깅 인터셉터, 전역 훅(onRequestStart/End), Bearer
3
- * HTTP 200/201 본문이 `{ code, data, error }` 이면 code 검사 후 data 로 치환 또는 토스트+reject
2
+ * =============================================================================
3
+ * xframe 공용 Axios 인스턴스 (`apiClient`) 인터셉터
4
+ * =============================================================================
5
+ *
6
+ * 역할 요약
7
+ * ---------
8
+ * 1) **요청**: Bearer 토큰(`sessionStore`) 부착, 선택적 전역 로딩 훅(`onRequestStart`).
9
+ * 2) **응답(HTTP 2xx)**: 공통 본문 `{ code, data, error }` 이면 비즈니스 코드 검사 후
10
+ * `response.data`를 안쪽 `data`로 치환하거나, 실패 시 토스트 콜백 + Promise reject.
11
+ * 3) **응답(HTTP 비 2xx / 네트워크)**: 메시지 추출 후 `onError` (보통 RootStore에서 토스트).
12
+ *
13
+ * 데이터가 흘러가는 순서 (성공 시)
14
+ * ---------------------------------
15
+ * 서버 JSON → axios가 파싱 → `response.data` (아직 봉투일 수 있음)
16
+ * → `logResponseSuccess` (콘솔에는 공통 봉투면 **내부 `data`만** 요약 출력)
17
+ * → `applyCommonResponseEnvelope` (봉투면 `response.data = body.data` 로 치환)
18
+ * → `sendRequest` / 호출부는 **항상 “실제 페이로드”**만 받는 것을 목표로 함.
19
+ *
20
+ * `setApiHooks`는 앱 기동 시 한 번(RootStore 생성자 등)에서 `rootStore`와 연결합니다.
21
+ * `skipGlobalIndicator`는 개별 요청 config에 붙이며, 전역 로딩 바/카운터를 건너뜁니다.
4
22
  */
5
23
 
6
24
  import axios, {
@@ -11,16 +29,23 @@ import axios, {
11
29
  import { sessionStore } from '../store/session-store';
12
30
  import {
13
31
  applyCommonResponseEnvelope,
14
- CommonResponseRejectedError,
15
32
  formatCommonErrorField,
16
33
  isCommonResponsePayload,
17
34
  } from './commonResponse';
18
35
 
19
36
  const LOG_PREFIX = '[xframe/http]';
20
37
 
38
+ /** 요청/성공 응답 콘솔 로그 — Vite `import.meta.env.DEV`일 때만 (프로덕션 번들에서는 제거됨) */
39
+ function devHttpLog(fn: () => void) {
40
+ if (import.meta.env.DEV) fn();
41
+ }
42
+
43
+ /** 전역 로딩(예: 상단 바) — `skipGlobalIndicator`가 아닐 때만 호출됨 */
21
44
  export type OnRequestStart = () => void;
22
45
  export type OnRequestEnd = () => void;
46
+ /** HTTP 레벨 실패(4xx/5xx, 네트워크 등) — 표시는 보통 RootStore `addToast` */
23
47
  export type OnError = (message: string, code?: string | number) => void;
48
+ /** HTTP 2xx인데 본문 `code !== 0` — 비즈니스 실패 */
24
49
  export type OnCommonBusinessError = (message: string, appCode: number) => void;
25
50
 
26
51
  let onRequestStart: OnRequestStart = () => {};
@@ -35,6 +60,11 @@ export {
35
60
  isCommonResponsePayload,
36
61
  } from './commonResponse';
37
62
 
63
+ /**
64
+ * 런타임에 전역 훅을 주입한다. **전달한 키만** 덮어쓰고, 나머지는 기존 구현을 유지한다.
65
+ * - 테스트 시 mock으로 바꾸거나, 스토어 없이 쓰는 스크립트에서 noop 유지 가능.
66
+ * @param hooks - 요청 시작/종료, HTTP 에러, 비즈니스 코드 실패 시 콜백
67
+ */
38
68
  export function setApiHooks(hooks: {
39
69
  onRequestStart?: OnRequestStart;
40
70
  onRequestEnd?: OnRequestEnd;
@@ -53,16 +83,27 @@ export const apiClient = axios.create({
53
83
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
54
84
  });
55
85
 
86
+ /**
87
+ * axios 기본 `baseURL` 설정. 끝의 `/`는 제거해 경로 결합 시 이중 슬래시를 피한다.
88
+ * @param options.baseURL - 예: `https://api.example.com` 또는 `http://localhost:3000`
89
+ */
56
90
  export function configureApi(options: { baseURL: string }) {
57
91
  apiClient.defaults.baseURL = options.baseURL.replace(/\/$/, '');
58
92
  }
59
93
 
94
+ /**
95
+ * 현재 설정된 axios `baseURL` (미설정이면 빈 문자열).
96
+ */
60
97
  export function getApiBaseUrl(): string {
61
98
  return apiClient.defaults.baseURL ?? '';
62
99
  }
63
100
 
101
+ /** axios InternalAxiosRequestConfig 확장 — sendRequest가 문자열 키로 전달 */
64
102
  type ExtConfig = InternalAxiosRequestConfig & { skipGlobalIndicator?: boolean };
65
103
 
104
+ /**
105
+ * 나가는 요청을 콘솔에 남긴다. 호출부는 `devHttpLog`로 감싸 **개발 모드에서만** 실행한다.
106
+ */
66
107
  function logRequest(config: InternalAxiosRequestConfig) {
67
108
  console.log(`${LOG_PREFIX} request`, {
68
109
  method: config.method?.toUpperCase(),
@@ -74,6 +115,12 @@ function logRequest(config: InternalAxiosRequestConfig) {
74
115
  });
75
116
  }
76
117
 
118
+ /**
119
+ * 성공 응답 로그: 공통 봉투면 `data` 키에 **내부 페이로드**만 넣어 가독성 확보.
120
+ * (인터셉터에서 아직 치환 전이므로, 여기서는 `response.data`를 직접 읽어 분기)
121
+ * 호출부는 `devHttpLog`로 감싸 프로덕션에서는 출력하지 않는다.
122
+ * @param response - axios 2xx 응답
123
+ */
77
124
  function logResponseSuccess(response: AxiosResponse) {
78
125
  const body = response.data;
79
126
  const data = isCommonResponsePayload(body) ? body.data : body;
@@ -85,6 +132,10 @@ function logResponseSuccess(response: AxiosResponse) {
85
132
  });
86
133
  }
87
134
 
135
+ /**
136
+ * AxiosError면 status·url·응답 body 등을 묶어 로그, 그 외는 그대로 출력.
137
+ * `devHttpLog` 안에서만 호출한다.
138
+ */
88
139
  function logResponseError(error: unknown) {
89
140
  if (axios.isAxiosError(error)) {
90
141
  console.log(`${LOG_PREFIX} response error`, {
@@ -100,43 +151,51 @@ function logResponseError(error: unknown) {
100
151
  }
101
152
  }
102
153
 
154
+ /** 요청: 로깅 → Bearer → FormData 시 Content-Type 제거 → 전역 로딩 시작(옵션) */
103
155
  apiClient.interceptors.request.use(
104
156
  (config: InternalAxiosRequestConfig) => {
105
- logRequest(config);
157
+ devHttpLog(() => logRequest(config));
106
158
  const c = config as ExtConfig;
107
159
  const token = sessionStore.token;
108
160
  if (token && c.headers) {
109
161
  c.headers.Authorization = `Bearer ${token}`;
110
162
  }
163
+ // 브라우저가 multipart boundary를 넣도록 Content-Type 제거
111
164
  if (c.data instanceof FormData && c.headers) {
112
165
  delete c.headers['Content-Type'];
113
166
  }
114
167
  if (!c.skipGlobalIndicator) onRequestStart();
115
168
  return c;
116
169
  },
170
+ /** 요청 단계에서의 예외 (네트워크 전 단계 등) */
117
171
  (err) => {
118
- console.log(`${LOG_PREFIX} request error`, err);
172
+ devHttpLog(() => console.log(`${LOG_PREFIX} request error`, err));
119
173
  return Promise.reject(err);
120
174
  },
121
175
  );
122
176
 
177
+ /** 응답(2xx): 전역 로딩 종료 → 성공 로그 → 공통 봉투 처리 */
123
178
  apiClient.interceptors.response.use(
124
179
  (response: AxiosResponse) => {
125
180
  const c = response.config as ExtConfig;
181
+ // 성공이므로 이 요청에 대응하는 “진행 중” 카운터를 내림
126
182
  if (!c.skipGlobalIndicator) onRequestEnd();
127
- logResponseSuccess(response);
183
+ devHttpLog(() => logResponseSuccess(response));
128
184
  try {
185
+ // 봉투 처리 실패 시 CommonResponseRejectedError throw → 아래 체인에서 catch
129
186
  applyCommonResponseEnvelope(response, onCommonBusinessError);
130
187
  } catch (e) {
131
188
  return Promise.reject(e);
132
189
  }
133
190
  return response;
134
191
  },
192
+ /** 응답(비 2xx)·네트워크 오류: 로딩 종료 → 로그 → 메시지 추출 → `onError` → reject */
135
193
  (err: AxiosError) => {
136
194
  const c = err.config as ExtConfig | undefined;
137
195
  if (!c?.skipGlobalIndicator) onRequestEnd();
138
- logResponseError(err);
196
+ devHttpLog(() => logResponseError(err));
139
197
  const resData = err.response?.data;
198
+ // 공통 봉투가 실패 응답에 실려 온 경우 error 필드 우선
140
199
  let raw: unknown =
141
200
  isCommonResponsePayload(resData) ? resData.error : undefined;
142
201
  if (raw === undefined) {
@@ -1,17 +1,45 @@
1
1
  /**
2
- * 서버 공통 본문: HTTP 200/201 + body `{ code, data, error }`
3
- * — 비즈니스 실패는 code !== 0 (HTTP는 정상).
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
- /** `error` 필드를 사용자용 문구로 */
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
- * 성공 인터셉터에서 호출: 공통 래핑이면 code 검사 후 data 로 치환하거나 reject.
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,