sh-ui-cli 0.22.2 → 0.23.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 +19 -2
- package/bin/sh-ui.mjs +7 -0
- package/data/changelog/versions.json +14 -0
- package/package.json +13 -2
- package/src/create/cli-args.js +63 -0
- package/src/create/generator.js +542 -0
- package/src/create/index.mjs +68 -0
- package/src/create/plugins/index.js +17 -0
- package/src/create/plugins/nextIntl.js +197 -0
- package/src/create/plugins/sentry.js +689 -0
- package/src/create/theme/decode.js +66 -0
- package/src/create/theme/inject.js +111 -0
- package/src/mcp.mjs +81 -27
- package/src/paths.mjs +5 -0
- package/templates/flutter-standalone/README.md +34 -0
- package/templates/flutter-standalone/analysis_options.yaml +1 -0
- package/templates/flutter-standalone/lib/main.dart +103 -0
- package/templates/flutter-standalone/lib/sh_ui/foundation/sh_ui_tokens.dart +389 -0
- package/templates/flutter-standalone/pubspec.yaml +20 -0
- package/templates/flutter-standalone/sh-ui.config.json +15 -0
- package/templates/monorepo/.dockerignore +7 -0
- package/templates/monorepo/.eslintrc.js +8 -0
- package/templates/monorepo/.prettierrc +17 -0
- package/templates/monorepo/README.md +103 -0
- package/templates/monorepo/package.json +24 -0
- package/templates/monorepo/packages/eslint-config/base.js +31 -0
- package/templates/monorepo/packages/eslint-config/fsd.js +119 -0
- package/templates/monorepo/packages/eslint-config/next.js +65 -0
- package/templates/monorepo/packages/eslint-config/package.json +31 -0
- package/templates/monorepo/packages/eslint-config/react-internal.js +36 -0
- package/templates/monorepo/packages/typescript-config/base.json +20 -0
- package/templates/monorepo/packages/typescript-config/nextjs.json +13 -0
- package/templates/monorepo/packages/typescript-config/package.json +5 -0
- package/templates/monorepo/packages/typescript-config/react-library.json +8 -0
- package/templates/monorepo/packages/ui/ui-apps/.gitkeep +0 -0
- package/templates/monorepo/packages/ui/ui-core/eslint.config.js +3 -0
- package/templates/monorepo/packages/ui/ui-core/package.json +23 -0
- package/templates/monorepo/packages/ui/ui-core/src/lib/utils.ts +6 -0
- package/templates/monorepo/packages/ui/ui-core/tsconfig.json +11 -0
- package/templates/monorepo/pnpm-workspace.yaml +5 -0
- package/templates/monorepo/tsconfig.json +3 -0
- package/templates/monorepo/turbo.json +26 -0
- package/templates/nextjs-app/.env.example +2 -0
- package/templates/nextjs-app/Dockerfile +11 -0
- package/templates/nextjs-app/README.md +64 -0
- package/templates/nextjs-app/app/layout.tsx +22 -0
- package/templates/nextjs-app/app/page.tsx +7 -0
- package/templates/nextjs-app/eslint.config.js +10 -0
- package/templates/nextjs-app/next.config.ts +12 -0
- package/templates/nextjs-app/package.json +45 -0
- package/templates/nextjs-app/postcss.config.mjs +1 -0
- package/templates/nextjs-app/src/app/layouts/.gitkeep +0 -0
- package/templates/nextjs-app/src/app/providers/GlobalProvider/index.tsx +23 -0
- package/templates/nextjs-app/src/app/providers/index.tsx +1 -0
- package/templates/nextjs-app/src/app/providers/tanstack/QueryClientProvider.tsx +27 -0
- package/templates/nextjs-app/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
- package/templates/nextjs-app/src/app/providers/theme/ThemeProviders.tsx +12 -0
- package/templates/nextjs-app/src/entities/.gitkeep +0 -0
- package/templates/nextjs-app/src/features/.gitkeep +0 -0
- package/templates/nextjs-app/src/shared/api/.gitkeep +0 -0
- package/templates/nextjs-app/src/shared/config/.gitkeep +0 -0
- package/templates/nextjs-app/src/shared/hooks/.gitkeep +0 -0
- package/templates/nextjs-app/src/shared/lib/.gitkeep +0 -0
- package/templates/nextjs-app/src/shared/model/.gitkeep +0 -0
- package/templates/nextjs-app/src/shared/ui/.gitkeep +0 -0
- package/templates/nextjs-app/src/views/.gitkeep +0 -0
- package/templates/nextjs-app/src/widgets/.gitkeep +0 -0
- package/templates/nextjs-app/tsconfig.json +23 -0
- package/templates/nextjs-app/vitest.config.ts +15 -0
- package/templates/nextjs-app/vitest.setup.ts +1 -0
- package/templates/nextjs-standalone/.env.example +2 -0
- package/templates/nextjs-standalone/.prettierrc +17 -0
- package/templates/nextjs-standalone/README.md +77 -0
- package/templates/nextjs-standalone/app/globals.css +33 -0
- package/templates/nextjs-standalone/app/layout.tsx +22 -0
- package/templates/nextjs-standalone/app/page.tsx +7 -0
- package/templates/nextjs-standalone/eslint.config.js +162 -0
- package/templates/nextjs-standalone/next.config.ts +10 -0
- package/templates/nextjs-standalone/package.json +66 -0
- package/templates/nextjs-standalone/postcss.config.mjs +5 -0
- package/templates/nextjs-standalone/sh-ui.config.json +19 -0
- package/templates/nextjs-standalone/src/app/layouts/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/app/providers/GlobalProvider/index.tsx +23 -0
- package/templates/nextjs-standalone/src/app/providers/index.tsx +1 -0
- package/templates/nextjs-standalone/src/app/providers/tanstack/QueryClientProvider.tsx +27 -0
- package/templates/nextjs-standalone/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
- package/templates/nextjs-standalone/src/app/providers/theme/ThemeProviders.tsx +12 -0
- package/templates/nextjs-standalone/src/entities/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/features/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/shared/api/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/shared/config/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/shared/hooks/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/shared/lib/utils.ts +6 -0
- package/templates/nextjs-standalone/src/shared/model/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/shared/styles/tokens.css +95 -0
- package/templates/nextjs-standalone/src/shared/ui/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/views/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/widgets/.gitkeep +0 -0
- package/templates/nextjs-standalone/tsconfig.json +39 -0
- package/templates/nextjs-standalone/vitest.config.ts +15 -0
- package/templates/nextjs-standalone/vitest.setup.ts +1 -0
- package/templates/ui-app-template/eslint.config.js +3 -0
- package/templates/ui-app-template/package.json +38 -0
- package/templates/ui-app-template/postcss.config.mjs +5 -0
- package/templates/ui-app-template/sh-ui.config.json +14 -0
- package/templates/ui-app-template/src/components/.gitkeep +0 -0
- package/templates/ui-app-template/src/hooks/.gitkeep +0 -0
- package/templates/ui-app-template/src/lib/.gitkeep +0 -0
- package/templates/ui-app-template/src/styles/globals.css +37 -0
- package/templates/ui-app-template/src/styles/tokens.css +95 -0
- package/templates/ui-app-template/tsconfig.json +11 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
export const sentryPlugin = {
|
|
2
|
+
name: 'sentry',
|
|
3
|
+
label: 'Sentry (에러 모니터링)',
|
|
4
|
+
priority: 1,
|
|
5
|
+
|
|
6
|
+
dependencies: {
|
|
7
|
+
'@sentry/nextjs': '^10.44.0',
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
// ─── next.config.ts 관련 ───
|
|
11
|
+
|
|
12
|
+
imports: [
|
|
13
|
+
`import { withSentryConfig } from '@sentry/nextjs';`,
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
wrapExport(expr) {
|
|
17
|
+
return `withSentryConfig(${expr}, {
|
|
18
|
+
org: process.env.SENTRY_ORG,
|
|
19
|
+
project: process.env.SENTRY_PROJECT,
|
|
20
|
+
authToken: process.env.SENTRY_AUTH_TOKEN,
|
|
21
|
+
tunnelRoute: '/monitoring',
|
|
22
|
+
silent: !process.env.CI,
|
|
23
|
+
bundleSizeOptimizations: {
|
|
24
|
+
excludeDebugStatements: true,
|
|
25
|
+
},
|
|
26
|
+
})`;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
envVars: [
|
|
30
|
+
'# Sentry',
|
|
31
|
+
'SENTRY_ORG=',
|
|
32
|
+
'SENTRY_PROJECT=',
|
|
33
|
+
'SENTRY_AUTH_TOKEN=',
|
|
34
|
+
'NEXT_PUBLIC_SENTRY_DSN=',
|
|
35
|
+
'NEXT_PUBLIC_SENTRY_ENVIRONMENT=dev',
|
|
36
|
+
],
|
|
37
|
+
|
|
38
|
+
turboEnvVars: [
|
|
39
|
+
'NEXT_PUBLIC_SENTRY_DSN',
|
|
40
|
+
'NEXT_PUBLIC_SENTRY_ENVIRONMENT',
|
|
41
|
+
'NEXT_RUNTIME',
|
|
42
|
+
'SENTRY_ORG',
|
|
43
|
+
'SENTRY_PROJECT',
|
|
44
|
+
'SENTRY_AUTH_TOKEN',
|
|
45
|
+
],
|
|
46
|
+
|
|
47
|
+
// ─── 공유 파일 조각 (providers 합성용) ───
|
|
48
|
+
|
|
49
|
+
providerImports: [],
|
|
50
|
+
providerWrappers: [],
|
|
51
|
+
|
|
52
|
+
// ─── 독립 파일 ───
|
|
53
|
+
|
|
54
|
+
files: {
|
|
55
|
+
'sentry.server.config.ts': `import * as Sentry from '@sentry/nextjs';
|
|
56
|
+
|
|
57
|
+
Sentry.init({
|
|
58
|
+
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
59
|
+
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'dev',
|
|
60
|
+
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
61
|
+
tracesSampleRate: 0,
|
|
62
|
+
beforeSend: (event, hint) => {
|
|
63
|
+
if (event.level === 'warning' || event.level === 'info' || event.level === 'debug' || event.level === 'log') {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (event.exception?.values?.[0]?.type === 'AxiosError') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const error = hint?.originalException;
|
|
72
|
+
|
|
73
|
+
if (error instanceof Error && error.name === 'ApiError') {
|
|
74
|
+
const status = (error as { status?: number }).status;
|
|
75
|
+
if (status === 401) return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (event.exception?.values?.[0]?.value?.includes('An error occurred in the Server Components render')) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return event;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
`,
|
|
86
|
+
|
|
87
|
+
'sentry.edge.config.ts': `import * as Sentry from '@sentry/nextjs';
|
|
88
|
+
|
|
89
|
+
Sentry.init({
|
|
90
|
+
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
91
|
+
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'dev',
|
|
92
|
+
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
93
|
+
tracesSampleRate: 0,
|
|
94
|
+
beforeSend: (event) => {
|
|
95
|
+
if (event.level === 'warning' || event.level === 'info' || event.level === 'debug' || event.level === 'log') {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (event.exception?.values?.[0]?.type === 'AxiosError') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return event;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
`,
|
|
107
|
+
|
|
108
|
+
'instrumentation.ts': `import * as Sentry from '@sentry/nextjs';
|
|
109
|
+
|
|
110
|
+
export const register = async () => {
|
|
111
|
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
112
|
+
await import('./sentry.server.config');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (process.env.NEXT_RUNTIME === 'edge') {
|
|
116
|
+
await import('./sentry.edge.config');
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const onRequestError = (
|
|
121
|
+
...[error, request, context]: Parameters<typeof Sentry.captureRequestError>
|
|
122
|
+
) => {
|
|
123
|
+
if (error instanceof Error && (error.name === 'ApiError' || error.name === 'AxiosError')) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Sentry.captureRequestError(error, request, context);
|
|
128
|
+
};
|
|
129
|
+
`,
|
|
130
|
+
|
|
131
|
+
'instrumentation-client.ts': `import * as Sentry from '@sentry/nextjs';
|
|
132
|
+
|
|
133
|
+
const IGNORED_ERROR_PATTERNS = [
|
|
134
|
+
'ResizeObserver loop',
|
|
135
|
+
'ResizeObserver loop completed with undelivered notifications',
|
|
136
|
+
'Non-Error promise rejection captured',
|
|
137
|
+
'AbortError',
|
|
138
|
+
'ChunkLoadError',
|
|
139
|
+
'Loading chunk',
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const BROWSER_NETWORK_ERROR_PATTERNS = [
|
|
143
|
+
'Load failed',
|
|
144
|
+
'Failed to fetch',
|
|
145
|
+
'NetworkError',
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
Sentry.init({
|
|
149
|
+
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
150
|
+
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'dev',
|
|
151
|
+
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
152
|
+
tracesSampleRate: 0,
|
|
153
|
+
integrations: [Sentry.replayIntegration()],
|
|
154
|
+
replaysSessionSampleRate: 0,
|
|
155
|
+
replaysOnErrorSampleRate: 0.5,
|
|
156
|
+
ignoreErrors: IGNORED_ERROR_PATTERNS,
|
|
157
|
+
denyUrls: [
|
|
158
|
+
/extensions\\//i,
|
|
159
|
+
/chrome-extension:/i,
|
|
160
|
+
/safari-web-extension:/i,
|
|
161
|
+
],
|
|
162
|
+
beforeSend: (event, hint) => {
|
|
163
|
+
if (event.level === 'warning' || event.level === 'info' || event.level === 'debug' || event.level === 'log') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const error = hint?.originalException;
|
|
168
|
+
|
|
169
|
+
if (error instanceof Error && error.name === 'ApiError') {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
error instanceof Error &&
|
|
175
|
+
BROWSER_NETWORK_ERROR_PATTERNS.some((pattern) => error.message.includes(pattern))
|
|
176
|
+
) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return event;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
|
185
|
+
`,
|
|
186
|
+
|
|
187
|
+
'app/global-error.tsx': `'use client';
|
|
188
|
+
|
|
189
|
+
import * as Sentry from '@sentry/nextjs';
|
|
190
|
+
import { useEffect } from 'react';
|
|
191
|
+
|
|
192
|
+
export default function GlobalError({
|
|
193
|
+
error,
|
|
194
|
+
}: {
|
|
195
|
+
error: Error & { digest?: string };
|
|
196
|
+
}) {
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
Sentry.captureException(error);
|
|
199
|
+
}, [error]);
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<html>
|
|
203
|
+
<body>
|
|
204
|
+
<div className='flex min-h-screen items-center justify-center'>
|
|
205
|
+
<div className='text-center'>
|
|
206
|
+
<h1 className='text-2xl font-bold'>Something went wrong!</h1>
|
|
207
|
+
<p className='mt-2 text-gray-600'>{error.message}</p>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</body>
|
|
211
|
+
</html>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
`,
|
|
215
|
+
|
|
216
|
+
'app/error.tsx': `'use client';
|
|
217
|
+
|
|
218
|
+
import * as Sentry from '@sentry/nextjs';
|
|
219
|
+
import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
|
|
220
|
+
import Link from 'next/link';
|
|
221
|
+
import { useEffect } from 'react';
|
|
222
|
+
|
|
223
|
+
export default function Error({
|
|
224
|
+
error,
|
|
225
|
+
reset,
|
|
226
|
+
}: {
|
|
227
|
+
error: Error & { digest?: string };
|
|
228
|
+
reset: () => void;
|
|
229
|
+
}) {
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
Sentry.captureException(error);
|
|
232
|
+
}, [error]);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div className='flex min-h-screen items-center justify-center px-4'>
|
|
236
|
+
<div className='w-full max-w-md rounded-lg border p-6 shadow-lg'>
|
|
237
|
+
<div className='mb-4 flex justify-center'>
|
|
238
|
+
<div className='flex h-16 w-16 items-center justify-center rounded-full bg-red-50 dark:bg-red-950'>
|
|
239
|
+
<AlertTriangle className='h-8 w-8 text-red-600 dark:text-red-400' />
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<h2 className='mb-2 text-center text-2xl font-bold'>오류가 발생했습니다</h2>
|
|
244
|
+
<p className='mb-6 text-center text-sm text-gray-500'>
|
|
245
|
+
예상치 못한 오류가 발생했습니다. 다시 시도해주세요.
|
|
246
|
+
</p>
|
|
247
|
+
|
|
248
|
+
<div className='rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-950'>
|
|
249
|
+
<p className='text-sm text-red-600 dark:text-red-400'>
|
|
250
|
+
{error.message || '알 수 없는 오류'}
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className='mt-6 space-y-3'>
|
|
255
|
+
<button
|
|
256
|
+
onClick={reset}
|
|
257
|
+
className='flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90'
|
|
258
|
+
>
|
|
259
|
+
<RefreshCw className='h-4 w-4' />
|
|
260
|
+
다시 시도
|
|
261
|
+
</button>
|
|
262
|
+
|
|
263
|
+
<Link
|
|
264
|
+
href='/'
|
|
265
|
+
className='flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent'
|
|
266
|
+
>
|
|
267
|
+
<Home className='h-4 w-4' />
|
|
268
|
+
홈으로 이동
|
|
269
|
+
</Link>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{process.env.NODE_ENV === 'development' && error.digest && (
|
|
273
|
+
<div className='mt-4 rounded-md bg-gray-50 p-3 dark:bg-gray-900'>
|
|
274
|
+
<p className='text-xs text-gray-600 dark:text-gray-400'>
|
|
275
|
+
Error ID: {error.digest}
|
|
276
|
+
</p>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
`,
|
|
284
|
+
|
|
285
|
+
'src/shared/ui/FallbackBoundary/index.tsx': `import React, {
|
|
286
|
+
Component,
|
|
287
|
+
ComponentType,
|
|
288
|
+
ErrorInfo,
|
|
289
|
+
ReactNode,
|
|
290
|
+
Suspense,
|
|
291
|
+
} from 'react';
|
|
292
|
+
import * as Sentry from '@sentry/nextjs';
|
|
293
|
+
import { ApiError } from '../../api/error';
|
|
294
|
+
import { QueryErrorResetBoundary } from '@tanstack/react-query';
|
|
295
|
+
|
|
296
|
+
interface ErrorFallbackProps {
|
|
297
|
+
error: Error | null;
|
|
298
|
+
resetErrorBoundary: () => void;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface ErrorBoundaryProps {
|
|
302
|
+
children: ReactNode;
|
|
303
|
+
fallback?: React.ElementType<ErrorFallbackProps>;
|
|
304
|
+
onReset: () => void;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface ErrorBoundaryState {
|
|
308
|
+
hasError: boolean;
|
|
309
|
+
error: Error | null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
313
|
+
state: ErrorBoundaryState = { hasError: false, error: null };
|
|
314
|
+
|
|
315
|
+
constructor(props: ErrorBoundaryProps) {
|
|
316
|
+
super(props);
|
|
317
|
+
this.resetErrorBoundary = this.resetErrorBoundary.bind(this);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
321
|
+
return { hasError: true, error };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
325
|
+
if (error instanceof ApiError) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
Sentry.withScope((scope) => {
|
|
330
|
+
scope.setTag('boundary', 'ErrorBoundary');
|
|
331
|
+
scope.setFingerprint(['ErrorBoundary', error.name, error.message]);
|
|
332
|
+
|
|
333
|
+
if (errorInfo.componentStack) {
|
|
334
|
+
scope.setContext('react', {
|
|
335
|
+
componentStack: errorInfo.componentStack,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
Sentry.captureException(error);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
resetErrorBoundary() {
|
|
343
|
+
this.props.onReset();
|
|
344
|
+
this.setState({ hasError: false, error: null });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
render() {
|
|
348
|
+
const { hasError, error } = this.state;
|
|
349
|
+
const { children, fallback: Fallback } = this.props;
|
|
350
|
+
|
|
351
|
+
if (hasError && Fallback) {
|
|
352
|
+
return (
|
|
353
|
+
<Fallback error={error} resetErrorBoundary={this.resetErrorBoundary} />
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return children;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
interface FallbackBoundaryProps {
|
|
362
|
+
children: ReactNode;
|
|
363
|
+
errorFallback?: ComponentType<ErrorFallbackProps>;
|
|
364
|
+
suspenseFallback?: ReactNode;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function FallbackBoundary({
|
|
368
|
+
children,
|
|
369
|
+
errorFallback,
|
|
370
|
+
suspenseFallback,
|
|
371
|
+
}: FallbackBoundaryProps) {
|
|
372
|
+
return (
|
|
373
|
+
<QueryErrorResetBoundary>
|
|
374
|
+
{({ reset }) => (
|
|
375
|
+
<ErrorBoundary onReset={reset} fallback={errorFallback}>
|
|
376
|
+
<Suspense fallback={suspenseFallback}>{children}</Suspense>
|
|
377
|
+
</ErrorBoundary>
|
|
378
|
+
)}
|
|
379
|
+
</QueryErrorResetBoundary>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
`,
|
|
383
|
+
|
|
384
|
+
'src/shared/api/apiTypes.ts': `export interface ApiErrorBody {
|
|
385
|
+
code: string;
|
|
386
|
+
message: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface ApiResponse<T = unknown> {
|
|
390
|
+
result: 'SUCCESS' | 'ERROR';
|
|
391
|
+
data: T | null;
|
|
392
|
+
error: ApiErrorBody | null;
|
|
393
|
+
}
|
|
394
|
+
`,
|
|
395
|
+
|
|
396
|
+
'src/shared/api/error.ts': `import type { ApiErrorBody } from './apiTypes';
|
|
397
|
+
|
|
398
|
+
export class ApiError extends Error {
|
|
399
|
+
constructor(
|
|
400
|
+
public readonly status: number,
|
|
401
|
+
public readonly code: string,
|
|
402
|
+
public readonly data: ApiErrorBody | null,
|
|
403
|
+
) {
|
|
404
|
+
super(data?.message ?? \`API 요청 실패 (\${status})\`);
|
|
405
|
+
this.name = 'ApiError';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
`,
|
|
409
|
+
|
|
410
|
+
'src/shared/api/apiCore.ts': `import * as Sentry from '@sentry/nextjs';
|
|
411
|
+
|
|
412
|
+
type ApiErrorLogParams = {
|
|
413
|
+
url: string;
|
|
414
|
+
method: string;
|
|
415
|
+
status: number | undefined;
|
|
416
|
+
requestHeaders?: Record<string, string | undefined>;
|
|
417
|
+
requestBody?: unknown;
|
|
418
|
+
responseBody?: unknown;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
export const logApiError = (prefix: string, params: ApiErrorLogParams): void => {
|
|
422
|
+
const { url, method, status, requestHeaders, requestBody, responseBody } = params;
|
|
423
|
+
|
|
424
|
+
console.error(\`❌ [\${prefix} ERROR REPORT]\`);
|
|
425
|
+
console.error(\`- URL: \${method} \${url}\`);
|
|
426
|
+
console.error(\`- Status: \${status ?? 'N/A'}\`);
|
|
427
|
+
|
|
428
|
+
if (requestHeaders) {
|
|
429
|
+
const { Authorization: _, ...safeHeaders } = requestHeaders;
|
|
430
|
+
console.error('- Request Headers:', safeHeaders);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (requestBody) {
|
|
434
|
+
console.error('- Request Body:', requestBody);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (responseBody) {
|
|
438
|
+
console.error('- Response Body:', responseBody);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
type ApiSentryCapture = {
|
|
443
|
+
url: string;
|
|
444
|
+
apiPath: string;
|
|
445
|
+
method: string;
|
|
446
|
+
status: number | undefined;
|
|
447
|
+
responseBody?: unknown;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
export const captureApiError = (params: ApiSentryCapture): void => {
|
|
451
|
+
const { url, apiPath, method, status, responseBody } = params;
|
|
452
|
+
|
|
453
|
+
if (!status || status < 500) return;
|
|
454
|
+
|
|
455
|
+
Sentry.withScope((scope) => {
|
|
456
|
+
scope.setTag('error.type', 'api');
|
|
457
|
+
scope.setContext('API Request', { method, url });
|
|
458
|
+
scope.setContext('API Response', {
|
|
459
|
+
status,
|
|
460
|
+
body: JSON.stringify(responseBody),
|
|
461
|
+
});
|
|
462
|
+
scope.setFingerprint([method, apiPath, String(status)]);
|
|
463
|
+
|
|
464
|
+
Sentry.captureException(
|
|
465
|
+
new Error(\`[API] \${method} \${apiPath} \${status}\`),
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
`,
|
|
470
|
+
|
|
471
|
+
'src/shared/api/http.ts': `import axios from 'axios';
|
|
472
|
+
import { ApiError } from './error';
|
|
473
|
+
import type { ApiResponse } from './apiTypes';
|
|
474
|
+
import { captureApiError, logApiError } from './apiCore';
|
|
475
|
+
|
|
476
|
+
const IS_SERVER = typeof window === 'undefined';
|
|
477
|
+
const API_URL = process.env.API_URL || 'http://localhost:8080/api';
|
|
478
|
+
const HTTP_TIMEOUT = 10_000;
|
|
479
|
+
|
|
480
|
+
const http = axios.create({
|
|
481
|
+
baseURL: IS_SERVER ? API_URL : '/api/proxy',
|
|
482
|
+
timeout: HTTP_TIMEOUT,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
http.interceptors.response.use(
|
|
486
|
+
(response) => {
|
|
487
|
+
const body = response.data as ApiResponse;
|
|
488
|
+
|
|
489
|
+
if (body && typeof body === 'object' && 'result' in body) {
|
|
490
|
+
if (body.result === 'ERROR') {
|
|
491
|
+
return Promise.reject(
|
|
492
|
+
new ApiError(response.status, body.error?.code ?? '', body.error),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
response.data = body.data;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return response;
|
|
499
|
+
},
|
|
500
|
+
(error) => {
|
|
501
|
+
if (axios.isAxiosError(error)) {
|
|
502
|
+
const { config, response } = error;
|
|
503
|
+
|
|
504
|
+
const fullUrl = \`\${config?.baseURL ?? ''}\${config?.url ?? ''}\`;
|
|
505
|
+
const apiPath = config?.url ?? '';
|
|
506
|
+
|
|
507
|
+
const body = response?.data as ApiResponse | undefined;
|
|
508
|
+
const errorBody = body?.error ?? null;
|
|
509
|
+
|
|
510
|
+
if (process.env.NODE_ENV === 'development') {
|
|
511
|
+
logApiError('API', {
|
|
512
|
+
url: fullUrl,
|
|
513
|
+
method: config?.method?.toUpperCase() ?? 'UNKNOWN',
|
|
514
|
+
status: response?.status,
|
|
515
|
+
requestBody: config?.data,
|
|
516
|
+
responseBody: response?.data,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (IS_SERVER) {
|
|
521
|
+
captureApiError({
|
|
522
|
+
url: fullUrl,
|
|
523
|
+
apiPath,
|
|
524
|
+
method: config?.method?.toUpperCase() ?? 'UNKNOWN',
|
|
525
|
+
status: response?.status,
|
|
526
|
+
responseBody: response?.data,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return Promise.reject(
|
|
531
|
+
new ApiError(
|
|
532
|
+
response?.status ?? 0,
|
|
533
|
+
errorBody?.code ?? '',
|
|
534
|
+
errorBody,
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return Promise.reject(error);
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
export { http };
|
|
544
|
+
`,
|
|
545
|
+
|
|
546
|
+
'src/shared/api/index.ts': `export { http } from './http';
|
|
547
|
+
export { ApiError } from './error';
|
|
548
|
+
export type { ApiErrorBody, ApiResponse } from './apiTypes';
|
|
549
|
+
export { captureApiError, logApiError } from './apiCore';
|
|
550
|
+
`,
|
|
551
|
+
|
|
552
|
+
'app/api/proxy/[...path]/route.ts': `import { NextResponse, type NextRequest } from 'next/server';
|
|
553
|
+
import {
|
|
554
|
+
logApiError,
|
|
555
|
+
captureApiError,
|
|
556
|
+
} from '@/src/shared/api/apiCore';
|
|
557
|
+
|
|
558
|
+
const API_URL = process.env.API_URL || 'http://localhost:8080/api';
|
|
559
|
+
|
|
560
|
+
const proxyRequest = async (
|
|
561
|
+
request: NextRequest,
|
|
562
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
563
|
+
method: string,
|
|
564
|
+
) => {
|
|
565
|
+
const { path } = await params;
|
|
566
|
+
const apiPath = path.join('/');
|
|
567
|
+
const url = new URL(\`\${API_URL}/\${apiPath}\`);
|
|
568
|
+
|
|
569
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
570
|
+
url.searchParams.set(key, value);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const headers: HeadersInit = {
|
|
574
|
+
'Accept-Language': request.headers.get('Accept-Language') || 'ko',
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const contentType = request.headers.get('Content-Type');
|
|
578
|
+
let body: BodyInit | undefined;
|
|
579
|
+
|
|
580
|
+
if (method !== 'GET') {
|
|
581
|
+
if (contentType?.includes('multipart/form-data')) {
|
|
582
|
+
body = await request.formData();
|
|
583
|
+
} else {
|
|
584
|
+
headers['Content-Type'] = 'application/json';
|
|
585
|
+
body = await request.text();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let response: Response;
|
|
590
|
+
try {
|
|
591
|
+
response = await fetch(url.toString(), { method, headers, body });
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.error(\`❌ [PROXY] \${method} \${url.toString()} — Network Error:\`, error);
|
|
594
|
+
return NextResponse.json(
|
|
595
|
+
{ result: 'ERROR', data: null, error: { code: 'NETWORK_ERROR', message: '서버에 연결할 수 없습니다.' } },
|
|
596
|
+
{ status: 502 },
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const data = await response.json();
|
|
601
|
+
|
|
602
|
+
if (!response.ok) {
|
|
603
|
+
logApiError('PROXY', {
|
|
604
|
+
url: url.toString(),
|
|
605
|
+
method,
|
|
606
|
+
status: response.status,
|
|
607
|
+
requestHeaders: headers as Record<string, string>,
|
|
608
|
+
requestBody: typeof body === 'string' ? body : undefined,
|
|
609
|
+
responseBody: data,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
captureApiError({
|
|
613
|
+
url: url.toString(),
|
|
614
|
+
apiPath,
|
|
615
|
+
method,
|
|
616
|
+
status: response.status,
|
|
617
|
+
responseBody: data,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return NextResponse.json(data, { status: response.status });
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
export const GET = (req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) =>
|
|
625
|
+
proxyRequest(req, ctx, 'GET');
|
|
626
|
+
|
|
627
|
+
export const POST = (req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) =>
|
|
628
|
+
proxyRequest(req, ctx, 'POST');
|
|
629
|
+
|
|
630
|
+
export const PUT = (req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) =>
|
|
631
|
+
proxyRequest(req, ctx, 'PUT');
|
|
632
|
+
|
|
633
|
+
export const PATCH = (req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) =>
|
|
634
|
+
proxyRequest(req, ctx, 'PATCH');
|
|
635
|
+
|
|
636
|
+
export const DELETE = (req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) =>
|
|
637
|
+
proxyRequest(req, ctx, 'DELETE');
|
|
638
|
+
`,
|
|
639
|
+
|
|
640
|
+
'src/shared/hooks/useAppMutation.ts': `import {
|
|
641
|
+
useMutation,
|
|
642
|
+
type UseMutationOptions,
|
|
643
|
+
type DefaultError,
|
|
644
|
+
} from '@tanstack/react-query';
|
|
645
|
+
import { toast } from 'sonner';
|
|
646
|
+
import { ApiError } from '../api/error';
|
|
647
|
+
|
|
648
|
+
type AppMutationOptions<
|
|
649
|
+
TData = unknown,
|
|
650
|
+
TError = DefaultError,
|
|
651
|
+
TVariables = void,
|
|
652
|
+
TContext = unknown,
|
|
653
|
+
> = UseMutationOptions<TData, TError, TVariables, TContext> & {
|
|
654
|
+
errorMessage?: string;
|
|
655
|
+
showErrorToast?: boolean;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
export const useAppMutation = <
|
|
659
|
+
TData = unknown,
|
|
660
|
+
TError = DefaultError,
|
|
661
|
+
TVariables = void,
|
|
662
|
+
TContext = unknown,
|
|
663
|
+
>(
|
|
664
|
+
options: AppMutationOptions<TData, TError, TVariables, TContext>,
|
|
665
|
+
) => {
|
|
666
|
+
const { errorMessage, showErrorToast = true, onError, ...rest } = options;
|
|
667
|
+
|
|
668
|
+
return useMutation({
|
|
669
|
+
...rest,
|
|
670
|
+
onError: (...args) => {
|
|
671
|
+
onError?.(...args);
|
|
672
|
+
|
|
673
|
+
if (!showErrorToast) return;
|
|
674
|
+
|
|
675
|
+
const [error] = args;
|
|
676
|
+
const message =
|
|
677
|
+
error instanceof ApiError
|
|
678
|
+
? error.data?.message ?? errorMessage
|
|
679
|
+
: errorMessage;
|
|
680
|
+
|
|
681
|
+
if (message) {
|
|
682
|
+
toast.error(message);
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
};
|
|
687
|
+
`,
|
|
688
|
+
},
|
|
689
|
+
};
|