@xfilecom/xframe 0.1.38 → 0.1.40
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 +22 -5
- package/template/web/admin/src/App.tsx +1 -1
- 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 +1 -1
- 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 +66 -7
- 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 +44 -3
- package/template/web/shared/src/hooks/useAppStore.ts +28 -3
- package/template/web/shared/src/hooks/useHealthStatus.ts +25 -2
- 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 +142 -8
- package/template/web/shared/src/store/session-store.ts +21 -2
- 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
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* shared/endpoint — Nest·Vite 공통 API 경로 정의 (`EndpointDef`)
|
|
4
|
+
* =============================================================================
|
|
4
5
|
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
5
|
-
readonly
|
|
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.
|
|
73
|
-
'import.meta.env.
|
|
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.
|
|
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="
|
|
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.
|
|
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
|
-
|
|
5
|
-
readonly
|
|
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.
|
|
76
|
-
'import.meta.env.
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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,
|