sh-ui-cli 0.97.0 → 0.98.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.
@@ -17,8 +17,8 @@ const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join('|');
17
17
  export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
18
18
 
19
19
  사용법:
20
- sh-ui create [name] [options] [--observability <none|sentry>]
21
- sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--i18n <react-i18next|none>] [--locales ko,en] [--observability <none|sentry>]
20
+ sh-ui create [name] [options]
21
+ sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--i18n <react-i18next|none>] [--locales ko,en]
22
22
  sh-ui create add-component <name> [--app <name>]
23
23
 
24
24
  옵션:
@@ -30,7 +30,6 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
30
30
  --css <${CSS_FRAMEWORKS_SUPPORTED.join('|')}> CSS 프레임워크. base 파일까지 분기 emit (tailwind/plain/css-modules)
31
31
  --i18n <react-i18next|none> vite 전용 — react-i18next 셋업 emit (i18n config + I18nProvider). 기본 none (v0.92.0+)
32
32
  --locales <ko,en> i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en'
33
- --observability <none|sentry> vite 전용 — Sentry 셋업 emit (@sentry/react + vite-plugin + SentryProvider). 기본 none (v0.93.0+)
34
33
  --yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
35
34
  --dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
36
35
  -h, --help 이 도움말
@@ -79,7 +78,6 @@ export async function runCreate(rest) {
79
78
  platform: flags.platform,
80
79
  i18n: flags.i18n,
81
80
  locales: flags.locales,
82
- observability: flags.observability,
83
81
  });
84
82
  } else if (command === 'add-component') {
85
83
  // 호환 별칭 — 신규 진입점은 `sh-ui add <name>` (bin/sh-ui.mjs 가 walk-up 으로 라우팅).
@@ -98,7 +96,6 @@ export async function runCreate(rest) {
98
96
  css: flags.css,
99
97
  i18n: flags.i18n,
100
98
  locales: flags.locales,
101
- observability: flags.observability,
102
99
  // create 컨텍스트에서는 --app 이 첫 앱 이름 (monorepo). 같은 플래그가
103
100
  // add-component 컨텍스트에서는 대상 앱 선택 — 의미가 컨텍스트마다 다름.
104
101
  appName: flags.app,
@@ -1,9 +1,7 @@
1
- import { sentryPlugin } from './sentry.js';
2
1
  import { nextIntlPlugin } from './nextIntl.js';
3
- import { authJwtPlugin } from './authJwt.js';
4
2
  import { validatePlugins } from './pluginSchema.js';
5
3
 
6
- export const allPlugins = [sentryPlugin, nextIntlPlugin, authJwtPlugin];
4
+ export const allPlugins = [nextIntlPlugin];
7
5
 
8
6
  // 모듈 로드 시점에 모든 플러그인 manifest 검증 — 잘못된 형태가 있으면 즉시 실패.
9
7
  // 예: src/proxy.ts 같은 잘못된 경로, name 이 kebab-case 가 아닌 경우 등.
@@ -130,10 +130,6 @@ export default function Error({
130
130
  }
131
131
  `,
132
132
  },
133
- // auth-jwt 플러그인이 함께 활성화돼 있으면 sign-in 도 locale prefix 안으로 이동.
134
- // auth-jwt 단독일 때 (= 이 transform 이 없을 때) 는 app/sign-in 이 그대로 root 에 남는다.
135
- // `move` 트랜스폼은 from 파일이 없으면 silently skip 이라 auth-jwt 미활성 시 안전한 no-op.
136
- { type: 'move', from: 'app/sign-in/page.tsx', to: 'app/[locale]/sign-in/page.tsx' },
137
133
  {
138
134
  // Next 16 부터 root layout (app/layout.tsx) 은 반드시 <html>/<body> 를 가져야 한다.
139
135
  // next-intl 적용 시에는 [locale] 가 root 역할을 맡으므로, 기본 app/layout.tsx 를 그대로
@@ -40,7 +40,7 @@ const archAwareArray = z.union([z.array(z.any()), archAwareFn]);
40
40
 
41
41
  export const PluginSchema = z.object({
42
42
  name: z.string().regex(/^[a-z][a-z0-9-]*$/, {
43
- message: 'Plugin name must be lowercase kebab-case (e.g., "auth-jwt")',
43
+ message: 'Plugin name must be lowercase kebab-case (e.g., "next-intl")',
44
44
  }),
45
45
  label: z.string().min(1),
46
46
  description: z.string().min(1).optional(),
package/src/mcp.mjs CHANGED
@@ -48,7 +48,6 @@ import {
48
48
  CSS_FRAMEWORKS_SUPPORTED,
49
49
  I18N_LIBRARIES,
50
50
  I18N_DEFAULT_LOCALES,
51
- OBSERVABILITY_PROVIDERS,
52
51
  } from "./constants.js";
53
52
  import { allPlugins } from "./create/plugins/index.js";
54
53
  import { allArchitectures, describeArchOptions } from "./create/architectures/index.js";
@@ -470,11 +469,6 @@ export async function startMcpServer() {
470
469
  `i18n 활성화 시 생성할 locale 코드 (comma-separated, 2글자 또는 'ko-KR' 류). 기본 '${I18N_DEFAULT_LOCALES}'. 첫 locale 이 fallbackLng. ` +
471
470
  "i18n='none' 이면 무시.",
472
471
  ),
473
- observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
474
- .describe(
475
- "observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
476
- "@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
477
- ),
478
472
  // monorepo 첫 앱 이름 — describe_template 와 시그니처 1:1 일치 (v0.96.0+).
479
473
  // standalone 일 땐 무시 (프로젝트 루트가 곧 앱). 미지정 시 'web'.
480
474
  appName: z.string().min(1).optional()
@@ -514,15 +508,6 @@ export async function startMcpServer() {
514
508
  }],
515
509
  };
516
510
  }
517
- if (input.observability && input.observability !== "none" && input.platform !== "vite") {
518
- return {
519
- isError: true,
520
- content: [{
521
- type: "text",
522
- text: `observability='${input.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
523
- }],
524
- };
525
- }
526
511
  const targetParent = resolveCwd(input);
527
512
  const targetDir = resolve(targetParent, input.name);
528
513
  if (existsSync(targetDir) && !input.force) {
@@ -550,7 +535,6 @@ export async function startMcpServer() {
550
535
  css: input.cssFramework,
551
536
  i18n: input.i18n,
552
537
  locales: input.locales,
553
- observability: input.observability,
554
538
  appName: input.appName,
555
539
  port: input.port,
556
540
  yes: true, // 사전 검사를 마쳤으니 generator 의 confirm 프롬프트 우회
@@ -594,11 +578,6 @@ export async function startMcpServer() {
594
578
  .describe(
595
579
  `i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 '${I18N_DEFAULT_LOCALES}'. 첫 locale 이 fallbackLng. i18n='none' 이면 무시.`,
596
580
  ),
597
- observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
598
- .describe(
599
- "observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
600
- "@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
601
- ),
602
581
  cwd: z.string().optional()
603
582
  .describe("모노레포 루트 (pnpm-workspace.yaml 있는 곳). 기본 process.cwd()"),
604
583
  },
@@ -618,15 +597,6 @@ export async function startMcpServer() {
618
597
  }],
619
598
  };
620
599
  }
621
- if (input.observability && input.observability !== "none" && input.platform && input.platform !== "vite") {
622
- return {
623
- isError: true,
624
- content: [{
625
- type: "text",
626
- text: `observability='${input.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
627
- }],
628
- };
629
- }
630
600
  const text = await captureConsole(() =>
631
601
  addApp({
632
602
  name: input.name,
@@ -637,7 +607,6 @@ export async function startMcpServer() {
637
607
  platform: input.platform,
638
608
  i18n: input.i18n,
639
609
  locales: input.locales,
640
- observability: input.observability,
641
610
  cwd: resolveCwd(input),
642
611
  }),
643
612
  );
@@ -991,8 +960,6 @@ export async function startMcpServer() {
991
960
  .describe(`i18n 라이브러리 (platform=vite 전용). 옵션: ${I18N_LIBRARIES.join(', ')}. 기본 none`),
992
961
  locales: z.string().optional()
993
962
  .describe(`i18n 활성화 시 locale 코드 (comma-separated, 예: "ko,en"). 기본 "${I18N_DEFAULT_LOCALES}"`),
994
- observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
995
- .describe(`observability 백엔드 (platform=vite 전용). 옵션: ${OBSERVABILITY_PROVIDERS.join(', ')}. 기본 none`),
996
963
  },
997
964
  },
998
965
  async (input) => {
@@ -1006,7 +973,6 @@ export async function startMcpServer() {
1006
973
  appName: input.appName,
1007
974
  i18n: input.i18n,
1008
975
  locales: input.locales,
1009
- observability: input.observability,
1010
976
  });
1011
977
  return jsonResult(result);
1012
978
  } catch (e) {
@@ -1,420 +0,0 @@
1
- /**
2
- * auth-jwt 플러그인 — Layer 2 부터 arch-aware.
3
- *
4
- * fs 경로 / import alias 가 arch.paths.api / arch.aliases.api 에서 파생.
5
- * FSD 기준 v0.57 까지의 하드코딩과 1:1 일치 (회귀 가드는 smoke).
6
- */
7
- export const authJwtPlugin = {
8
- name: 'auth-jwt',
9
- label: '쿠키 기반 JWT 인증 (refresh 자리표시자 포함)',
10
- description:
11
- '쿠키 기반 JWT 인증. Next 16 proxy.ts 미들웨어, refresh-aware BFF, withAuthRetry 헬퍼. refresh 본문은 placeholder — 백엔드 명세 확정 후 한 파일 채우면 자동 활성화.',
12
- priority: 2,
13
-
14
- // 의존성 추가 없음 — 베이스의 fetch + cookies + react-query 만 사용
15
-
16
- envVars: [
17
- '# Auth (auth-jwt)',
18
- 'COOKIE_SECURE=false',
19
- '# Dev 시 인증 가드 우회 — proxy.ts 가 이 flag 를 보면 /sign-in 으로 redirect 안 함.',
20
- '# 실제 백엔드 연동 후엔 반드시 비워야 함 (또는 NODE_ENV 가 production 이면 무시).',
21
- 'NEXT_PUBLIC_DEV_AUTH_BYPASS=false',
22
- ],
23
-
24
- turboEnvVars: [
25
- 'COOKIE_SECURE',
26
- 'NEXT_PUBLIC_DEV_AUTH_BYPASS',
27
- ],
28
-
29
- providerImports: [],
30
- providerWrappers: [],
31
-
32
- // ─── 독립 파일 ───
33
- //
34
- // 베이스의 BFF (app/api/proxy/[...path]/route.ts) 를 refresh-aware 버전으로
35
- // 덮어쓰고, 미들웨어와 인증 헬퍼를 추가한다.
36
- // refreshSession.ts 는 v1 placeholder — 백엔드 명세 확정 후 본문만 채우면
37
- // BFF 와 withAuthRetry 가 자동 활용한다.
38
-
39
- files: (arch) => ({
40
- // placeholder sign-in page — proxy.ts 가 미인증 요청을 /sign-in 으로 redirect
41
- // 하므로, 페이지가 없으면 사용자가 dev 띄우자마자 무한 404 루프에 빠진다.
42
- // 템플릿 단계에선 dev 우회용 "fake 토큰 set" 버튼 + 안내 문구로 최소화.
43
- // next-intl 활성 시 nextIntl 플러그인의 transforms 가 [locale]/sign-in 으로 옮긴다.
44
- 'app/sign-in/page.tsx': `'use client';
45
-
46
- /**
47
- * auth-jwt 플러그인의 placeholder sign-in 페이지.
48
- *
49
- * 실제 프로덕션에선 백엔드 sign-in API 와 연동하는 폼으로 교체해야 한다.
50
- * 지금은 dev 시 미인증 redirect 루프를 끊기 위한 최소 페이지.
51
- */
52
- export default function SignInPage() {
53
- const setDevToken = () => {
54
- document.cookie = 'accessToken=dev-placeholder; path=/; max-age=86400';
55
- document.cookie = 'refreshToken=dev-placeholder; path=/; max-age=86400';
56
- window.location.href = '/';
57
- };
58
-
59
- // 토큰 기반 색상 — 다크/라이트 자동 적응. inline hex 사용 시 다크에서 시인성 깨짐.
60
- return (
61
- <main style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
62
- <div style={{ maxWidth: 420, width: '100%', display: 'flex', flexDirection: 'column', gap: 16 }}>
63
- <h1 style={{ fontSize: 24, margin: 0, color: 'var(--foreground)' }}>Sign in</h1>
64
- <p style={{ color: 'var(--foreground-muted)', fontSize: 14, lineHeight: 1.6, margin: 0 }}>
65
- auth-jwt 플러그인 placeholder 페이지입니다. 실제 인증 연동 전까지는
66
- 아래 버튼으로 dev 용 가짜 토큰을 설정해 가드를 우회할 수 있습니다.
67
- </p>
68
- <button
69
- type="button"
70
- onClick={setDevToken}
71
- style={{
72
- padding: '10px 16px',
73
- borderRadius: 8,
74
- border: '1px solid var(--border-strong)',
75
- background: 'var(--primary)',
76
- color: 'var(--primary-foreground)',
77
- cursor: 'pointer',
78
- fontSize: 14,
79
- fontWeight: 500,
80
- }}
81
- >
82
- Continue (dev — set fake token)
83
- </button>
84
- </div>
85
- </main>
86
- );
87
- }
88
- `,
89
-
90
- 'proxy.ts': `import { NextRequest, NextResponse } from 'next/server';
91
-
92
- const AUTH_ROUTES = ['/sign-in', '/sign-up'];
93
-
94
- /**
95
- * 홈(\`/\`) 진입 시 redirect 할 path. 빈 문자열이면 \`app/page.tsx\` 가 그대로 노출.
96
- * 예: '/dashboard', '/projects'. 인증 가드 위에서 동작하므로 미인증이면
97
- * 그대로 \`/sign-in\` 으로 빠진다.
98
- */
99
- const HOME_REDIRECT = '';
100
-
101
- /**
102
- * Next 16+ 의 proxy.ts (구 middleware.ts).
103
- * 토큰 존재 여부만 검사한다 — 만료 검사나 refresh 는 하지 않는다.
104
- *
105
- * - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트
106
- * - AT 쿠키 없음 + 인증 라우트 아님 → /sign-in 으로 리다이렉트
107
- * - AT 쿠키 있음 또는 인증 라우트 → 통과
108
- * - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
109
- *
110
- * AT 가 만료된 채 통과한 요청은 BFF (/api/proxy) 가 401 을 받아
111
- * refreshSession 으로 갱신을 시도한다.
112
- */
113
- const DEV_BYPASS =
114
- process.env.NODE_ENV !== 'production' &&
115
- process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
116
-
117
- export default function proxy(req: NextRequest) {
118
- const { pathname } = req.nextUrl;
119
- const hasToken = !!req.cookies.get('accessToken')?.value;
120
- const isAuthRoute = AUTH_ROUTES.some((r) => pathname.startsWith(r));
121
-
122
- if (pathname === '/' && HOME_REDIRECT) {
123
- return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
124
- }
125
-
126
- if (DEV_BYPASS) return NextResponse.next();
127
- if (isAuthRoute) return NextResponse.next();
128
- if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
129
-
130
- return NextResponse.next();
131
- }
132
-
133
- export const config = {
134
- matcher: '/((?!api|_next|.*\\\\..*).*)',
135
- };
136
- `,
137
-
138
- [`${arch.paths.api}/refreshSession.ts`]: `type RefreshResult =
139
- | { ok: true; accessToken: string; refreshToken: string }
140
- | { ok: false };
141
-
142
- let inflight: Promise<RefreshResult> | null = null;
143
-
144
- /**
145
- * refreshToken 으로 새 accessToken / refreshToken 을 발급받는다.
146
- *
147
- * v1 placeholder — 백엔드 refresh API 명세가 확정되면 아래 TODO 부분을 채운다.
148
- * 본문이 채워지면 BFF (/api/proxy) 와 withAuthRetry 가 자동으로 활용한다.
149
- *
150
- * 동시에 여러 요청이 401 을 만나도 inflight 모듈 변수로 코얼레싱돼서
151
- * refresh 는 한 번만 발사된다.
152
- *
153
- * 참고 구현 예시:
154
- *
155
- * const res = await fetch(\`\${process.env.API_URL}/v1/auth/token/refresh\`, {
156
- * method: 'POST',
157
- * headers: { 'Content-Type': 'application/json' },
158
- * body: JSON.stringify({ refreshToken }),
159
- * });
160
- * const body = await res.json();
161
- * if (body.result === 'SUCCESS') {
162
- * return {
163
- * ok: true,
164
- * accessToken: body.data.accessToken,
165
- * refreshToken: body.data.refreshToken,
166
- * };
167
- * }
168
- * return { ok: false };
169
- */
170
- export async function refreshSession(
171
- refreshToken: string,
172
- ): Promise<RefreshResult> {
173
- if (inflight) return inflight;
174
-
175
- inflight = (async (): Promise<RefreshResult> => {
176
- try {
177
- // TODO: 백엔드 refresh API 명세 확정 후 여기에 fetch 호출을 작성.
178
- // 지금은 placeholder 라 항상 실패 → BFF 가 쿠키 삭제 + 401 응답.
179
- void refreshToken;
180
- return { ok: false };
181
- } finally {
182
- inflight = null;
183
- }
184
- })();
185
-
186
- return inflight;
187
- }
188
- `,
189
-
190
- [`${arch.paths.api}/withAuthRetry.ts`]: `import { cookies } from 'next/headers';
191
-
192
- import { ApiError } from './error';
193
- import { refreshSession } from './refreshSession';
194
-
195
- const ACCESS_TOKEN_COOKIE = 'accessToken';
196
- const REFRESH_TOKEN_COOKIE = 'refreshToken';
197
-
198
- const COOKIE = {
199
- httpOnly: true,
200
- secure: process.env.COOKIE_SECURE === 'true',
201
- sameSite: 'lax' as const,
202
- path: '/',
203
- };
204
-
205
- /**
206
- * Route Handler 또는 Server Action 안에서 인증된 요청을 보낼 때 사용한다.
207
- * 401 을 만나면 refreshSession 으로 토큰을 갱신하고 fn 을 한 번 더 실행한다.
208
- *
209
- * RSC (Server Component) 에서는 cookies().set() 이 막혀 있으므로 여기서 호출하면 안 된다.
210
- * RSC 는 serverFetch 를 직접 호출하고, 401 은 prefetchQuery 가 swallow 한 뒤
211
- * 클라이언트 refetch 가 BFF 경유로 자동 복구한다.
212
- *
213
- * 사용 예 (Server Action):
214
- *
215
- * 'use server';
216
- * import { serverFetch } from '${arch.aliases.api}/serverFetch';
217
- * import { withAuthRetry } from '${arch.aliases.api}/withAuthRetry';
218
- *
219
- * export async function toggleFavoriteAction(id: number) {
220
- * return withAuthRetry(() =>
221
- * serverFetch(\`/v1/products/\${id}/favorite\`, { method: 'POST' }),
222
- * );
223
- * }
224
- */
225
- export async function withAuthRetry<T>(fn: () => Promise<T>): Promise<T> {
226
- try {
227
- return await fn();
228
- } catch (e) {
229
- if (!(e instanceof ApiError) || e.status !== 401) throw e;
230
-
231
- const jar = await cookies();
232
- const refreshToken = jar.get(REFRESH_TOKEN_COOKIE)?.value;
233
- if (!refreshToken) throw e;
234
-
235
- const r = await refreshSession(refreshToken);
236
- if (!r.ok) throw new ApiError(401, 'UNAUTHORIZED', null);
237
-
238
- jar.set(ACCESS_TOKEN_COOKIE, r.accessToken, COOKIE);
239
- jar.set(REFRESH_TOKEN_COOKIE, r.refreshToken, COOKIE);
240
-
241
- return await fn();
242
- }
243
- }
244
- `,
245
-
246
- 'app/api/proxy/[...path]/route.ts': `import { cookies } from 'next/headers';
247
- import { NextResponse, type NextRequest } from 'next/server';
248
-
249
- import {
250
- captureApiError,
251
- logApiError,
252
- } from '${arch.aliases.api}/observability';
253
- import { refreshSession } from '${arch.aliases.api}/refreshSession';
254
-
255
- const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
256
- const ACCESS_TOKEN_COOKIE = 'accessToken';
257
- const REFRESH_TOKEN_COOKIE = 'refreshToken';
258
- const LOCALE_COOKIE = 'NEXT_LOCALE';
259
-
260
- const COOKIE = {
261
- httpOnly: true,
262
- secure: process.env.COOKIE_SECURE === 'true',
263
- sameSite: 'lax' as const,
264
- path: '/',
265
- };
266
-
267
- const clearAuthCookies = (res: NextResponse) => {
268
- res.cookies.set(ACCESS_TOKEN_COOKIE, '', { ...COOKIE, maxAge: 0 });
269
- res.cookies.set(REFRESH_TOKEN_COOKIE, '', { ...COOKIE, maxAge: 0 });
270
- return res;
271
- };
272
-
273
- const proxyRequest = async (
274
- request: NextRequest,
275
- ctx: { params: Promise<{ path: string[] }> },
276
- method: string,
277
- ) => {
278
- const { path } = await ctx.params;
279
- const apiPath = path.join('/');
280
- const url = new URL(\`\${API_URL}/\${apiPath}\`);
281
-
282
- request.nextUrl.searchParams.forEach((value, key) => {
283
- url.searchParams.set(key, value);
284
- });
285
-
286
- const cookieStore = await cookies();
287
- const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
288
- const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
289
- const locale =
290
- cookieStore.get(LOCALE_COOKIE)?.value ??
291
- request.headers.get('Accept-Language') ??
292
- undefined;
293
-
294
- const headers: Record<string, string> = {};
295
- if (accessToken) headers.Authorization = \`Bearer \${accessToken}\`;
296
- if (locale) headers['Accept-Language'] = locale;
297
-
298
- let body: BodyInit | undefined;
299
- if (method !== 'GET' && method !== 'HEAD') {
300
- const contentType = request.headers.get('Content-Type');
301
- if (contentType?.includes('multipart/form-data')) {
302
- body = await request.formData();
303
- } else {
304
- headers['Content-Type'] = 'application/json';
305
- body = await request.text();
306
- }
307
- }
308
-
309
- let response: Response;
310
- try {
311
- response = await fetch(url.toString(), { method, headers, body });
312
- } catch (error) {
313
- console.error(\`[PROXY] \${method} \${url.toString()} —\`, error);
314
- return NextResponse.json(
315
- {
316
- result: 'ERROR',
317
- data: null,
318
- error: {
319
- code: 'NETWORK_ERROR',
320
- message: 'Failed to reach upstream server.',
321
- },
322
- },
323
- { status: 502 },
324
- );
325
- }
326
-
327
- let data = await response.json();
328
-
329
- // 401 → refresh 시도 (RT 가 있을 때만)
330
- if (response.status === 401) {
331
- if (!refreshToken) {
332
- return clearAuthCookies(NextResponse.json(data, { status: 401 }));
333
- }
334
-
335
- const r = await refreshSession(refreshToken);
336
-
337
- // refresh placeholder 가 본문 미구현이면 항상 ok:false
338
- // → 쿠키 삭제 + 401 그대로 (clientFetch 가 /sign-in 으로 이동)
339
- if (!r.ok) {
340
- return clearAuthCookies(NextResponse.json(data, { status: 401 }));
341
- }
342
-
343
- // 새 AT 로 재시도
344
- headers.Authorization = \`Bearer \${r.accessToken}\`;
345
- const retry = await fetch(url.toString(), { method, headers, body });
346
- data = await retry.json();
347
-
348
- const res = NextResponse.json(data, { status: retry.status });
349
- res.cookies.set(ACCESS_TOKEN_COOKIE, r.accessToken, COOKIE);
350
- res.cookies.set(REFRESH_TOKEN_COOKIE, r.refreshToken, COOKIE);
351
-
352
- if (!retry.ok) {
353
- logApiError('PROXY', {
354
- url: url.toString(),
355
- method,
356
- status: retry.status,
357
- requestBody: typeof body === 'string' ? body : undefined,
358
- responseBody: data,
359
- });
360
-
361
- captureApiError({
362
- url: url.toString(),
363
- apiPath,
364
- method,
365
- status: retry.status,
366
- responseBody: data,
367
- });
368
- }
369
-
370
- return res;
371
- }
372
-
373
- if (!response.ok) {
374
- logApiError('PROXY', {
375
- url: url.toString(),
376
- method,
377
- status: response.status,
378
- requestBody: typeof body === 'string' ? body : undefined,
379
- responseBody: data,
380
- });
381
-
382
- captureApiError({
383
- url: url.toString(),
384
- apiPath,
385
- method,
386
- status: response.status,
387
- responseBody: data,
388
- });
389
- }
390
-
391
- return NextResponse.json(data, { status: response.status });
392
- };
393
-
394
- export const GET = (
395
- req: NextRequest,
396
- ctx: { params: Promise<{ path: string[] }> },
397
- ) => proxyRequest(req, ctx, 'GET');
398
-
399
- export const POST = (
400
- req: NextRequest,
401
- ctx: { params: Promise<{ path: string[] }> },
402
- ) => proxyRequest(req, ctx, 'POST');
403
-
404
- export const PUT = (
405
- req: NextRequest,
406
- ctx: { params: Promise<{ path: string[] }> },
407
- ) => proxyRequest(req, ctx, 'PUT');
408
-
409
- export const PATCH = (
410
- req: NextRequest,
411
- ctx: { params: Promise<{ path: string[] }> },
412
- ) => proxyRequest(req, ctx, 'PATCH');
413
-
414
- export const DELETE = (
415
- req: NextRequest,
416
- ctx: { params: Promise<{ path: string[] }> },
417
- ) => proxyRequest(req, ctx, 'DELETE');
418
- `,
419
- }),
420
- };