sh-ui-cli 0.23.1 → 0.25.0
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/README.md +0 -2
- package/data/changelog/versions.json +25 -0
- package/package.json +1 -1
- package/src/create/index.mjs +2 -3
- package/src/create/plugins/nextIntl.js +36 -20
- package/templates/monorepo/README.md +3 -3
- package/templates/nextjs-app/app/api/proxy/[...path]/route.ts +85 -0
- package/templates/nextjs-app/app/layout.tsx +5 -11
- package/templates/nextjs-app/src/app/layouts/RootLayout.tsx +11 -0
- package/templates/nextjs-app/src/shared/api/apiTypes.ts +21 -0
- package/templates/nextjs-app/src/shared/api/error.ts +12 -0
- package/templates/nextjs-app/src/shared/api/http.ts +56 -0
- package/templates/nextjs-standalone/app/api/proxy/[...path]/route.ts +85 -0
- package/templates/nextjs-standalone/app/layout.tsx +5 -11
- package/templates/nextjs-standalone/src/app/layouts/RootLayout.tsx +11 -0
- package/templates/nextjs-standalone/src/shared/api/apiTypes.ts +21 -0
- package/templates/nextjs-standalone/src/shared/api/error.ts +12 -0
- package/templates/nextjs-standalone/src/shared/api/http.ts +56 -0
- package/templates/nextjs-app/src/app/layouts/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/app/layouts/.gitkeep +0 -0
package/README.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.25.0",
|
|
7
|
+
"date": "2026-04-28",
|
|
8
|
+
"title": "Next.js 템플릿에 http 골격 + BFF proxy 추가 — IS_SERVER 분기 제거",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"nextjs-app · nextjs-standalone 템플릿에 axios 단일 인스턴스(src/shared/api/http.ts)와 BFF 라우트 핸들러(app/api/proxy/[...path]/route.ts) 추가 — 클라/서버 모두 /api/proxy 경유, IS_SERVER 분기 없음",
|
|
12
|
+
"인증 · 토큰 refresh · Sentry · 로그인 쿠키 특례 처리는 의도적으로 제외해 인증 비종속 골격으로 유지 — 프로젝트별 추가는 apps/docs/recipes 가이드로 분리",
|
|
13
|
+
"레시피 9종 추가 (apps/docs/app/recipes/): api-layer, auth, file-upload, tanstack-query, async-boundary, i18n, sentry, testing, deployment",
|
|
14
|
+
"사이드바에 '레시피' 진입점 추가"
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.25.0"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.24.0",
|
|
20
|
+
"date": "2026-04-28",
|
|
21
|
+
"title": "Next.js 템플릿 RootLayout 분리 — app/layout.tsx 는 thin wrapper",
|
|
22
|
+
"type": "minor",
|
|
23
|
+
"highlights": [
|
|
24
|
+
"nextjs-standalone / nextjs-app 모두 src/app/layouts/RootLayout.tsx 로 layout 본체 분리, app/layout.tsx 는 metadata + thin wrapper 만 유지",
|
|
25
|
+
"next-intl 플러그인 적용 시에도 동일 패턴 — RootLayout 이 locale 검증·hasLocale·notFound 까지 담당하고 app/[locale]/layout.tsx 는 params forward 만",
|
|
26
|
+
"사용자가 layout 로직을 확장할 때 entrypoint(layout.tsx) 와 컴포넌트(RootLayout) 책임이 분리돼 FSD 관습에 맞게 정렬"
|
|
27
|
+
],
|
|
28
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.24.0"
|
|
29
|
+
},
|
|
5
30
|
{
|
|
6
31
|
"version": "0.23.1",
|
|
7
32
|
"date": "2026-04-28",
|
package/package.json
CHANGED
package/src/create/index.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// sh-ui create — 프로젝트 스캐폴드 진입점.
|
|
2
|
-
// bin/sh-ui.mjs 의 `create` 서브커맨드와 sh-ui-create 호환 shim 양쪽에서 호출된다.
|
|
1
|
+
// sh-ui create — 프로젝트 스캐폴드 진입점. bin/sh-ui.mjs 의 `create` 서브커맨드에서 호출.
|
|
3
2
|
|
|
4
3
|
import { parseArgs } from './cli-args.js';
|
|
5
4
|
import { createProject, addApp, addComponent } from './generator.js';
|
|
@@ -37,7 +36,7 @@ export async function runCreate(rest) {
|
|
|
37
36
|
// parseArgs 가 process.argv 형태(앞 두 개는 스킵)를 기대하므로 더미 두 개를 prepend.
|
|
38
37
|
let parsed;
|
|
39
38
|
try {
|
|
40
|
-
parsed = parseArgs(['node', 'sh-ui
|
|
39
|
+
parsed = parseArgs(['node', 'sh-ui', ...rest]);
|
|
41
40
|
} catch (e) {
|
|
42
41
|
console.error(`❌ ${e.message}`);
|
|
43
42
|
console.error(`\n도움말: sh-ui create --help`);
|
|
@@ -47,6 +47,37 @@ export const nextIntlPlugin = {
|
|
|
47
47
|
}) {
|
|
48
48
|
return children;
|
|
49
49
|
}
|
|
50
|
+
`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'replace',
|
|
54
|
+
path: 'src/app/layouts/RootLayout.tsx',
|
|
55
|
+
content: `import { hasLocale } from 'next-intl';
|
|
56
|
+
import { notFound } from 'next/navigation';
|
|
57
|
+
import { GlobalProvider } from '@/src/app/providers';
|
|
58
|
+
import { routing } from '@/src/shared/config/i18n/routing';
|
|
59
|
+
|
|
60
|
+
export async function RootLayout({
|
|
61
|
+
children,
|
|
62
|
+
params,
|
|
63
|
+
}: {
|
|
64
|
+
children: React.ReactNode;
|
|
65
|
+
params: Promise<{ locale: string }>;
|
|
66
|
+
}) {
|
|
67
|
+
const { locale } = await params;
|
|
68
|
+
|
|
69
|
+
if (!hasLocale(routing.locales, locale)) {
|
|
70
|
+
notFound();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<html lang={locale} suppressHydrationWarning>
|
|
75
|
+
<body>
|
|
76
|
+
<GlobalProvider>{children}</GlobalProvider>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
50
81
|
`,
|
|
51
82
|
},
|
|
52
83
|
],
|
|
@@ -149,36 +180,21 @@ export const { Link, redirect, usePathname, useRouter, getPathname } =
|
|
|
149
180
|
`,
|
|
150
181
|
|
|
151
182
|
'app/[locale]/layout.tsx': `import type { Metadata } from 'next';
|
|
152
|
-
import {
|
|
153
|
-
import { notFound } from 'next/navigation';
|
|
154
|
-
import { GlobalProvider } from '@/src/app/providers';
|
|
155
|
-
import { routing } from '@/src/shared/config/i18n/routing';
|
|
183
|
+
import { RootLayout } from '@/src/app/layouts/RootLayout';
|
|
156
184
|
|
|
157
185
|
export const metadata: Metadata = {
|
|
158
186
|
title: 'My App',
|
|
159
187
|
description: 'My App Description',
|
|
160
188
|
};
|
|
161
189
|
|
|
162
|
-
export default
|
|
190
|
+
export default function Layout({
|
|
163
191
|
children,
|
|
164
192
|
params,
|
|
165
|
-
}: {
|
|
193
|
+
}: Readonly<{
|
|
166
194
|
children: React.ReactNode;
|
|
167
195
|
params: Promise<{ locale: string }>;
|
|
168
|
-
}) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!hasLocale(routing.locales, locale)) {
|
|
172
|
-
notFound();
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return (
|
|
176
|
-
<html lang={locale} suppressHydrationWarning>
|
|
177
|
-
<body>
|
|
178
|
-
<GlobalProvider>{children}</GlobalProvider>
|
|
179
|
-
</body>
|
|
180
|
-
</html>
|
|
181
|
-
);
|
|
196
|
+
}>) {
|
|
197
|
+
return <RootLayout params={params}>{children}</RootLayout>;
|
|
182
198
|
}
|
|
183
199
|
`,
|
|
184
200
|
|
|
@@ -77,7 +77,7 @@ pnpm dev # 모든 앱 동시 실행
|
|
|
77
77
|
## 앱 추가
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
|
-
npx sh-ui-create add-app
|
|
80
|
+
npx sh-ui-cli create add-app
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
`apps/{name}/` 과 `packages/ui/ui-apps/ui-{name}/` 을 함께 생성합니다.
|
|
@@ -93,10 +93,10 @@ pnpm --filter admin build
|
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
95
|
# 모든 ui 패키지에 추가 (대화형)
|
|
96
|
-
npx sh-ui-create add-component button
|
|
96
|
+
npx sh-ui-cli create add-component button
|
|
97
97
|
|
|
98
98
|
# 특정 앱에만 추가
|
|
99
|
-
npx sh-ui-create add-component button --app web
|
|
99
|
+
npx sh-ui-cli create add-component button --app web
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
내부적으로 `packages/ui/ui-apps/ui-{app}/` 디렉토리에서 `npx sh-ui add button` 이 실행되며,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
|
|
5
|
+
const ACCESS_TOKEN_COOKIE = 'accessToken';
|
|
6
|
+
const LOCALE_COOKIE = 'NEXT_LOCALE';
|
|
7
|
+
|
|
8
|
+
const proxyRequest = async (
|
|
9
|
+
request: NextRequest,
|
|
10
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
11
|
+
method: string,
|
|
12
|
+
) => {
|
|
13
|
+
const { path } = await ctx.params;
|
|
14
|
+
const url = new URL(`${API_URL}/${path.join('/')}`);
|
|
15
|
+
|
|
16
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
17
|
+
url.searchParams.set(key, value);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const cookieStore = await cookies();
|
|
21
|
+
const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
22
|
+
const locale =
|
|
23
|
+
cookieStore.get(LOCALE_COOKIE)?.value ??
|
|
24
|
+
request.headers.get('Accept-Language') ??
|
|
25
|
+
undefined;
|
|
26
|
+
|
|
27
|
+
const headers: Record<string, string> = {};
|
|
28
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
29
|
+
if (locale) headers['Accept-Language'] = locale;
|
|
30
|
+
|
|
31
|
+
let body: BodyInit | undefined;
|
|
32
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
33
|
+
const contentType = request.headers.get('Content-Type');
|
|
34
|
+
if (contentType?.includes('multipart/form-data')) {
|
|
35
|
+
body = await request.formData();
|
|
36
|
+
} else {
|
|
37
|
+
headers['Content-Type'] = 'application/json';
|
|
38
|
+
body = await request.text();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(url.toString(), { method, headers, body });
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return NextResponse.json(data, { status: response.status });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`[PROXY] ${method} ${url.toString()} —`, error);
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
result: 'ERROR',
|
|
51
|
+
data: null,
|
|
52
|
+
error: {
|
|
53
|
+
code: 'NETWORK_ERROR',
|
|
54
|
+
message: '서버에 연결할 수 없습니다.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ status: 502 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const GET = (
|
|
63
|
+
req: NextRequest,
|
|
64
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
65
|
+
) => proxyRequest(req, ctx, 'GET');
|
|
66
|
+
|
|
67
|
+
export const POST = (
|
|
68
|
+
req: NextRequest,
|
|
69
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
70
|
+
) => proxyRequest(req, ctx, 'POST');
|
|
71
|
+
|
|
72
|
+
export const PUT = (
|
|
73
|
+
req: NextRequest,
|
|
74
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
75
|
+
) => proxyRequest(req, ctx, 'PUT');
|
|
76
|
+
|
|
77
|
+
export const PATCH = (
|
|
78
|
+
req: NextRequest,
|
|
79
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
80
|
+
) => proxyRequest(req, ctx, 'PATCH');
|
|
81
|
+
|
|
82
|
+
export const DELETE = (
|
|
83
|
+
req: NextRequest,
|
|
84
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
85
|
+
) => proxyRequest(req, ctx, 'DELETE');
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import '@workspace/ui-app-name/globals.css';
|
|
3
|
-
import {
|
|
3
|
+
import { RootLayout } from '@/src/app/layouts/RootLayout';
|
|
4
4
|
|
|
5
5
|
export const metadata: Metadata = {
|
|
6
6
|
title: 'App Name',
|
|
7
7
|
description: 'App Description',
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
export default function
|
|
10
|
+
export default function Layout({
|
|
11
11
|
children,
|
|
12
|
-
}: {
|
|
12
|
+
}: Readonly<{
|
|
13
13
|
children: React.ReactNode;
|
|
14
|
-
}) {
|
|
15
|
-
return
|
|
16
|
-
<html lang='ko' suppressHydrationWarning>
|
|
17
|
-
<body>
|
|
18
|
-
<GlobalProvider>{children}</GlobalProvider>
|
|
19
|
-
</body>
|
|
20
|
-
</html>
|
|
21
|
-
);
|
|
14
|
+
}>) {
|
|
15
|
+
return <RootLayout>{children}</RootLayout>;
|
|
22
16
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GlobalProvider } from '@/src/app/providers';
|
|
2
|
+
|
|
3
|
+
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
|
+
return (
|
|
5
|
+
<html lang='ko' suppressHydrationWarning>
|
|
6
|
+
<body>
|
|
7
|
+
<GlobalProvider>{children}</GlobalProvider>
|
|
8
|
+
</body>
|
|
9
|
+
</html>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 공통 API 에러 형식 */
|
|
2
|
+
export type ApiErrorBody = {
|
|
3
|
+
message: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** 백엔드 공통 응답 래퍼 */
|
|
8
|
+
export type ApiResponse<TData = unknown, TError = ApiErrorBody> = {
|
|
9
|
+
result: 'SUCCESS' | 'ERROR';
|
|
10
|
+
data: TData;
|
|
11
|
+
error: TError | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 페이지네이션 응답 래퍼 */
|
|
15
|
+
export type PaginatedData<T> = {
|
|
16
|
+
content: T[];
|
|
17
|
+
totalItems: number;
|
|
18
|
+
offset: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApiErrorBody } from './apiTypes';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly status: number,
|
|
6
|
+
public readonly code: string,
|
|
7
|
+
public readonly data: ApiErrorBody | null,
|
|
8
|
+
) {
|
|
9
|
+
super(data?.message ?? `API 요청 실패 (${status})`);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import { ApiError } from './error';
|
|
4
|
+
import type { ApiResponse } from './apiTypes';
|
|
5
|
+
|
|
6
|
+
const HTTP_TIMEOUT = 10_000;
|
|
7
|
+
|
|
8
|
+
const http = axios.create({
|
|
9
|
+
baseURL: '/api/proxy',
|
|
10
|
+
timeout: HTTP_TIMEOUT,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// 서버 컴포넌트에서 호출 시 axios 는 절대 URL 이 필요하다.
|
|
14
|
+
// 호스트만 프리픽스하고 그 외 분기는 두지 않는다.
|
|
15
|
+
http.interceptors.request.use(async (config) => {
|
|
16
|
+
if (typeof window !== 'undefined') return config;
|
|
17
|
+
|
|
18
|
+
const { headers: getHeaders } = await import('next/headers');
|
|
19
|
+
const hdrs = await getHeaders();
|
|
20
|
+
const host = hdrs.get('host') ?? 'localhost:3000';
|
|
21
|
+
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
|
22
|
+
config.baseURL = `${protocol}://${host}/api/proxy`;
|
|
23
|
+
return config;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
http.interceptors.response.use(
|
|
27
|
+
(response) => {
|
|
28
|
+
const body = response.data as ApiResponse;
|
|
29
|
+
if (body && typeof body === 'object' && 'result' in body) {
|
|
30
|
+
if (body.result === 'ERROR') {
|
|
31
|
+
return Promise.reject(
|
|
32
|
+
new ApiError(response.status, body.error?.code ?? '', body.error),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
response.data = body.data;
|
|
36
|
+
}
|
|
37
|
+
return response;
|
|
38
|
+
},
|
|
39
|
+
(error) => {
|
|
40
|
+
if (axios.isAxiosError(error)) {
|
|
41
|
+
const { response } = error;
|
|
42
|
+
const body = response?.data as ApiResponse | undefined;
|
|
43
|
+
const errorBody = body?.error ?? null;
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new ApiError(
|
|
46
|
+
response?.status ?? 0,
|
|
47
|
+
errorBody?.code ?? '',
|
|
48
|
+
errorBody,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return Promise.reject(error);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export { http };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
|
|
5
|
+
const ACCESS_TOKEN_COOKIE = 'accessToken';
|
|
6
|
+
const LOCALE_COOKIE = 'NEXT_LOCALE';
|
|
7
|
+
|
|
8
|
+
const proxyRequest = async (
|
|
9
|
+
request: NextRequest,
|
|
10
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
11
|
+
method: string,
|
|
12
|
+
) => {
|
|
13
|
+
const { path } = await ctx.params;
|
|
14
|
+
const url = new URL(`${API_URL}/${path.join('/')}`);
|
|
15
|
+
|
|
16
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
17
|
+
url.searchParams.set(key, value);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const cookieStore = await cookies();
|
|
21
|
+
const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
22
|
+
const locale =
|
|
23
|
+
cookieStore.get(LOCALE_COOKIE)?.value ??
|
|
24
|
+
request.headers.get('Accept-Language') ??
|
|
25
|
+
undefined;
|
|
26
|
+
|
|
27
|
+
const headers: Record<string, string> = {};
|
|
28
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
29
|
+
if (locale) headers['Accept-Language'] = locale;
|
|
30
|
+
|
|
31
|
+
let body: BodyInit | undefined;
|
|
32
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
33
|
+
const contentType = request.headers.get('Content-Type');
|
|
34
|
+
if (contentType?.includes('multipart/form-data')) {
|
|
35
|
+
body = await request.formData();
|
|
36
|
+
} else {
|
|
37
|
+
headers['Content-Type'] = 'application/json';
|
|
38
|
+
body = await request.text();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(url.toString(), { method, headers, body });
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return NextResponse.json(data, { status: response.status });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`[PROXY] ${method} ${url.toString()} —`, error);
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
result: 'ERROR',
|
|
51
|
+
data: null,
|
|
52
|
+
error: {
|
|
53
|
+
code: 'NETWORK_ERROR',
|
|
54
|
+
message: '서버에 연결할 수 없습니다.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ status: 502 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const GET = (
|
|
63
|
+
req: NextRequest,
|
|
64
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
65
|
+
) => proxyRequest(req, ctx, 'GET');
|
|
66
|
+
|
|
67
|
+
export const POST = (
|
|
68
|
+
req: NextRequest,
|
|
69
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
70
|
+
) => proxyRequest(req, ctx, 'POST');
|
|
71
|
+
|
|
72
|
+
export const PUT = (
|
|
73
|
+
req: NextRequest,
|
|
74
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
75
|
+
) => proxyRequest(req, ctx, 'PUT');
|
|
76
|
+
|
|
77
|
+
export const PATCH = (
|
|
78
|
+
req: NextRequest,
|
|
79
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
80
|
+
) => proxyRequest(req, ctx, 'PATCH');
|
|
81
|
+
|
|
82
|
+
export const DELETE = (
|
|
83
|
+
req: NextRequest,
|
|
84
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
85
|
+
) => proxyRequest(req, ctx, 'DELETE');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
-
import {
|
|
2
|
+
import { RootLayout } from '@/src/app/layouts/RootLayout';
|
|
3
3
|
import './globals.css';
|
|
4
4
|
|
|
5
5
|
export const metadata: Metadata = {
|
|
@@ -7,16 +7,10 @@ export const metadata: Metadata = {
|
|
|
7
7
|
description: 'My App Description',
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
export default function
|
|
10
|
+
export default function Layout({
|
|
11
11
|
children,
|
|
12
|
-
}: {
|
|
12
|
+
}: Readonly<{
|
|
13
13
|
children: React.ReactNode;
|
|
14
|
-
}) {
|
|
15
|
-
return
|
|
16
|
-
<html lang='ko' suppressHydrationWarning>
|
|
17
|
-
<body>
|
|
18
|
-
<GlobalProvider>{children}</GlobalProvider>
|
|
19
|
-
</body>
|
|
20
|
-
</html>
|
|
21
|
-
);
|
|
14
|
+
}>) {
|
|
15
|
+
return <RootLayout>{children}</RootLayout>;
|
|
22
16
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GlobalProvider } from '@/src/app/providers';
|
|
2
|
+
|
|
3
|
+
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
|
+
return (
|
|
5
|
+
<html lang='ko' suppressHydrationWarning>
|
|
6
|
+
<body>
|
|
7
|
+
<GlobalProvider>{children}</GlobalProvider>
|
|
8
|
+
</body>
|
|
9
|
+
</html>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 공통 API 에러 형식 */
|
|
2
|
+
export type ApiErrorBody = {
|
|
3
|
+
message: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** 백엔드 공통 응답 래퍼 */
|
|
8
|
+
export type ApiResponse<TData = unknown, TError = ApiErrorBody> = {
|
|
9
|
+
result: 'SUCCESS' | 'ERROR';
|
|
10
|
+
data: TData;
|
|
11
|
+
error: TError | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 페이지네이션 응답 래퍼 */
|
|
15
|
+
export type PaginatedData<T> = {
|
|
16
|
+
content: T[];
|
|
17
|
+
totalItems: number;
|
|
18
|
+
offset: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApiErrorBody } from './apiTypes';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly status: number,
|
|
6
|
+
public readonly code: string,
|
|
7
|
+
public readonly data: ApiErrorBody | null,
|
|
8
|
+
) {
|
|
9
|
+
super(data?.message ?? `API 요청 실패 (${status})`);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import { ApiError } from './error';
|
|
4
|
+
import type { ApiResponse } from './apiTypes';
|
|
5
|
+
|
|
6
|
+
const HTTP_TIMEOUT = 10_000;
|
|
7
|
+
|
|
8
|
+
const http = axios.create({
|
|
9
|
+
baseURL: '/api/proxy',
|
|
10
|
+
timeout: HTTP_TIMEOUT,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// 서버 컴포넌트에서 호출 시 axios 는 절대 URL 이 필요하다.
|
|
14
|
+
// 호스트만 프리픽스하고 그 외 분기는 두지 않는다.
|
|
15
|
+
http.interceptors.request.use(async (config) => {
|
|
16
|
+
if (typeof window !== 'undefined') return config;
|
|
17
|
+
|
|
18
|
+
const { headers: getHeaders } = await import('next/headers');
|
|
19
|
+
const hdrs = await getHeaders();
|
|
20
|
+
const host = hdrs.get('host') ?? 'localhost:3000';
|
|
21
|
+
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
|
22
|
+
config.baseURL = `${protocol}://${host}/api/proxy`;
|
|
23
|
+
return config;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
http.interceptors.response.use(
|
|
27
|
+
(response) => {
|
|
28
|
+
const body = response.data as ApiResponse;
|
|
29
|
+
if (body && typeof body === 'object' && 'result' in body) {
|
|
30
|
+
if (body.result === 'ERROR') {
|
|
31
|
+
return Promise.reject(
|
|
32
|
+
new ApiError(response.status, body.error?.code ?? '', body.error),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
response.data = body.data;
|
|
36
|
+
}
|
|
37
|
+
return response;
|
|
38
|
+
},
|
|
39
|
+
(error) => {
|
|
40
|
+
if (axios.isAxiosError(error)) {
|
|
41
|
+
const { response } = error;
|
|
42
|
+
const body = response?.data as ApiResponse | undefined;
|
|
43
|
+
const errorBody = body?.error ?? null;
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new ApiError(
|
|
46
|
+
response?.status ?? 0,
|
|
47
|
+
errorBody?.code ?? '',
|
|
48
|
+
errorBody,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return Promise.reject(error);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export { http };
|
|
File without changes
|
|
File without changes
|