@fuzionx/framework 0.1.71 → 0.1.73

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.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.71",
12
+ "@fuzionx/player": "^0.1.73",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -852,7 +852,18 @@ export default class Application {
852
852
  })
853
853
  .catch((err) => {
854
854
  console.error(`[FX] Unhandled controller error: ${ctx.method} ${ctx.path}`, err);
855
- this._handleContextError(err, ctx);
855
+ try {
856
+ this._handleContextError(err, ctx);
857
+ } catch (handlerErr) {
858
+ // 에러 핸들링 자체 실패 → 최소한의 500 응답 보장
859
+ console.error(`[FX] Error handler failed:`, handlerErr);
860
+ try {
861
+ ctx._statusCode = 500;
862
+ ctx._body = JSON.stringify({ error: { message: err.message, status: 500 } });
863
+ ctx._headers['Content-Type'] = 'application/json';
864
+ ctx._sent = true;
865
+ } catch { /* ctx 조작도 실패하면 포기 */ }
866
+ }
856
867
  this._sendContextResponse(rawReq.requestId, ctx);
857
868
  });
858
869
 
@@ -1251,20 +1262,29 @@ export default class Application {
1251
1262
 
1252
1263
  /**
1253
1264
  * Context 에러 핸들링 — ctx에 에러 응답 설정
1265
+ *
1266
+ * 커스텀 에러 핸들러(useError)는 부가 작업(알림 등)용으로,
1267
+ * 기본 에러 응답 설정(ErrorHandler.handle)은 항상 실행된다.
1268
+ *
1254
1269
  * @param {Error} err - 발생한 에러
1255
1270
  * @param {object} ctx - Context 인스턴스
1256
1271
  * @private
1257
1272
  */
1258
1273
  _handleContextError(err, ctx) {
1259
- // 커스텀 에러 핸들러 체크 (useError로 등록)
1274
+ // 커스텀 에러 핸들러 실행 (fire-and-forget — 알림, 로깅 등 부가 작업)
1260
1275
  for (const { path: ePath, handler } of this._errorHandlers) {
1261
1276
  if (!ePath || ctx.path?.startsWith(ePath)) {
1262
1277
  try {
1263
- handler(err, ctx);
1264
- return;
1265
- } catch { /* 커스텀 핸들러 실패 시 기본으로 */ }
1278
+ const result = handler(err, ctx);
1279
+ // async 핸들러 → 에러 무시 (알림 실패가 응답에 영향 X)
1280
+ if (result && typeof result.then === 'function') {
1281
+ result.catch(() => {});
1282
+ }
1283
+ } catch { /* 커스텀 핸들러 sync 에러 무시 */ }
1266
1284
  }
1267
1285
  }
1286
+
1287
+ // 기본 에러 응답 설정 (항상 실행 — ctx에 status/body 세팅)
1268
1288
  this._errorHandler.handle(err, ctx);
1269
1289
 
1270
1290
  // 'error' 이벤트 발행 — 앱 레벨에서 ErrorLog 기록용 (fire-and-forget)
@@ -34,8 +34,14 @@ export default class ErrorHandler {
34
34
  this.logger(`[${status}] ${ctx.method} ${ctx.path} — ${err.message}`);
35
35
  }
36
36
 
37
- // JSON 응답 요청이면 JSON으로
38
- const wantsJson = ctx.get('accept')?.includes('application/json')
37
+ // JSON 응답 판단 — 요청 헤더 기반 (경로 하드코딩 X)
38
+ // 1. Accept 헤더에 application/json 포함 (SPA fetch/axios 기본)
39
+ // 2. Content-Type이 JSON (JSON 요청 본문)
40
+ // 3. X-Requested-With: XMLHttpRequest (AJAX 요청)
41
+ // 4. /api 경로 (fallback — 헤더 없는 경우 대비)
42
+ const accept = ctx.get('accept') || '';
43
+ const wantsJson = accept.includes('application/json')
44
+ || (!accept.includes('text/html') && ctx.get('x-requested-with') === 'XMLHttpRequest')
39
45
  || ctx.is('json')
40
46
  || ctx.path?.startsWith('/api');
41
47
 
@@ -117,22 +123,134 @@ export default class ErrorHandler {
117
123
  } catch {}
118
124
  }
119
125
 
120
- // 3. 프레임워크 내장 에러 페이지 (XSS 방지)
126
+ // 3. 프레임워크 내장 에러 페이지 (모던 디자인 — XSS 방지)
121
127
  const esc = (s) => String(s)
122
128
  .replace(/&/g, '&amp;').replace(/</g, '&lt;')
123
129
  .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
124
130
 
125
- const title = status >= 500 ? 'Server Error' : esc(err.message);
131
+ // 상태 코드별 제목/이모지/색상 분기
132
+ const statusMap = {
133
+ 400: { emoji: '⚠️', label: 'Bad Request', desc: '잘못된 요청입니다.', color: '#f59e0b' },
134
+ 401: { emoji: '🔒', label: 'Unauthorized', desc: '인증이 필요합니다.', color: '#6366f1' },
135
+ 403: { emoji: '🚫', label: 'Forbidden', desc: '접근 권한이 없습니다.', color: '#ef4444' },
136
+ 404: { emoji: '🔍', label: 'Not Found', desc: '요청한 페이지를 찾을 수 없습니다.', color: '#8b5cf6' },
137
+ 500: { emoji: '💥', label: 'Server Error', desc: '서버에서 오류가 발생했습니다.', color: '#ef4444' },
138
+ 502: { emoji: '🌐', label: 'Bad Gateway', desc: '게이트웨이 오류가 발생했습니다.', color: '#f97316' },
139
+ 503: { emoji: '🔧', label: 'Service Unavailable', desc: '서비스를 일시적으로 이용할 수 없습니다.', color: '#f59e0b' },
140
+ };
141
+ const info = statusMap[status] || {
142
+ emoji: status >= 500 ? '💥' : '⚠️',
143
+ label: status >= 500 ? 'Server Error' : 'Error',
144
+ desc: esc(err.message),
145
+ color: status >= 500 ? '#ef4444' : '#f59e0b',
146
+ };
147
+
148
+ // 개발 모드: 스택 트레이스 블록
149
+ const stackBlock = this.isDev && err.stack
150
+ ? `<details class="stack" open>
151
+ <summary>Stack Trace</summary>
152
+ <pre>${esc(err.stack)}</pre>
153
+ </details>` : '';
154
+
155
+ // 개발 모드: 요청 정보
156
+ const reqBlock = this.isDev
157
+ ? `<div class="req-info">
158
+ <span class="method">${esc(ctx.method)}</span>
159
+ <span class="path">${esc(ctx.url)}</span>
160
+ </div>` : '';
126
161
 
127
162
  ctx.status(status).html(
128
- `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${status}</title>` +
129
- `<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:80px auto;` +
130
- `padding:0 20px;color:#333}h1{color:#e74c3c;}pre{background:#f5f5f5;padding:16px;border-radius:8px;` +
131
- `overflow-x:auto;font-size:13px;}</style></head>` +
132
- `<body><h1>${status} ${title}</h1>` +
133
- `<p>요청: ${esc(ctx.method)} ${esc(ctx.url)}</p>` +
134
- `${this.isDev && err.stack ? `<pre>${esc(err.stack)}</pre>` : ''}` +
135
- `</body></html>`
163
+ `<!DOCTYPE html>
164
+ <html lang="ko">
165
+ <head>
166
+ <meta charset="utf-8">
167
+ <meta name="viewport" content="width=device-width,initial-scale=1">
168
+ <title>${status} ${esc(info.label)}</title>
169
+ <style>
170
+ *{margin:0;padding:0;box-sizing:border-box}
171
+ :root{--bg:#0f0f11;--card:#18181b;--border:#27272a;--text:#fafafa;--sub:#a1a1aa;--accent:${info.color}}
172
+ @media(prefers-color-scheme:light){
173
+ :root{--bg:#f8f9fa;--card:#fff;--border:#e5e7eb;--text:#18181b;--sub:#71717a}
174
+ }
175
+ body{
176
+ font-family:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;
177
+ background:var(--bg);color:var(--text);
178
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
179
+ padding:20px;
180
+ }
181
+ .container{
182
+ max-width:520px;width:100%;text-align:center;
183
+ }
184
+ .status-code{
185
+ font-size:120px;font-weight:800;line-height:1;
186
+ background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 60%,#fff));
187
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;
188
+ background-clip:text;
189
+ letter-spacing:-4px;margin-bottom:8px;
190
+ animation:fadeIn .5s ease-out;
191
+ }
192
+ .emoji{font-size:48px;margin-bottom:16px;display:block;animation:bounce .6s ease-out}
193
+ .card{
194
+ background:var(--card);border:1px solid var(--border);
195
+ border-radius:16px;padding:40px 32px;
196
+ box-shadow:0 4px 24px rgba(0,0,0,.12);
197
+ animation:slideUp .4s ease-out;
198
+ }
199
+ h1{font-size:18px;font-weight:600;margin-bottom:8px;color:var(--text)}
200
+ .desc{color:var(--sub);font-size:14px;line-height:1.6;margin-bottom:24px}
201
+ .home-btn{
202
+ display:inline-flex;align-items:center;gap:6px;
203
+ padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;
204
+ background:var(--accent);color:#fff;
205
+ text-decoration:none;transition:all .2s;
206
+ border:none;cursor:pointer;
207
+ }
208
+ .home-btn:hover{opacity:.85;transform:translateY(-1px)}
209
+ .req-info{
210
+ margin-top:20px;padding:10px 16px;
211
+ background:color-mix(in srgb,var(--bg) 80%,var(--card));
212
+ border-radius:8px;font-size:12px;font-family:'Menlo','Consolas',monospace;
213
+ color:var(--sub);display:flex;gap:8px;justify-content:center;align-items:center;
214
+ }
215
+ .method{
216
+ background:var(--accent);color:#fff;padding:2px 8px;border-radius:4px;
217
+ font-weight:600;font-size:11px;
218
+ }
219
+ .stack{
220
+ margin-top:20px;text-align:left;
221
+ }
222
+ .stack summary{
223
+ font-size:12px;color:var(--sub);cursor:pointer;margin-bottom:8px;
224
+ font-weight:500;
225
+ }
226
+ .stack pre{
227
+ background:color-mix(in srgb,var(--bg) 80%,var(--card));
228
+ border:1px solid var(--border);border-radius:8px;
229
+ padding:16px;font-size:11px;line-height:1.5;
230
+ overflow-x:auto;color:var(--sub);font-family:'Menlo','Consolas',monospace;
231
+ max-height:300px;overflow-y:auto;
232
+ }
233
+ .footer{margin-top:32px;font-size:11px;color:var(--sub)}
234
+ @keyframes fadeIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
235
+ @keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
236
+ @keyframes bounce{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
237
+ </style>
238
+ </head>
239
+ <body>
240
+ <div class="container">
241
+ <div class="card">
242
+ <span class="emoji">${info.emoji}</span>
243
+ <div class="status-code">${status}</div>
244
+ <h1>${esc(info.label)}</h1>
245
+ <p class="desc">${esc(info.desc)}</p>
246
+ <a href="/" class="home-btn">← 홈으로</a>
247
+ ${reqBlock}
248
+ ${stackBlock}
249
+ </div>
250
+ <p class="footer">FuzionX Framework</p>
251
+ </div>
252
+ </body>
253
+ </html>`
136
254
  );
137
255
  }
138
256
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@aws-sdk/client-s3": "^3.1028.0",
38
- "@fuzionx/core": "^0.1.71",
38
+ "@fuzionx/core": "^0.1.73",
39
39
  "better-sqlite3": "^12.8.0",
40
40
  "knex": "^3.2.5",
41
41
  "mongoose": "^9.3.2",