sh-ui-cli 0.31.1 → 0.32.1
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 +1 -1
- package/data/changelog/versions.json +26 -0
- package/package.json +1 -1
- package/src/create/cli-args.js +1 -1
- package/src/create/generator.js +55 -1
- package/src/create/index.mjs +2 -2
- package/src/create/plugins/authJwt.js +340 -0
- package/src/create/plugins/index.js +2 -1
- package/src/create/plugins/sentry.js +32 -280
- package/src/mcp.mjs +1 -1
- package/templates/nextjs-app/app/api/proxy/[...path]/route.ts +31 -4
- package/templates/nextjs-app/package.json +0 -1
- package/templates/nextjs-app/src/app/providers/tanstack/QueryClientProvider.tsx +5 -18
- package/templates/nextjs-app/src/shared/api/clientFetch.ts +40 -0
- package/templates/nextjs-app/src/shared/api/http.ts +13 -56
- package/templates/nextjs-app/src/shared/api/observability.ts +20 -0
- package/templates/nextjs-app/src/shared/api/queryClient.ts +30 -0
- package/templates/nextjs-app/src/shared/api/serverFetch.ts +59 -0
- package/templates/nextjs-app/src/shared/hooks/useAppMutation.ts +52 -0
- package/templates/nextjs-standalone/app/api/proxy/[...path]/route.ts +31 -4
- package/templates/nextjs-standalone/package.json +0 -1
- package/templates/nextjs-standalone/src/app/providers/tanstack/QueryClientProvider.tsx +5 -18
- package/templates/nextjs-standalone/src/shared/api/clientFetch.ts +40 -0
- package/templates/nextjs-standalone/src/shared/api/http.ts +13 -56
- package/templates/nextjs-standalone/src/shared/api/observability.ts +20 -0
- package/templates/nextjs-standalone/src/shared/api/queryClient.ts +30 -0
- package/templates/nextjs-standalone/src/shared/api/serverFetch.ts +59 -0
- package/templates/nextjs-standalone/src/shared/hooks/useAppMutation.ts +52 -0
|
@@ -50,6 +50,11 @@ export const sentryPlugin = {
|
|
|
50
50
|
providerWrappers: [],
|
|
51
51
|
|
|
52
52
|
// ─── 독립 파일 ───
|
|
53
|
+
//
|
|
54
|
+
// Sentry 가 활성화될 때만 깔리는 파일.
|
|
55
|
+
// HTTP/proxy 인프라(http.ts, apiTypes.ts, error.ts, app/api/proxy 등)는
|
|
56
|
+
// 베이스 템플릿이 소유한다. Sentry 는 베이스의 observability.ts 를
|
|
57
|
+
// Sentry-aware 버전으로 덮어써서 캡처/로그를 활성화한다.
|
|
53
58
|
|
|
54
59
|
files: {
|
|
55
60
|
'sentry.server.config.ts': `import * as Sentry from '@sentry/nextjs';
|
|
@@ -64,10 +69,6 @@ Sentry.init({
|
|
|
64
69
|
return null;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
if (event.exception?.values?.[0]?.type === 'AxiosError') {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
72
|
const error = hint?.originalException;
|
|
72
73
|
|
|
73
74
|
if (error instanceof Error && error.name === 'ApiError') {
|
|
@@ -95,11 +96,6 @@ Sentry.init({
|
|
|
95
96
|
if (event.level === 'warning' || event.level === 'info' || event.level === 'debug' || event.level === 'log') {
|
|
96
97
|
return null;
|
|
97
98
|
}
|
|
98
|
-
|
|
99
|
-
if (event.exception?.values?.[0]?.type === 'AxiosError') {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
99
|
return event;
|
|
104
100
|
},
|
|
105
101
|
});
|
|
@@ -120,7 +116,7 @@ export const register = async () => {
|
|
|
120
116
|
export const onRequestError = (
|
|
121
117
|
...[error, request, context]: Parameters<typeof Sentry.captureRequestError>
|
|
122
118
|
) => {
|
|
123
|
-
if (error instanceof Error &&
|
|
119
|
+
if (error instanceof Error && error.name === 'ApiError') {
|
|
124
120
|
return;
|
|
125
121
|
}
|
|
126
122
|
|
|
@@ -290,9 +286,10 @@ export default function Error({
|
|
|
290
286
|
Suspense,
|
|
291
287
|
} from 'react';
|
|
292
288
|
import * as Sentry from '@sentry/nextjs';
|
|
293
|
-
import { ApiError } from '../../api/error';
|
|
294
289
|
import { QueryErrorResetBoundary } from '@tanstack/react-query';
|
|
295
290
|
|
|
291
|
+
import { ApiError } from '../../api/error';
|
|
292
|
+
|
|
296
293
|
interface ErrorFallbackProps {
|
|
297
294
|
error: Error | null;
|
|
298
295
|
resetErrorBoundary: () => void;
|
|
@@ -381,75 +378,35 @@ export function FallbackBoundary({
|
|
|
381
378
|
}
|
|
382
379
|
`,
|
|
383
380
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
export interface ApiResponse<T = unknown> {
|
|
390
|
-
result: 'SUCCESS' | 'ERROR';
|
|
391
|
-
data: T | null;
|
|
392
|
-
error: ApiErrorBody | null;
|
|
393
|
-
}
|
|
394
|
-
`,
|
|
381
|
+
// ─── observability 브릿지 ───
|
|
382
|
+
//
|
|
383
|
+
// 베이스의 no-op observability 를 Sentry-aware 버전으로 덮어쓴다.
|
|
384
|
+
// http.ts / serverFetch.ts / proxy/route.ts 가 이 모듈을 import 하므로,
|
|
385
|
+
// Sentry 플러그인이 켜지면 자동으로 캡처가 활성화된다.
|
|
395
386
|
|
|
396
|
-
'src/shared/api/
|
|
387
|
+
'src/shared/api/observability.ts': `import * as Sentry from '@sentry/nextjs';
|
|
397
388
|
|
|
398
|
-
|
|
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 = {
|
|
389
|
+
type ApiCaptureParams = {
|
|
413
390
|
url: string;
|
|
391
|
+
apiPath: string;
|
|
414
392
|
method: string;
|
|
415
393
|
status: number | undefined;
|
|
416
|
-
requestHeaders?: Record<string, string | undefined>;
|
|
417
|
-
requestBody?: unknown;
|
|
418
394
|
responseBody?: unknown;
|
|
419
395
|
};
|
|
420
396
|
|
|
421
|
-
|
|
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 = {
|
|
397
|
+
type ApiLogParams = {
|
|
443
398
|
url: string;
|
|
444
|
-
apiPath: string;
|
|
445
399
|
method: string;
|
|
446
400
|
status: number | undefined;
|
|
401
|
+
requestHeaders?: Record<string, string | undefined>;
|
|
402
|
+
requestBody?: unknown;
|
|
447
403
|
responseBody?: unknown;
|
|
448
404
|
};
|
|
449
405
|
|
|
450
|
-
export const captureApiError = (params:
|
|
406
|
+
export const captureApiError = (params: ApiCaptureParams): void => {
|
|
451
407
|
const { url, apiPath, method, status, responseBody } = params;
|
|
452
408
|
|
|
409
|
+
// 5xx 서버 에러만 Sentry 로 보고 (4xx 비즈니스 에러는 UI 에서 처리)
|
|
453
410
|
if (!status || status < 500) return;
|
|
454
411
|
|
|
455
412
|
Sentry.withScope((scope) => {
|
|
@@ -461,228 +418,23 @@ export const captureApiError = (params: ApiSentryCapture): void => {
|
|
|
461
418
|
});
|
|
462
419
|
scope.setFingerprint([method, apiPath, String(status)]);
|
|
463
420
|
|
|
464
|
-
Sentry.captureException(
|
|
465
|
-
new Error(\`[API] \${method} \${apiPath} \${status}\`),
|
|
466
|
-
);
|
|
421
|
+
Sentry.captureException(new Error(\`[API] \${method} \${apiPath} \${status}\`));
|
|
467
422
|
});
|
|
468
423
|
};
|
|
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
424
|
|
|
558
|
-
const
|
|
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();
|
|
425
|
+
export const logApiError = (prefix: string, params: ApiLogParams): void => {
|
|
426
|
+
const { url, method, status, requestHeaders, requestBody, responseBody } = params;
|
|
601
427
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
method,
|
|
606
|
-
status: response.status,
|
|
607
|
-
requestHeaders: headers as Record<string, string>,
|
|
608
|
-
requestBody: typeof body === 'string' ? body : undefined,
|
|
609
|
-
responseBody: data,
|
|
610
|
-
});
|
|
428
|
+
console.error(\`❌ [\${prefix} ERROR REPORT]\`);
|
|
429
|
+
console.error(\`- URL: \${method} \${url}\`);
|
|
430
|
+
console.error(\`- Status: \${status ?? 'N/A'}\`);
|
|
611
431
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
method,
|
|
616
|
-
status: response.status,
|
|
617
|
-
responseBody: data,
|
|
618
|
-
});
|
|
432
|
+
if (requestHeaders) {
|
|
433
|
+
const { Authorization: _, ...safeHeaders } = requestHeaders;
|
|
434
|
+
console.error('- Request Headers:', safeHeaders);
|
|
619
435
|
}
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
});
|
|
436
|
+
if (requestBody) console.error('- Request Body:', requestBody);
|
|
437
|
+
if (responseBody) console.error('- Response Body:', responseBody);
|
|
686
438
|
};
|
|
687
439
|
`,
|
|
688
440
|
},
|
package/src/mcp.mjs
CHANGED
|
@@ -165,7 +165,7 @@ export async function startMcpServer() {
|
|
|
165
165
|
.describe("타겟 플랫폼"),
|
|
166
166
|
structure: z.enum(["standalone", "monorepo"]).optional()
|
|
167
167
|
.describe("Next.js 구조 — platform=next 일 때 필수. standalone(단독) | monorepo(Turborepo)"),
|
|
168
|
-
plugins: z.array(z.enum(["sentry", "next-intl"])).optional()
|
|
168
|
+
plugins: z.array(z.enum(["sentry", "next-intl", "auth-jwt"])).optional()
|
|
169
169
|
.describe("Next.js 플러그인. 미지정시 빈 배열"),
|
|
170
170
|
theme: z.string().optional()
|
|
171
171
|
.describe("base64 인코딩된 테마 JSON (선택)"),
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { cookies } from 'next/headers';
|
|
2
2
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
captureApiError,
|
|
6
|
+
logApiError,
|
|
7
|
+
} from '@/src/shared/api/observability';
|
|
8
|
+
|
|
4
9
|
const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
|
|
5
10
|
const ACCESS_TOKEN_COOKIE = 'accessToken';
|
|
6
11
|
const LOCALE_COOKIE = 'NEXT_LOCALE';
|
|
@@ -11,7 +16,8 @@ const proxyRequest = async (
|
|
|
11
16
|
method: string,
|
|
12
17
|
) => {
|
|
13
18
|
const { path } = await ctx.params;
|
|
14
|
-
const
|
|
19
|
+
const apiPath = path.join('/');
|
|
20
|
+
const url = new URL(`${API_URL}/${apiPath}`);
|
|
15
21
|
|
|
16
22
|
request.nextUrl.searchParams.forEach((value, key) => {
|
|
17
23
|
url.searchParams.set(key, value);
|
|
@@ -39,10 +45,9 @@ const proxyRequest = async (
|
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
let response: Response;
|
|
42
49
|
try {
|
|
43
|
-
|
|
44
|
-
const data = await response.json();
|
|
45
|
-
return NextResponse.json(data, { status: response.status });
|
|
50
|
+
response = await fetch(url.toString(), { method, headers, body });
|
|
46
51
|
} catch (error) {
|
|
47
52
|
console.error(`[PROXY] ${method} ${url.toString()} —`, error);
|
|
48
53
|
return NextResponse.json(
|
|
@@ -57,6 +62,28 @@ const proxyRequest = async (
|
|
|
57
62
|
{ status: 502 },
|
|
58
63
|
);
|
|
59
64
|
}
|
|
65
|
+
|
|
66
|
+
const data = await response.json();
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
logApiError('PROXY', {
|
|
70
|
+
url: url.toString(),
|
|
71
|
+
method,
|
|
72
|
+
status: response.status,
|
|
73
|
+
requestBody: typeof body === 'string' ? body : undefined,
|
|
74
|
+
responseBody: data,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
captureApiError({
|
|
78
|
+
url: url.toString(),
|
|
79
|
+
apiPath,
|
|
80
|
+
method,
|
|
81
|
+
status: response.status,
|
|
82
|
+
responseBody: data,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return NextResponse.json(data, { status: response.status });
|
|
60
87
|
};
|
|
61
88
|
|
|
62
89
|
export const GET = (
|
|
@@ -1,26 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
QueryClientProvider as TanstackQueryClientProvider,
|
|
6
|
-
} from '@tanstack/react-query';
|
|
7
|
-
import { useState, type ReactNode } from 'react';
|
|
3
|
+
import { QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
const [queryClient] = useState(
|
|
11
|
-
() =>
|
|
12
|
-
new QueryClient({
|
|
13
|
-
defaultOptions: {
|
|
14
|
-
queries: {
|
|
15
|
-
staleTime: 60 * 1000,
|
|
16
|
-
retry: 1,
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
}),
|
|
20
|
-
);
|
|
6
|
+
import { getBrowserQueryClient } from '@/src/shared/api/queryClient';
|
|
21
7
|
|
|
8
|
+
export function QueryClientProvider({ children }: { children: ReactNode }) {
|
|
22
9
|
return (
|
|
23
|
-
<TanstackQueryClientProvider client={
|
|
10
|
+
<TanstackQueryClientProvider client={getBrowserQueryClient()}>
|
|
24
11
|
{children}
|
|
25
12
|
</TanstackQueryClientProvider>
|
|
26
13
|
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ApiResponse } from './apiTypes';
|
|
4
|
+
import { ApiError } from './error';
|
|
5
|
+
|
|
6
|
+
const PROXY_BASE = '/api/proxy';
|
|
7
|
+
|
|
8
|
+
export async function clientFetch<T>(
|
|
9
|
+
path: string,
|
|
10
|
+
init: RequestInit = {},
|
|
11
|
+
): Promise<T> {
|
|
12
|
+
const url = `${PROXY_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
|
13
|
+
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
...init,
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
...(init.headers as Record<string, string> | undefined),
|
|
19
|
+
},
|
|
20
|
+
credentials: 'include',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const body = (await res.json()) as ApiResponse<T>;
|
|
24
|
+
|
|
25
|
+
if (res.status === 401) {
|
|
26
|
+
if (
|
|
27
|
+
typeof window !== 'undefined' &&
|
|
28
|
+
!window.location.pathname.startsWith('/sign-in')
|
|
29
|
+
) {
|
|
30
|
+
window.location.href = '/sign-in';
|
|
31
|
+
}
|
|
32
|
+
throw new ApiError(401, body.error?.code ?? 'UNAUTHORIZED', body.error);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!res.ok || body.result === 'ERROR') {
|
|
36
|
+
throw new ApiError(res.status, body.error?.code ?? '', body.error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return body.data;
|
|
40
|
+
}
|
|
@@ -1,56 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 };
|
|
1
|
+
import { clientFetch } from './clientFetch';
|
|
2
|
+
import { serverFetch } from './serverFetch';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* isomorphic 진입점.
|
|
6
|
+
* RSC/서버에서는 백엔드로 직통, 브라우저에서는 /api/proxy 경유.
|
|
7
|
+
* API 함수는 한 번만 작성하고 환경 분기는 여기서 처리한다.
|
|
8
|
+
*/
|
|
9
|
+
export function http<T>(path: string, init?: RequestInit): Promise<T> {
|
|
10
|
+
return typeof window === 'undefined'
|
|
11
|
+
? serverFetch<T>(path, init)
|
|
12
|
+
: clientFetch<T>(path, init);
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type ApiCaptureParams = {
|
|
2
|
+
url: string;
|
|
3
|
+
apiPath: string;
|
|
4
|
+
method: string;
|
|
5
|
+
status: number | undefined;
|
|
6
|
+
responseBody?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type ApiLogParams = {
|
|
10
|
+
url: string;
|
|
11
|
+
method: string;
|
|
12
|
+
status: number | undefined;
|
|
13
|
+
requestHeaders?: Record<string, string | undefined>;
|
|
14
|
+
requestBody?: unknown;
|
|
15
|
+
responseBody?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const captureApiError = (_params: ApiCaptureParams): void => {};
|
|
19
|
+
|
|
20
|
+
export const logApiError = (_prefix: string, _params: ApiLogParams): void => {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QueryClient,
|
|
3
|
+
defaultShouldDehydrateQuery,
|
|
4
|
+
isServer,
|
|
5
|
+
} from '@tanstack/react-query';
|
|
6
|
+
import { cache } from 'react';
|
|
7
|
+
|
|
8
|
+
function makeQueryClient(): QueryClient {
|
|
9
|
+
return new QueryClient({
|
|
10
|
+
defaultOptions: {
|
|
11
|
+
queries: {
|
|
12
|
+
staleTime: 60 * 1000,
|
|
13
|
+
retry: 1,
|
|
14
|
+
},
|
|
15
|
+
dehydrate: {
|
|
16
|
+
shouldDehydrateQuery: (q) =>
|
|
17
|
+
defaultShouldDehydrateQuery(q) || q.state.status === 'pending',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const getServerQueryClient = cache(makeQueryClient);
|
|
24
|
+
|
|
25
|
+
let browserQueryClient: QueryClient | undefined;
|
|
26
|
+
|
|
27
|
+
export function getBrowserQueryClient(): QueryClient {
|
|
28
|
+
if (isServer) return makeQueryClient();
|
|
29
|
+
return (browserQueryClient ??= makeQueryClient());
|
|
30
|
+
}
|