mega-framework 0.1.7 → 0.1.9
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 +9 -0
- package/package.json +3 -3
- package/sample/crud/.env +9 -0
- package/sample/crud/.env.example +9 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
- package/sample/crud/apps/main/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- package/sample/crud/apps/main/routes/upload.js +20 -1
- package/sample/crud/apps/main/services/guide-service.js +4 -3
- package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
- package/sample/crud/apps/main/views/upload/index.ejs +4 -1
- package/sample/crud/docs/guide/01-cli.md +587 -0
- package/sample/crud/docs/guide/02-router-controller.md +497 -0
- package/sample/crud/docs/guide/03-service-model-db.md +929 -0
- package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
- package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
- package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
- package/sample/crud/docs/guide/08-observability.md +373 -0
- package/sample/crud/mega.config.js +7 -0
- package/sample/crud/package.json +2 -2
- package/sample/crud/scripts/start-ws-hub.sh +18 -4
- package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
- package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
- package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
- package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
- package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
- package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/adapters/adapter-options.js +14 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +7 -4
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +4 -1
- package/src/adapters/mongo-adapter.js +21 -7
- package/src/adapters/postgres-adapter.js +8 -4
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +6 -2
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/scaffold.js +38 -2
- package/src/cli/generators/index.js +58 -1
- package/src/cli/index.js +88 -59
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +3 -1
- package/src/core/ctx-builder.js +59 -1
- package/src/core/envelope.js +9 -2
- package/src/core/hub-link.js +24 -14
- package/src/core/index.js +1 -1
- package/src/core/mega-app.js +55 -45
- package/src/core/pipeline.js +8 -6
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +3 -3
- package/src/core/session-store.js +14 -1
- package/src/core/ws-presence.js +17 -5
- package/src/core/ws-roster.js +49 -10
- package/src/core/ws-upgrade.js +105 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +10 -0
- package/src/lib/mega-job-queue.js +53 -13
- package/src/lib/mega-job.js +8 -1
- package/src/lib/mega-metrics.js +28 -1
- package/src/lib/mega-plugin.js +2 -2
- package/src/lib/mega-worker.js +28 -5
- package/src/lib/ws-hub.js +90 -9
- package/templates/adr/code.tpl +23 -0
- package/types/adapters/adapter-options.d.ts +2 -0
- package/types/adapters/file-adapter.d.ts +12 -1
- package/types/adapters/file-session-adapter.d.ts +4 -2
- package/types/adapters/maria-adapter.d.ts +5 -3
- package/types/adapters/mega-cache-adapter.d.ts +27 -1
- package/types/adapters/mega-db-adapter.d.ts +4 -1
- package/types/adapters/mongo-adapter.d.ts +13 -2
- package/types/adapters/postgres-adapter.d.ts +4 -2
- package/types/adapters/redis-adapter.d.ts +8 -0
- package/types/adapters/sqlite-adapter.d.ts +8 -2
- package/types/cli/generators/index.d.ts +11 -1
- package/types/cli/index.d.ts +12 -27
- package/types/cli/watch.d.ts +59 -0
- package/types/core/ctx-builder.d.ts +23 -0
- package/types/core/hub-link.d.ts +3 -1
- package/types/core/index.d.ts +1 -1
- package/types/core/mega-app.d.ts +1 -1
- package/types/core/pipeline.d.ts +2 -1
- package/types/core/security.d.ts +3 -3
- package/types/core/session-store.d.ts +7 -0
- package/types/core/ws-roster.d.ts +13 -1
- package/types/core/ws-upgrade.d.ts +29 -0
- package/types/lib/mega-circuit-breaker.d.ts +4 -2
- package/types/lib/mega-health.d.ts +7 -0
- package/types/lib/mega-job-queue.d.ts +16 -4
- package/types/lib/mega-job.d.ts +8 -1
- package/types/lib/mega-plugin.d.ts +1 -1
- package/types/lib/mega-worker.d.ts +3 -1
- package/types/lib/ws-hub.d.ts +27 -2
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# Router + Controller
|
|
2
|
+
|
|
3
|
+
MEGA-FRAMEWORK 의 HTTP/WebSocket 라우트를 정의하고, 컨트롤러로 요청을 처리하고,
|
|
4
|
+
응답을 일관된 형태(envelope)로 돌려주는 방법을 다룬다.
|
|
5
|
+
|
|
6
|
+
라우트 파일은 앱의 `apps/<app>/routes/*.js` 에 두면 부팅 시 **자동으로 로딩**된다.
|
|
7
|
+
각 파일은 `router` 를 받는 함수를 default export 한다.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// apps/main/routes/users.js
|
|
11
|
+
import { UserController } from '../controllers/user-controller.js'
|
|
12
|
+
|
|
13
|
+
export default (router) => {
|
|
14
|
+
router.http.get('/users', UserController.index)
|
|
15
|
+
router.http.post('/users', UserController.create)
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 라우트 정의 — `router.http.<method>()` / `router.ws()`
|
|
22
|
+
|
|
23
|
+
### HTTP
|
|
24
|
+
|
|
25
|
+
`router.http` 는 HTTP 메서드 7종을 메서드로 가진 객체다.
|
|
26
|
+
시그니처는 **메서드별로** 부른다 — `router.http(method, ...)` 가 아니라
|
|
27
|
+
`router.http.get(...)`, `router.http.post(...)` 처럼 쓴다.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
router.http.get(path, handler, opts)
|
|
31
|
+
router.http.post(path, handler, opts)
|
|
32
|
+
router.http.put(path, handler, opts)
|
|
33
|
+
router.http.patch(path, handler, opts)
|
|
34
|
+
router.http.delete(path, handler, opts)
|
|
35
|
+
router.http.head(path, handler, opts)
|
|
36
|
+
router.http.options(path, handler, opts)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **`path`**: 라우트 경로(`'/users'`, `'/users/:id'`). 비어 있으면 부팅 시 throw.
|
|
40
|
+
- **`handler`**: 함수. inline async 함수 또는 컨트롤러 정적 메서드 참조
|
|
41
|
+
(`UserController.index`). 함수가 아니면 부팅 시 throw.
|
|
42
|
+
- **`opts`**: 아래 표 참고(생략 가능).
|
|
43
|
+
|
|
44
|
+
| opts 키 | 설명 |
|
|
45
|
+
| ------------- | -------------------------------------------------------------------- |
|
|
46
|
+
| `schema` | AJV(JSON Schema) 검증 — `body` / `params` / `querystring` / `headers` |
|
|
47
|
+
| `before` | preHandler 단계 미들웨어 배열(인증·rate limit·검증) |
|
|
48
|
+
| `transform` | preSerialization 이른 단계 변환 배열(envelope wrap 전, HTTP 전용) |
|
|
49
|
+
| `after` | onResponse 단계 side-effect 배열(로깅·메트릭, HTTP 전용) |
|
|
50
|
+
| `openapi` | OpenAPI 메타 — `{ tags, summary, description, deprecated }` |
|
|
51
|
+
|
|
52
|
+
### 자동 로딩 규칙
|
|
53
|
+
|
|
54
|
+
- `apps/<app>/routes/` 안의 `*.js` 파일을 **이름 정렬 순**으로 모두 로딩한다.
|
|
55
|
+
- `*.test.js` 파일은 제외된다.
|
|
56
|
+
- 각 파일은 **default export 가 함수**여야 한다. 아니면 부팅 시
|
|
57
|
+
`route.file_no_default` 로 throw(우회 없이 즉시 실패).
|
|
58
|
+
- default export 가 `async` 여도 된다 — 로더가 `await mod.default(router)` 로 기다린다.
|
|
59
|
+
|
|
60
|
+
### WebSocket
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
router.ws(path, ChannelClass, opts)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **`ChannelClass`**: `MegaWebSocketController` 를 상속한 채널 클래스여야 한다
|
|
67
|
+
(부팅 시 상속 검증, 아니면 throw).
|
|
68
|
+
- **`opts.before`**: upgrade 인증 미들웨어(WS 는 `before` 만 지원).
|
|
69
|
+
- **`opts.schemas`**: `{ [메시지타입]: JSONSchema }` — 메시지 type 별 payload 스키마.
|
|
70
|
+
등록 시점에 AJV 로 사전 컴파일되고, 수신 메시지의 `type` 에 스키마가 있으면 dispatch
|
|
71
|
+
직전에 검증한다. 위반 시 `ws.invalid_payload` error envelope 로 응답(연결은 유지).
|
|
72
|
+
- `transform` / `after` 는 **HTTP 전용**이라 WS 에 넘기면 throw.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
// apps/main/routes/ws.js
|
|
76
|
+
import { ChatChannel } from '../channels/chat-channel.js'
|
|
77
|
+
import { makeWsRequireAuth } from '../middleware/ws-auth.js'
|
|
78
|
+
|
|
79
|
+
const CHAT_SEND_SCHEMA = {
|
|
80
|
+
type: 'object',
|
|
81
|
+
required: ['text'],
|
|
82
|
+
properties: { text: { type: 'string', minLength: 1, maxLength: 500 } },
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default (router) => {
|
|
87
|
+
router.ws('/ws/chat', ChatChannel, {
|
|
88
|
+
before: [makeWsRequireAuth(router.app)],
|
|
89
|
+
schemas: { 'chat.send': CHAT_SEND_SCHEMA },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 파일 전체 미들웨어 — `router.use()`
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
router.use(middleware) // 이 라우트 파일의 모든 라우트에 적용
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
실행 순서: **전역 → 앱 → 파일(`router.use`) → 라우트(`before` opts) → handler**.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Controller
|
|
105
|
+
|
|
106
|
+
컨트롤러는 **베이스 클래스 없이 정적 메서드만** 가진 클래스다.
|
|
107
|
+
라우트는 컨트롤러의 정적 메서드를 핸들러로 참조한다.
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
// apps/main/controllers/user-controller.js
|
|
111
|
+
export class UserController {
|
|
112
|
+
/** GET /users — 목록 */
|
|
113
|
+
static async index(req, reply, ctx) {
|
|
114
|
+
return ctx.services.user.list()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** GET /users/:id — 단건 */
|
|
118
|
+
static async show(req, reply, ctx) {
|
|
119
|
+
return ctx.services.user.get(req.params.id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** POST /users — 생성(201) */
|
|
123
|
+
static async create(req, reply, ctx) {
|
|
124
|
+
const user = await ctx.services.user.create(req.body)
|
|
125
|
+
reply.code(201)
|
|
126
|
+
return user
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 핸들러 시그니처 — `(req, reply, ctx)`
|
|
132
|
+
|
|
133
|
+
핸들러는 **위치 인자 3개**를 받는다.
|
|
134
|
+
|
|
135
|
+
- **`req`**: Fastify 요청. `req.params` / `req.body` / `req.query` / `req.headers`.
|
|
136
|
+
- **`reply`**: Fastify 응답. `reply.code(201)`, `reply.header(...)`, `reply.send(...)`.
|
|
137
|
+
- **`ctx`**: 요청 단위 컨텍스트(아래 표). 요청당 한 번 만들어 미들웨어와 핸들러가 공유한다.
|
|
138
|
+
|
|
139
|
+
> 라우트·컨트롤러는 **모델을 직접 import 하지 않는다**. 도메인 로직은
|
|
140
|
+
> `ctx.services.<name>` 서비스를 거친다. 컨트롤러는 요청을 받아 서비스를 호출하고
|
|
141
|
+
> 도메인 데이터를 반환하는 얇은 층이다.
|
|
142
|
+
|
|
143
|
+
### `ctx` 표면
|
|
144
|
+
|
|
145
|
+
| ctx 항목 | 설명 |
|
|
146
|
+
| ------------------- | --------------------------------------------------------------------- |
|
|
147
|
+
| `ctx.services.<n>` | 요청별 lazy 서비스 DI. 미등록 이름 접근은 즉시 throw |
|
|
148
|
+
| `ctx.db(alias)` | DB 어댑터(별명 → 공유 인스턴스). raw 핸들은 `.native` |
|
|
149
|
+
| `ctx.cache(alias)` | 캐시 어댑터 |
|
|
150
|
+
| `ctx.bus(alias)` | 메시지 버스 어댑터 |
|
|
151
|
+
| `ctx.lock(alias)` | 분산 락 어댑터 |
|
|
152
|
+
| `ctx.workers` | CPU 워커 풀 — `ctx.workers.<name>.run(task)` |
|
|
153
|
+
| `ctx.t(key, ...)` | server scope 번역기(i18n). 미등록이면 key/defaultValue 그대로 반환 |
|
|
154
|
+
| `ctx.lang` | i18n 미들웨어가 결정한 현재 언어(미활성 시 null) |
|
|
155
|
+
| `ctx.session` | 요청별 세션 객체(미활성/미로드 시 null) |
|
|
156
|
+
| `ctx.user` | 인증 사용자(`{ id, roles }`). 인증 가드가 채움 |
|
|
157
|
+
| `ctx.render(view)` | 템플릿 렌더(`reply.render` 위임). 템플릿 미설정 앱은 fail-fast |
|
|
158
|
+
| `ctx.log` | 요청 로거(pino). `ctx.log.debug(...)` |
|
|
159
|
+
| `ctx.requestId` | 요청 id |
|
|
160
|
+
| `ctx.app` | 바인딩된 MegaApp |
|
|
161
|
+
|
|
162
|
+
> `db` / `cache` / `bus` / `lock` 은 **별명을 받는 함수**다 — `ctx.db` 가 아니라
|
|
163
|
+
> `ctx.db('primary')` 처럼 호출한다. 별명은 `app.config.js` 의
|
|
164
|
+
> `databases` / `caches` / `buses` / `locks` 에 선언한 것만 쓸 수 있고,
|
|
165
|
+
> 미선언 별명은 즉시 throw 한다(오타가 조용히 통과하지 않게).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Envelope — 응답 자동 포장
|
|
170
|
+
|
|
171
|
+
모든 HTTP 응답은 `{ ok, data|error, meta }` 형태로 통일된다.
|
|
172
|
+
**핸들러는 도메인 데이터만 반환**하고, framework 가 envelope 으로 감싼다.
|
|
173
|
+
|
|
174
|
+
### 성공
|
|
175
|
+
|
|
176
|
+
핸들러가 반환한 raw data 는 preSerialization 마지막 단계에서 wrap 된다.
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
static async show(req, reply, ctx) {
|
|
180
|
+
return ctx.services.user.get(req.params.id) // { id, name, email }
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
응답:
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"ok": true,
|
|
189
|
+
"data": { "id": "1", "name": "Henry", "email": "h@example.com" },
|
|
190
|
+
"meta": { "request_id": "req-1", "took_ms": 3 }
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 실패
|
|
195
|
+
|
|
196
|
+
도메인/HTTP 에러를 throw 하면 글로벌 에러 핸들러가 error envelope 으로 변환한다.
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{
|
|
200
|
+
"ok": false,
|
|
201
|
+
"error": {
|
|
202
|
+
"code": "user.not_found",
|
|
203
|
+
"message": "User 99 not found",
|
|
204
|
+
"details": { "id": "99" }
|
|
205
|
+
},
|
|
206
|
+
"meta": { "request_id": "req-2" }
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
> `error.message` 는 i18n 옵트인 앱에서 에러 `code`(예: `user.not_found`)를 locale 키로
|
|
211
|
+
> lookup 해 번역된다. 키가 없으면 원본 message 를 그대로 쓴다.
|
|
212
|
+
|
|
213
|
+
### envelope 판별
|
|
214
|
+
|
|
215
|
+
이미 `{ ok, data|error }` 모양이면 다시 감싸지 않고 그대로 통과시킨다.
|
|
216
|
+
판별 기준은 **`ok`(boolean) 가 있으면서 `data` 또는 `error` 키도 있을 때**다.
|
|
217
|
+
|
|
218
|
+
스트림(`reply.send(stream)`)·정적 파일처럼 envelope 대상이 아닌 응답은 그대로 전송된다.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Middleware — `before` / `after` / `transform`
|
|
223
|
+
|
|
224
|
+
미들웨어는 **모두 async 함수**다(콜백 `done` 스타일은 미지원).
|
|
225
|
+
|
|
226
|
+
### before — preHandler
|
|
227
|
+
|
|
228
|
+
인증·검증·rate limit 처럼 핸들러 **이전**에 도는 가드. 응답을 보내면
|
|
229
|
+
이후 미들웨어·핸들러는 skip 된다(reply 단락). throw 하면 글로벌 핸들러가
|
|
230
|
+
error envelope 으로 변환한다.
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
import { requireAuth } from 'mega-framework/auth'
|
|
234
|
+
|
|
235
|
+
export default (router) => {
|
|
236
|
+
const guarded = { before: [requireAuth] }
|
|
237
|
+
router.http.get('/users', UserController.index, guarded)
|
|
238
|
+
router.http.post('/users', UserController.create, guarded)
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### transform — preSerialization (HTTP 전용)
|
|
243
|
+
|
|
244
|
+
핸들러가 만든 **raw data 를 envelope wrap 전에** 변환한다.
|
|
245
|
+
시그니처는 `(req, reply, payload)` 이고 변환된 payload 를 반환한다.
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
const stripSecret = async (req, reply, payload) => {
|
|
249
|
+
const { secret, ...rest } = payload
|
|
250
|
+
return rest
|
|
251
|
+
}
|
|
252
|
+
router.http.get('/me', MeController.show, { transform: [stripSecret] })
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
실행 순서: **transform → envelope wrap**. 즉 transform 은 항상 `data` 안에 들어갈
|
|
256
|
+
값만 만지고, `ok`/`meta` 는 framework 가 채운다.
|
|
257
|
+
|
|
258
|
+
### after — onResponse (HTTP 전용)
|
|
259
|
+
|
|
260
|
+
응답 **전송 후** 도는 side-effect(audit 로그·메트릭). 응답에는 영향이 없고,
|
|
261
|
+
throw 해도 응답이 깨지지 않는다 — 대신 **warn 로그**로 표면화된다(묵살 금지).
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
const auditLog = async (req, reply) => {
|
|
265
|
+
req.log.info({ user: req.user?.id, route: req.url }, 'user mutated')
|
|
266
|
+
}
|
|
267
|
+
router.http.post('/users', UserController.create, { after: [auditLog] })
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## AJV 스키마 검증
|
|
273
|
+
|
|
274
|
+
`opts.schema` 는 Fastify 네이티브 AJV 검증으로 그대로 전달된다.
|
|
275
|
+
`body` / `params` / `querystring` / `headers` 를 검증할 수 있다.
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
const idParams = {
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: { id: { type: 'string' } },
|
|
281
|
+
}
|
|
282
|
+
const userBody = {
|
|
283
|
+
type: 'object',
|
|
284
|
+
required: ['name', 'email'],
|
|
285
|
+
properties: {
|
|
286
|
+
name: { type: 'string' },
|
|
287
|
+
email: { type: 'string', format: 'email' },
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
router.http.put('/users/:id', UserController.update, {
|
|
292
|
+
schema: { params: idParams, body: userBody },
|
|
293
|
+
})
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
검증 실패 시 글로벌 핸들러가 **400 validation envelope** 으로 변환한다.
|
|
297
|
+
`error.code` 는 `validation.failed`, `error.details` 는 **배열**이다.
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"ok": false,
|
|
302
|
+
"error": {
|
|
303
|
+
"code": "validation.failed",
|
|
304
|
+
"message": "Validation failed for body",
|
|
305
|
+
"details": [
|
|
306
|
+
{ "field": "email", "rule": "format", "value": { "format": "email" }, "message": "must match format \"email\"" }
|
|
307
|
+
]
|
|
308
|
+
},
|
|
309
|
+
"meta": { "request_id": "req-3" }
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
> `password` / `secret` / `token` 처럼 민감한 필드명은 `details.value` 가
|
|
314
|
+
> `[REDACTED]` 로 마스킹된다(시크릿 노출 방지).
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## CSRF
|
|
319
|
+
|
|
320
|
+
CSRF 보호는 **기본 ON** 이다. `app.config.js` 의 top-level `csrf` 키로 옵션을 주거나
|
|
321
|
+
`csrf: false` 로 끌 수 있다. 쿠키 double-submit 방식이다.
|
|
322
|
+
|
|
323
|
+
- **JSON 요청**(`application/json`): 토큰 대신 **Origin/Referer 검증**.
|
|
324
|
+
Origin 헤더가 없는 비브라우저 클라(API)는 통과(CSRF 는 브라우저 쿠키 공격이라 해당 없음).
|
|
325
|
+
허용 host 와 불일치하면 403 `csrf.origin_mismatch`.
|
|
326
|
+
- **폼 요청**(`x-www-form-urlencoded` / `multipart/form-data`): **토큰 검증**.
|
|
327
|
+
HTML 폼 auto-submit 이 CSRF 의 주 공격 벡터라 토큰을 강제한다.
|
|
328
|
+
실패 시 403 `csrf.invalid_token`.
|
|
329
|
+
|
|
330
|
+
토큰은 뷰에서 `reply.generateCsrf()` 로 만들어 폼에 심는다.
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
static async newForm(req, reply, ctx) {
|
|
334
|
+
return ctx.render('users/new', { csrfToken: reply.generateCsrf() })
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
```html
|
|
339
|
+
<!-- 일반 HTML 폼 — 숨은 필드 _csrf -->
|
|
340
|
+
<form method="post" action="/users">
|
|
341
|
+
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
|
342
|
+
...
|
|
343
|
+
</form>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
> **multipart 폼**은 스트리밍 body 라 preHandler 시점에 `_csrf` 필드를 못 읽는다.
|
|
347
|
+
> 따라서 토큰을 **헤더**(`csrf-token`)로 보내야 한다 — `fetch` + `FormData` 로 제출한다.
|
|
348
|
+
|
|
349
|
+
health·metrics 처럼 면제할 라우트는 라우트 `config: { skipCsrf: true }` 로 우회한다.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## multipart (파일 업로드)
|
|
354
|
+
|
|
355
|
+
multipart 는 **옵트인**이다. `app.config.js` 에 `upload` 설정이 있을 때만 등록되고,
|
|
356
|
+
없으면 multipart 요청은 415(파서 없음)로 거부된다.
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
// app.config.js
|
|
360
|
+
export default {
|
|
361
|
+
upload: {
|
|
362
|
+
maxFileSize: 10 * 1024 * 1024, // 기본 10 MB. 초과 시 413
|
|
363
|
+
maxFiles: 5, // 개수 초과 시 413
|
|
364
|
+
allowedMimeTypes: ['image/png', 'image/*'], // 빈 배열=전부 허용. 비허용 시 415
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
컨트롤러에서는 `req.saveUploads(destDir)` 로 저장한다. 파일명 살균(경로 탐색 차단) +
|
|
370
|
+
스트리밍 저장 + MIME 게이트가 적용되고, 저장된 파일 메타 배열을 반환한다.
|
|
371
|
+
|
|
372
|
+
```js
|
|
373
|
+
static async upload(req, reply, ctx) {
|
|
374
|
+
// 비허용 MIME(415)·크기 초과(413)면 throw → 글로벌 핸들러가 error envelope 응답
|
|
375
|
+
const saved = await req.saveUploads(uploadDir())
|
|
376
|
+
const files = saved.map((f) => ({
|
|
377
|
+
filename: f.filename,
|
|
378
|
+
bytes: f.bytes,
|
|
379
|
+
mimetype: f.mimetype,
|
|
380
|
+
}))
|
|
381
|
+
return { files }
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
저수준 접근이 필요하면 `req.file()`(단일) / `req.files()`(다중)도 MIME 화이트리스트
|
|
386
|
+
게이트로 감싸져 제공된다.
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## 에러 처리
|
|
391
|
+
|
|
392
|
+
도메인/HTTP 에러는 **`mega-framework/errors`** 의 `MegaHttpError` 계열을 throw 하면,
|
|
393
|
+
글로벌 에러 핸들러(error-mapper)가 알맞은 HTTP status + error envelope 으로 자동 매핑한다.
|
|
394
|
+
|
|
395
|
+
| 에러 클래스 | status | 기본 code |
|
|
396
|
+
| ------------------------------- | ------ | ------------------------------- |
|
|
397
|
+
| `MegaValidationError` | 400 | `validation.failed` |
|
|
398
|
+
| `MegaAuthError` | 401 | `auth.required` |
|
|
399
|
+
| `MegaForbiddenError` | 403 | `auth.forbidden` |
|
|
400
|
+
| `MegaNotFoundError` | 404 | `resource.not_found` |
|
|
401
|
+
| `MegaConflictError` | 409 | `resource.conflict` |
|
|
402
|
+
| `MegaPayloadTooLargeError` | 413 | `upload.too_large` |
|
|
403
|
+
| `MegaUnsupportedMediaTypeError` | 415 | `upload.unsupported_media_type` |
|
|
404
|
+
| `MegaInternalError` | 500 | `server.internal` |
|
|
405
|
+
|
|
406
|
+
```js
|
|
407
|
+
import { MegaNotFoundError, MegaConflictError } from 'mega-framework/errors'
|
|
408
|
+
|
|
409
|
+
// 서비스 계층 예시
|
|
410
|
+
async get(id) {
|
|
411
|
+
const user = await User.find(id)
|
|
412
|
+
if (!user) {
|
|
413
|
+
throw new MegaNotFoundError('user.not_found', `User ${id} not found`, {
|
|
414
|
+
details: { id },
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
return user
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
- 생성자: `new MegaHttpError계열(code, message, { details, cause })`.
|
|
422
|
+
`code` 는 `domain.error` 형식(ADR-016), `details` 는 object 또는 array.
|
|
423
|
+
- `MegaError`(HTTP status 없는 도메인 에러)는 500 으로 매핑되며 code/message 는 보존된다.
|
|
424
|
+
- Fastify/플러그인의 네이티브 4xx(413·415·CSRF 등)는 status·코드가 보존된다.
|
|
425
|
+
- 일반 `Error`(스택 노출 위험)는 기본적으로 `server.internal` 로 마스킹된다
|
|
426
|
+
(내부 구현 누출 방지). 메시지·스택은 로그에만 남는다.
|
|
427
|
+
|
|
428
|
+
### 인증 가드 — `mega-framework/auth`
|
|
429
|
+
|
|
430
|
+
세션 기반 인증 가드를 `before` 미들웨어로 제공한다.
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
import { requireAuth, requireRole } from 'mega-framework/auth'
|
|
434
|
+
|
|
435
|
+
router.http.get('/me', MeController.show, { before: [requireAuth] })
|
|
436
|
+
router.http.post('/users', UserController.create, { before: [requireRole('admin')] })
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
- `requireAuth`: 세션 `userId` 가 없으면 401 `auth.required`. 통과 시 `req.user`(`{ id, roles }`) 를 채운다.
|
|
440
|
+
- `requireRole(...roles)`: 인증 + 역할 교집합 검사. 역할 부족이면 403 `auth.forbidden`.
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## 함정 (실전에서 막히는 지점)
|
|
445
|
+
|
|
446
|
+
### 1. async `before` 미들웨어는 arity 2 여야 한다
|
|
447
|
+
|
|
448
|
+
Fastify 는 async `preHandler` 의 인자가 **3개 이상이면** 3번째를 콜백 `done` 으로
|
|
449
|
+
오해해 `"Async function has too many arguments"` 로 **라우트 등록을 거부**한다.
|
|
450
|
+
|
|
451
|
+
framework 의 인증 가드(`requireAuth` 등)는 `(req, reply, ctx)` 로 arity 3 이지만,
|
|
452
|
+
라우터가 `before` 미들웨어를 **arity-2 래퍼**(`(req, reply) => fn(req, reply)`)로
|
|
453
|
+
감싸 등록하므로 `{ before: [requireAuth] }` 가 그대로 동작한다.
|
|
454
|
+
직접 만드는 `before` 미들웨어도 **`(req, reply)` 시그니처 + async** 로 작성한다.
|
|
455
|
+
|
|
456
|
+
### 2. 핸들러 반환에 `ok` 를 섞지 말 것
|
|
457
|
+
|
|
458
|
+
envelope 의 `ok` 는 framework 가 HTTP success 여부를 책임지는 필드다.
|
|
459
|
+
핸들러가 도메인 데이터에 `ok` 를 직접 넣으면 안 된다.
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
// ❌ 잘못 — data 안에 ok 가 박혀 { ok:true, data:{ ok:true, ... } } 로 이중 의미
|
|
463
|
+
static async show() { return { ok: true, message: 'hi' } }
|
|
464
|
+
|
|
465
|
+
// ✅ 도메인 데이터만 반환 — { ok:true, data:{ message:'hi' }, meta } 로 단일 ok
|
|
466
|
+
static async show() { return { message: 'hi' } }
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`{ ok:true, message }` 는 `data`/`error` 키가 없어 envelope 으로 인식되지 않고
|
|
470
|
+
통째로 `data` 에 담겨 한 번 더 감싸진다(`data.ok`).
|
|
471
|
+
|
|
472
|
+
### 3. 라우트 파일은 default export 가 함수여야 한다
|
|
473
|
+
|
|
474
|
+
`apps/<app>/routes/*.js` 는 `export default (router) => { ... }` 형태여야 한다.
|
|
475
|
+
함수가 아니면 부팅 시 `route.file_no_default` 로 즉시 실패한다.
|
|
476
|
+
default 가 `async` 면 로더가 `await` 하므로 비동기 셋업도 안전하지만,
|
|
477
|
+
라우트 등록은 그 함수 **안에서** 완료돼야 한다(다른 곳에서 떼어내 비동기로 등록 X).
|
|
478
|
+
|
|
479
|
+
### 4. WS 는 `before` + `schemas` 만 받는다
|
|
480
|
+
|
|
481
|
+
`router.ws()` 에 `transform` / `after` 를 넘기면 throw 한다(HTTP 전용).
|
|
482
|
+
WS 메시지는 채널 안에서 type 으로 dispatch 되고, payload 검증은 `schemas` 로 한다.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## 관련 ADR
|
|
487
|
+
|
|
488
|
+
- **ADR-005** — 응답 envelope 정책
|
|
489
|
+
- **ADR-018** — 응답 자동 envelope 감싸기 / `isEnvelope` 판별
|
|
490
|
+
- **ADR-019** — AJV(JSON Schema) 라우트 검증
|
|
491
|
+
- **ADR-074** — 컨트롤러 정적 메서드 + `(req, reply, ctx)` 시그니처
|
|
492
|
+
- **ADR-090** — AJV 검증 에러 → validation envelope 매핑(PII 마스킹)
|
|
493
|
+
- **ADR-091** — before/transform/after 라이프사이클
|
|
494
|
+
- **ADR-147** — 핸들러 반환에서 envelope `ok` 중복 제거
|
|
495
|
+
- **ADR-148** — `ctx.services` 자동 DI
|
|
496
|
+
- **ADR-156** — `before` 미들웨어 arity-2 래핑
|
|
497
|
+
- **ADR-167** — routes-loader `await mod.default` / `isFile` 동형
|