mega-framework 0.1.8 → 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/package.json +1 -1
- package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
- 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/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
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
# WebSocket + ASP + Hub
|
|
2
|
+
|
|
3
|
+
MEGA-FRAMEWORK 의 실시간(WebSocket) 기능, 그 위에서 동작하는 전송 암호화 ASP(Auto Secure
|
|
4
|
+
Protocol), 그리고 멀티 인스턴스 fan-out 을 담당하는 클러스터(NATS `wsCluster`)·Hub 를 다룬다.
|
|
5
|
+
sample/crud 의 `/ws/chat` 채팅 데모를 실제 예시로 사용한다.
|
|
6
|
+
|
|
7
|
+
전체 그림:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
브라우저(WASM MegaSocket) ──E:/P: 프레임──▶ Bridge(mega server) ──┬─ NATS wsCluster (클러스터 경로 ①, §5)
|
|
11
|
+
ASP 직접 암복호화 ASP 종단(ws-upgrade) │ broadcast/direct fan-out + roster 동기화
|
|
12
|
+
채널(WebSocketController)│
|
|
13
|
+
└─ 평문 12-타입 ──▶ WS Hub (클러스터 경로 ②, §9)
|
|
14
|
+
※ ①·② 중 하나 선택(상호배타). sample=②(Hub)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **클라 ↔ Bridge** 구간만 ASP 로 암호화된다(`E:`/`P:` 프레임).
|
|
18
|
+
- **Bridge ↔ Hub** 구간은 평문 + Bearer 토큰이다(ASP 무관).
|
|
19
|
+
- 같은 서버 프로세스 안에서는 `app.broadcast` 로 로컬 fan-out, 여러 워커/인스턴스로 퍼뜨릴 때는
|
|
20
|
+
**클러스터 전송을 config 로 선택**한다(ADR-176, 앱당 하나·상호배타). 둘 다 boot 가 config 만 보고
|
|
21
|
+
**자동 배선**(`connectHub`/redis 같은 코드 불요):
|
|
22
|
+
- **NATS `wsCluster`**(§5) — `wsCluster.bus`(NATS)만 켜면 broadcast·roster 동기화까지 프레임워크가 처리.
|
|
23
|
+
- **WS Hub `bridgeHub`**(§9) — app config 에 `bridgeHub` 를 두면 boot 가 `connectHub` 자동 호출, 여러
|
|
24
|
+
**호스트**에 흩어진 인스턴스를 별도 hub 프로세스로 묶는다.
|
|
25
|
+
- 둘 다 설정하면 부팅 fail-fast(`config.cluster_transport_conflict`). **sample/crud 는 현재 WS Hub
|
|
26
|
+
(`bridgeHub`, localhost:3100)를 쓴다** — NATS 로 바꾸려면 §5 참조.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. WS 라우트 등록
|
|
31
|
+
|
|
32
|
+
`routes/*.js` 안에서 `router.ws(path, ChannelClass, opts)` 로 등록한다. HTTP 라우트와 달리 WS 는
|
|
33
|
+
`before`(upgrade 인증)와 `schemas`(메시지 payload 검증) 두 옵션만 의미가 있다 — `transform`/`after`
|
|
34
|
+
는 HTTP 전용이라 넘기면 부팅 시 거부된다.
|
|
35
|
+
|
|
36
|
+
sample/crud — `apps/main/routes/ws.js`:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { WsController } from '../controllers/ws-controller.js'
|
|
40
|
+
import { ChatChannel } from '../channels/chat-channel.js'
|
|
41
|
+
import { webRequireAuth } from '../middleware/web-auth.js'
|
|
42
|
+
import { makeWsRequireAuth } from '../middleware/ws-auth.js'
|
|
43
|
+
|
|
44
|
+
/** chat.send payload 스키마 — text 1~500자만 허용(빈 메시지·과대 페이로드 차단). */
|
|
45
|
+
const CHAT_SEND_SCHEMA = {
|
|
46
|
+
type: 'object',
|
|
47
|
+
required: ['text'],
|
|
48
|
+
properties: { text: { type: 'string', minLength: 1, maxLength: 500 } },
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default (router) => {
|
|
53
|
+
// 데모 셸 페이지(MPA) — 로그인 가드.
|
|
54
|
+
router.http.get('/demo/ws', WsController.index, { before: [webRequireAuth] })
|
|
55
|
+
|
|
56
|
+
// 실 WS 엔드포인트 — upgrade 시 세션 인증, chat.send payload 사전 검증.
|
|
57
|
+
router.ws('/ws/chat', ChatChannel, {
|
|
58
|
+
before: [makeWsRequireAuth(router.app)],
|
|
59
|
+
schemas: { 'chat.send': CHAT_SEND_SCHEMA },
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
옵션 정리:
|
|
65
|
+
|
|
66
|
+
| 옵션 | 의미 |
|
|
67
|
+
|------|------|
|
|
68
|
+
| `before` | upgrade 핸드셰이크 직전에 실행되는 인증 미들웨어 배열. raw `IncomingMessage` 를 받는다(§6). |
|
|
69
|
+
| `schemas` | `{ [messageType]: JSONSchema }` — type 별 **payload** 스키마. 부팅 시 AJV 로 사전 컴파일된다. |
|
|
70
|
+
|
|
71
|
+
`path` 는 그대로 ASP namespace 로도 쓰인다(예: `/ws/chat`). 같은 path 를 두 번 등록하면
|
|
72
|
+
`route.ws_duplicate_path` 로 throw 한다.
|
|
73
|
+
|
|
74
|
+
**부팅 fail-fast**: `ChannelClass` 가 `MegaWebSocketController` 를 상속하지 않으면
|
|
75
|
+
`route.ws_channel_not_controller` 로 즉시 throw 한다. 라이프사이클 훅과 자동 디스패치가 보장되어야
|
|
76
|
+
upgrade 가 동작하기 때문이다.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 2. WebSocketController (Channel 클래스)
|
|
81
|
+
|
|
82
|
+
WS 채널은 `MegaWebSocketController` 를 상속한 클래스다. 인스턴스는 **연결 1건마다 새로 생성**되므로
|
|
83
|
+
연결별 상태는 인스턴스에, 공유 상태는 외부(redis 등)에 둔다.
|
|
84
|
+
|
|
85
|
+
라이프사이클 3훅:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
import { MegaWebSocketController } from 'mega-framework'
|
|
89
|
+
|
|
90
|
+
export class ChatChannel extends MegaWebSocketController {
|
|
91
|
+
async onConnect(sock, ctx) { /* 연결 수립 1회 */ }
|
|
92
|
+
async onMessage(sock, msg, ctx) { /* 디스패치되지 않은 메시지 폴백 */ }
|
|
93
|
+
async onDisconnect(sock, ctx) { /* 연결 종료 */ }
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### type 자동 디스패치
|
|
98
|
+
|
|
99
|
+
수신 메시지의 `type` 이 `domain.action` 패턴이고 **동명 메서드**가 있으면 그 메서드로 직접
|
|
100
|
+
디스패치된다. 없으면 `onMessage` 폴백으로 간다. 예: `{ type: 'chat.send' }` →
|
|
101
|
+
`this['chat.send'](sock, msg, ctx)`.
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
export class ChatChannel extends MegaWebSocketController {
|
|
105
|
+
async ['chat.send'](sock, msg, ctx) {
|
|
106
|
+
const text = String(msg.payload?.text ?? '').trim()
|
|
107
|
+
// ...
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`type` 패턴이 점(`.`)을 최소 1개 강제하므로(`/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/`),
|
|
113
|
+
`onMessage`·`constructor` 같은 베이스 멤버명은 절대 type 으로 매칭되지 않는다 — prototype 오염이나
|
|
114
|
+
의도치 않은 베이스 호출을 구조적으로 차단한다.
|
|
115
|
+
|
|
116
|
+
### `sock` (MegaWsConnection) — 채널이 받는 소켓 래퍼
|
|
117
|
+
|
|
118
|
+
| 멤버 | 설명 |
|
|
119
|
+
|------|------|
|
|
120
|
+
| `sock.send({ type, ns?, payload?, error?, ref? })` | envelope 송신. `v`/`id`/`ts` 자동 채움, ASP 활성 시 자동 암호화. `ns` 생략 시 연결 namespace 자동 주입. |
|
|
121
|
+
| `sock.sendError({ code, message?, details?, ref?, type? })` | error envelope 송신(기본 `type: 'mega.error'`). |
|
|
122
|
+
| `sock.close(code?, reason?)` | 연결 종료. |
|
|
123
|
+
| `sock.id` | 연결 식별자(ULID). |
|
|
124
|
+
| `sock.ns` / `sock.path` | namespace / 경로. |
|
|
125
|
+
| `sock.isOpen` | OPEN 상태인지. |
|
|
126
|
+
| `sock.raw` | 하위 `ws` WebSocket(바이너리·직접 제어용 escape hatch). |
|
|
127
|
+
| `sock.userId` / `sock.sessionId` / `sock.channels` / `sock.metadata` | `joinSession` 으로 매핑된 신원(§4). |
|
|
128
|
+
|
|
129
|
+
### `ctx` — 채널 컨텍스트
|
|
130
|
+
|
|
131
|
+
- `ctx.app` — 소속 MegaApp(`broadcast`/`joinSession` 등 호출처).
|
|
132
|
+
- `ctx.log` — request 로거(`debug`/`warn`/`error`).
|
|
133
|
+
- `ctx.auth` — `before` 인증이 돌려준 신원(§6). 인증 안 한 채널은 `null`.
|
|
134
|
+
- `ctx.ns` / `ctx.path` / `ctx.req` / `ctx.connId`.
|
|
135
|
+
- `ctx.tracer` — `ctx.tracer.span(name, fn)` 으로 핸들러 안에서 직접 span.
|
|
136
|
+
- `ctx.presence` — presence 단축 API(`{ list, join, directToUser, broadcast }`, ns·conn 바인딩, ADR-176).
|
|
137
|
+
클러스터 roster·fan-out 은 프레임워크(`wsCluster`, NATS)가 처리하므로 채널은 비즈니스 로직만 쓴다(§4·§5).
|
|
138
|
+
- DB/캐시 접근자(`ctx.db`, `ctx.cache` 등) — HTTP ctx 와 동일 표면.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 3. envelope (12-type 프로토콜)
|
|
143
|
+
|
|
144
|
+
모든 WebSocket 메시지는 단일 envelope 으로 통일된다(`src/core/ws-message.js`):
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
{ v, id, type, ts, ns?, payload?, error?, ref? }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
| 필드 | 설명 |
|
|
151
|
+
|------|------|
|
|
152
|
+
| `v` | 프로토콜 버전. 현재 `1` 고정. |
|
|
153
|
+
| `id` | 메시지 ID(ULID — 시간 정렬 가능 + 충돌 거의 없음). |
|
|
154
|
+
| `type` | `domain.action[.result]` 패턴(소문자+점). |
|
|
155
|
+
| `ts` | epoch ms(integer). |
|
|
156
|
+
| `ns` | 채널 namespace(예: `'chat'`). |
|
|
157
|
+
| `payload` | 본문(object). |
|
|
158
|
+
| `error` | 에러 객체(§6.3 `{ code, message, details? }`). |
|
|
159
|
+
| `ref` | 요청-응답 매칭용. 서버 푸시는 생략. |
|
|
160
|
+
|
|
161
|
+
> HTTP 응답 envelope(`{ ok, data, meta }`)과는 **별개**다 — transport 가 다르다.
|
|
162
|
+
|
|
163
|
+
검증은 외부 의존성 없이 고정 shape 를 직접 검사한다. `validateWsMessage(msg)` 는 위반 사유 배열을
|
|
164
|
+
돌려주고(빈 배열 = 유효), `parseWsMessage(json)` 은 파싱·검증 실패 시 throw 한다(silent 금지).
|
|
165
|
+
|
|
166
|
+
핵심 type 예:
|
|
167
|
+
|
|
168
|
+
- `session.start` / `session.end`
|
|
169
|
+
- `presence.join` / `presence.leave`
|
|
170
|
+
- `chat.send` / `chat.msg` / `chat.history`
|
|
171
|
+
- `mega.error`(서버측 에러 통지), `asp.error`(복호화 실패 통지)
|
|
172
|
+
|
|
173
|
+
Bridge ↔ Hub 사이의 12-타입 프로토콜(`src/lib/hub-protocol.js`)도 같은 envelope 위에 얹는다.
|
|
174
|
+
다만 wire `type` 은 `hub.<lower>` 로 못박혀 있다(예: 논리명 `REGISTER` → `hub.register`):
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
hub.register / hub.register_ok / hub.join / hub.leave / hub.bulk_leave /
|
|
178
|
+
hub.broadcast / hub.direct / hub.metadata / hub.disconnect / hub.binary /
|
|
179
|
+
hub.heartbeat / hub.error
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 4. broadcast / 사용자 직접 전송 / presence
|
|
185
|
+
|
|
186
|
+
`app`(= `ctx.app`)에 fan-out API 가 있다.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
// 채널 전체 broadcast — 로컬 ns 소켓에 즉시 전달 + (wsCluster/hub 배선 시) 클러스터 전파.
|
|
190
|
+
ctx.app.broadcast({
|
|
191
|
+
ns: '/ws/chat',
|
|
192
|
+
channel: 'chat',
|
|
193
|
+
message: { type: 'chat.msg', payload: { text: 'hi' } },
|
|
194
|
+
exceptSessionIds: [sock.sessionId], // 특정 세션 제외(옵션)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// 특정 사용자에게 직접 — joinSession 으로 매핑된 그 userId 연결에만.
|
|
198
|
+
ctx.app.directToUser(userId, { type: 'chat.dm', payload: { text: 'hello' } })
|
|
199
|
+
|
|
200
|
+
// presence 메타데이터 갱신.
|
|
201
|
+
ctx.app.updateMetadata(sock.sessionId, { typing: true })
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`message.type` 이 없으면 즉시 throw 한다 — 호출부 입력 오류를 silent drop 하지 않는다.
|
|
205
|
+
|
|
206
|
+
**클러스터 전파는 자동이다(ADR-176)**: Global `wsCluster.bus`(NATS)가 켜져 있으면 `app.broadcast`/
|
|
207
|
+
`directToUser` 는 로컬 전달 후 NATS 로 fan-out 하고, 다른 인스턴스가 같은 subject 를 구독해 각자
|
|
208
|
+
로컬에 전달한다(자기 echo 는 instanceId 로 스킵). 개발자는 redis pub/sub 같은 배선 코드를 쓰지 않는다.
|
|
209
|
+
WS hub(`connectHub`) 연결 시에는 hub 경로로도 fan-out 한다(§9). 둘 다 미배선이면 로컬 ns 전용이다.
|
|
210
|
+
|
|
211
|
+
### `ctx.presence` — presence 단축 API (권장, ADR-176)
|
|
212
|
+
|
|
213
|
+
채널 안에서는 `ctx.app.*` 대신 `ctx.presence` 로 ns·conn 이 미리 바인딩된 단축 API 를 쓰는 게 권장이다.
|
|
214
|
+
|
|
215
|
+
| 멤버 | 설명 |
|
|
216
|
+
|------|------|
|
|
217
|
+
| `ctx.presence.list()` | 이 채널 ns 의 **클러스터 전역** 접속자 목록 `[{ sessionId, userId, metadata? }]`. `wsCluster` roster 미배선이면 로컬 멤버만. |
|
|
218
|
+
| `ctx.presence.join({ userId, sessionId, channels?, metadata? })` | 연결을 신원에 매핑 — roster 등록이 자동으로 따라온다(`app.joinSession` 단축). |
|
|
219
|
+
| `ctx.presence.directToUser(userId, message)` | 특정 userId 에게 직접 전송(클러스터 전역). |
|
|
220
|
+
| `ctx.presence.broadcast({ channel?, message, exceptSessionIds? })` | 이 ns 전체 broadcast(클러스터 전역). `ns` 는 자동 바인딩. |
|
|
221
|
+
|
|
222
|
+
> mock app(단위 테스트)·ns 부재면 `ctx.presence` 는 `null` 이다.
|
|
223
|
+
|
|
224
|
+
`app.roster(ns)` 는 같은 클러스터 전역 명단을 app 레벨에서 직접 읽는다(`ctx.presence.list()` 와 동일
|
|
225
|
+
데이터). roster 동기화(add/remove/heartbeat/sweep)는 프레임워크가 처리하므로 개발자는 **읽기만** 한다.
|
|
226
|
+
|
|
227
|
+
### joinSession — 신원 매핑
|
|
228
|
+
|
|
229
|
+
`directToUser` 가 **해당 userId 세션에만** 닿으려면, `onConnect` 에서 연결을 신원에 매핑해야 한다.
|
|
230
|
+
매핑 없는 연결은 `directToUser` 대상에서 제외된다(cross-user flood 방지). `wsCluster` 배선 시 이
|
|
231
|
+
매핑이 클러스터 roster 에도 자동 등록되고, disconnect 시 자동 제거된다(개발자 코드 불요).
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
async onConnect(sock, ctx) {
|
|
235
|
+
// ctx.presence.join 단축(권장) — app.joinSession 과 동일, ns 바인딩.
|
|
236
|
+
ctx.presence.join({
|
|
237
|
+
userId: ctx.auth.userId,
|
|
238
|
+
sessionId: ctx.auth.sessionId,
|
|
239
|
+
channels: ['chat'],
|
|
240
|
+
metadata: { userName: ctx.auth.userName },
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`userId`/`sessionId` 가 비어 있으면 throw 한다. 같은 `sessionId` 가 다른 연결로 다시 join 되면 옛
|
|
246
|
+
연결을 인덱스에서 떼어 dangling 을 막는다(소켓 자체는 닫지 않음).
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 5. cluster cross-worker broadcast (NATS `wsCluster` 자동배선)
|
|
251
|
+
|
|
252
|
+
`app.broadcast` 는 **단일 프로세스** 안의 로컬 fan-out 이다. 클러스터(워커 N개·다중 인스턴스)에서는
|
|
253
|
+
다른 인스턴스에 붙은 클라에게 닿지 않는다. 클러스터 전송 두 경로(§1) 중 **NATS `wsCluster`** 는 단일
|
|
254
|
+
NATS 만으로 broadcast·roster 동기화까지 처리해 간편하다 — 개발자는 config 에 `wsCluster` 만 켜고
|
|
255
|
+
`ctx.presence` 로 비즈니스 로직만 작성하면 된다(ADR-176). redis pub/sub 브릿지(예전 `chat-bus.js`)는
|
|
256
|
+
**제거됐다**. (다른 경로인 WS Hub 는 §9.)
|
|
257
|
+
|
|
258
|
+
### config — wsCluster 켜기
|
|
259
|
+
|
|
260
|
+
Global `mega.config.js` 에 `wsCluster.bus`(= `services.buses` 의 NATS 키)만 선언하면 boot 가 앱마다
|
|
261
|
+
`MegaWsCluster` 를 자동 생성·start 한다(`connectHub` 같은 배선 코드 불요). ⚠️ app config 에 `bridgeHub`
|
|
262
|
+
(WS Hub)가 있으면 상호배타라 부팅 fail-fast — 한쪽만 켠다. **sample/crud 는 현재 WS Hub(§9)를 쓰며**,
|
|
263
|
+
아래처럼 `bridgeHub` 를 빼고 `wsCluster` 를 켜면 NATS 로 전환된다:
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
// mega.config.js (global)
|
|
267
|
+
services: {
|
|
268
|
+
buses: {
|
|
269
|
+
jobs: { driver: 'nats', url: process.env.NATS_JOBS_URL }, // NATS 버스
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
wsCluster: {
|
|
273
|
+
bus: 'jobs', // services.buses 의 글로벌 키(driver:'nats')
|
|
274
|
+
roster: { driver: 'nats', ttlMs: 15_000 }, // 접속자 목록도 NATS 동기화. 생략/'none' 이면 로컬만
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
subject 는 앱 이름으로 격리된다: `<subjectPrefix>.<appName>.{bcast,direct,roster}`(`subjectPrefix`
|
|
279
|
+
기본 `'mega.ws'`). core NATS pub/sub(JetStream 아님)만 쓰므로 redis 의 `duplicate()`(구독 전용 연결
|
|
280
|
+
분리)가 불필요하다. echo 회피: 각 인스턴스는 고유 `instanceId` 를 envelope `o`(origin)로 실어, 자기
|
|
281
|
+
publish 수신은 스킵한다(로컬에서 이미 전달).
|
|
282
|
+
|
|
283
|
+
결과: `app.broadcast` 1건 → 로컬 즉시 전달 + NATS publish → 모든 인스턴스 구독자가 받아 각자 로컬
|
|
284
|
+
전달 → 전 클러스터 클라가 정확히 1회씩 수신.
|
|
285
|
+
|
|
286
|
+
### roster — 설정형 접속자 목록 (`wsCluster.roster.driver`)
|
|
287
|
+
|
|
288
|
+
접속자 명단 동기화도 프레임워크가 처리하되 방식을 config 로 고른다.
|
|
289
|
+
|
|
290
|
+
| `roster.driver` | 동작 |
|
|
291
|
+
|-----------------|------|
|
|
292
|
+
| `'nats'` | `joinSession`/disconnect 가 add/remove 델타 publish + 주기 heartbeat(멤버 전체 재공지로 TTL 갱신) + sweep(heartbeat 끊긴 **crash 인스턴스**의 stale 멤버 자동 제거) + 신규 인스턴스 sync_request(전원 즉시 응답으로 빠른 수렴). `ctx.presence.list()`/`app.roster(ns)` 가 클러스터 전역 명단을 반환. |
|
|
293
|
+
| `'none'`(기본) | 로컬 멤버만. 명단은 그 인스턴스 로컬 연결만. |
|
|
294
|
+
|
|
295
|
+
`ttlMs`(기본 30000)는 heartbeat 미수신 시 멤버를 stale 로 간주하고 제거하는 TTL 이다 — redis HASH
|
|
296
|
+
roster 의 **비정상종료 드리프트** 문제를 heartbeat/TTL 로 대체한다. 개발자는 roster 동기화 코드를
|
|
297
|
+
한 줄도 쓰지 않고 **읽기만** 한다.
|
|
298
|
+
|
|
299
|
+
### 채널 코드 — `ctx.presence` 만 (sample chat-channel 발췌)
|
|
300
|
+
|
|
301
|
+
```js
|
|
302
|
+
// chat-channel.js onConnect — 발췌 (전파·roster 배선 코드 없음)
|
|
303
|
+
async onConnect(sock, ctx) {
|
|
304
|
+
const { userId, sessionId, userName } = ctx.auth
|
|
305
|
+
// 신원 매핑 + 클러스터 roster 자동 등록(프레임워크가 NATS 로 동기화).
|
|
306
|
+
ctx.presence.join({ userId, sessionId, channels: ['chat'], metadata: { userName } })
|
|
307
|
+
|
|
308
|
+
const items = await this._loadHistory(ctx, sock) // 최근기록(redis KV — 전파/roster 와 무관)
|
|
309
|
+
const members = ctx.presence.list() // 클러스터 전역 명단(자동 동기화된 roster)
|
|
310
|
+
|
|
311
|
+
sock.send({
|
|
312
|
+
type: 'chat.history',
|
|
313
|
+
payload: { me: { userId, userName }, items, online: members.length, members, workerPid: process.pid },
|
|
314
|
+
})
|
|
315
|
+
// 다른 접속자(전 클러스터)에 입장 알림 — NATS fan-out 은 프레임워크가 처리.
|
|
316
|
+
ctx.presence.broadcast({
|
|
317
|
+
channel: 'chat',
|
|
318
|
+
message: { type: 'chat.presence', payload: { event: 'join', userName, members } },
|
|
319
|
+
exceptSessionIds: [sessionId],
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
chat-channel 은 입장 응답에 `workerPid` 를 실어 어느 워커에 붙었는지 보여준다 — cluster broadcast 가
|
|
325
|
+
실제로 워커 경계를 넘는지 눈으로 확인하는 장치다.
|
|
326
|
+
|
|
327
|
+
> **history 만 redis 유지**: 최근 메시지 replay(`ws:chat:history`)는 전파/roster 가 아니라 단순 KV
|
|
328
|
+
> 저장이라 redis(`demo` 캐시)에 그대로 둔다(ADR-176 범위 밖). 전파·roster 는 NATS, 기록은 redis 로
|
|
329
|
+
> 책임이 분리돼 있다.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## 6. WS upgrade 인증 (before / readSession)
|
|
334
|
+
|
|
335
|
+
HTTP `'upgrade'` 핸드셰이크는 Fastify 요청 파이프라인을 타지 않는다 → `req.session` 이 없고 raw
|
|
336
|
+
`IncomingMessage` 만 들어온다. 그래서 `before` 미들웨어는 raw req 를 받아 직접 인증해야 한다.
|
|
337
|
+
|
|
338
|
+
`before` 반환값 규약:
|
|
339
|
+
|
|
340
|
+
| 반환 | 결과 |
|
|
341
|
+
|------|------|
|
|
342
|
+
| `false` | upgrade **401 거부**(fail-closed). |
|
|
343
|
+
| throw | 마찬가지로 **401 거부**(silent 금지). |
|
|
344
|
+
| object | 인증 신원 — 마지막으로 객체를 돌려준 `before` 값이 `ctx.auth` 가 된다. |
|
|
345
|
+
| `true` / `undefined` | "허용, 신원 없음". |
|
|
346
|
+
|
|
347
|
+
`readSession(req, { store, secret, cookieName? })` 헬퍼가 쿠키 → 서명 검증 → 세션 스토어 로드를
|
|
348
|
+
직접 수행한다. 유효 세션이면 `{ sid, data }`, 아니면 `null` 을 돌려준다(fail-closed).
|
|
349
|
+
|
|
350
|
+
sample/crud — `apps/main/middleware/ws-auth.js`:
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
import { readSession } from 'mega-framework'
|
|
354
|
+
|
|
355
|
+
export function makeWsRequireAuth(app) {
|
|
356
|
+
const secret = process.env.SESSION_SECRET
|
|
357
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
358
|
+
// 시크릿 없는 세션 검증은 불가 — 부팅 시 fail-fast(silent 진행 금지).
|
|
359
|
+
throw new Error('makeWsRequireAuth: SESSION_SECRET is required (set it in .env).')
|
|
360
|
+
}
|
|
361
|
+
return async function wsRequireAuth(req) {
|
|
362
|
+
const sess = await readSession(req, { store: app.sessionStore, secret })
|
|
363
|
+
const userId = sess?.data.userId
|
|
364
|
+
if (userId == null) {
|
|
365
|
+
app.fastify.log?.debug?.({ route: req.url }, 'ws.auth deny — not logged in (401)')
|
|
366
|
+
return false // → upgrade 401
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
userId: String(userId),
|
|
370
|
+
sessionId: sess.sid,
|
|
371
|
+
userName: typeof sess.data.userName === 'string' ? sess.data.userName : '',
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`makeWsRequireAuth` 는 **팩토리**다 — 라우트에서 `before: [makeWsRequireAuth(router.app)]` 처럼
|
|
378
|
+
**호출**해서 써야 한다(`router.app` 으로 세션 스토어를 클로저 주입). 팩토리 자체를 넘기면 인증이
|
|
379
|
+
동작하지 않는다.
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## 7. ASP (Auto Secure Protocol)
|
|
384
|
+
|
|
385
|
+
ASP 는 클라 ↔ Bridge 구간의 전송 암호화다. WS 와 HTTP 양쪽에서 동작한다.
|
|
386
|
+
|
|
387
|
+
### 프레임 프레이밍 (`E:` / `P:`)
|
|
388
|
+
|
|
389
|
+
WS 프레임은 평문/암호를 prefix 로 명시한다(`src/lib/asp/ws-terminator.js`):
|
|
390
|
+
|
|
391
|
+
- 송신: 암호화면 `E:<ts>:<base64>`, 평문이면 `P:<json>`.
|
|
392
|
+
- 수신: **prefix 가 권위**다. `E:` 는 항상 복호화 시도, `P:` 는 평문, prefix 없으면 `invalid_payload`.
|
|
393
|
+
|
|
394
|
+
### fail-closed
|
|
395
|
+
|
|
396
|
+
복호화 실패는 절대 silent fallback 하지 않는다. 평문 `asp.error` 통지 후 **close 4500** 으로 닫는다
|
|
397
|
+
(`src/core/ws-upgrade.js`). 클라가 키를 못 만드는 상황이라는 명시적 신호다.
|
|
398
|
+
|
|
399
|
+
```js
|
|
400
|
+
// ws-upgrade.js handleIncoming — 발췌
|
|
401
|
+
try {
|
|
402
|
+
plain = codec.decode(frame)
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const rule = err instanceof MegaAspDecryptError ? err.rule : 'unknown'
|
|
405
|
+
log.warn?.({ err, rule, connId: conn.id }, 'ws frame decode failed (ADR-084)')
|
|
406
|
+
// 평문 asp.error 통지 후 close 4500.
|
|
407
|
+
raw.send(codec.encodePlain(JSON.stringify(
|
|
408
|
+
createWsMessage({ type: 'asp.error', error: { code: 'asp.decrypt_failed', rule } }),
|
|
409
|
+
)))
|
|
410
|
+
if (conn.isOpen) raw.close(CLOSE_CODE_DECRYPT_FAILED, 'asp.decrypt_failed')
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### close code 카탈로그
|
|
416
|
+
|
|
417
|
+
| code | 의미 |
|
|
418
|
+
|------|------|
|
|
419
|
+
| `1000` | Normal closure(정상 종료). |
|
|
420
|
+
| `1001` | Going away(graceful shutdown). |
|
|
421
|
+
| `1011` | Internal error — `onConnect` 등 **서버 내부** 실패(복호화와 무관). |
|
|
422
|
+
| `4500` | Decrypt failed — ASP 키 mismatch / 프레임 손상 **전용**. |
|
|
423
|
+
|
|
424
|
+
> `4500` 을 일반 핸들러 실패에 쓰면 클라가 키 mismatch 로 오해한다. 그래서 서버 내부 오류는 RFC 6455
|
|
425
|
+
> 표준 `1011` 로 분리한다.
|
|
426
|
+
|
|
427
|
+
### crypto
|
|
428
|
+
|
|
429
|
+
`src/lib/asp/crypto.js` 는 `node:crypto` 만 쓰는 zero-dep 구현이고, WASM 클라이언트의 Rust 정본과
|
|
430
|
+
**byte-for-byte 호환**이다.
|
|
431
|
+
|
|
432
|
+
- 알고리즘: AES-256-GCM(암호화) + HMAC-SHA256(키 유도) + nibble-swap/XOR obfuscation.
|
|
433
|
+
- 키 유도: `SHA256(hex(HMAC-SHA256(masterSecret, "domain:path:uaSlice:timestamp")))` → 32 bytes.
|
|
434
|
+
- `domain` = Host(클라 `location.hostname`), `path` = WS 경로, `uaSlice` = User-Agent 의 timestamp
|
|
435
|
+
기반 4-byte 순환 슬라이스, `timestamp` = 프레임 ts.
|
|
436
|
+
- wire: `nonce(12) || ciphertext || tag(16)` 를 obfuscate 후 base64.
|
|
437
|
+
|
|
438
|
+
### config
|
|
439
|
+
|
|
440
|
+
ASP 는 **명시적 옵트인**이다. masterSecret 은 global(`mega.config.js`), 옵트인 범위는 앱
|
|
441
|
+
(`app.config.js`)에서 선언하고 부팅이 합성한다.
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
// mega.config.js (global)
|
|
445
|
+
asp: {
|
|
446
|
+
masterSecret: process.env.ASP_MASTER_SECRET,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// apps/main/app.config.js (앱)
|
|
450
|
+
asp: {
|
|
451
|
+
websocket: {
|
|
452
|
+
namespaces: ['/ws/chat'], // 이 경로만 기본 암호화(E:), 나머지는 평문(P:)
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
`failOpen` 은 사용자가 `true` 로 박아도 코어가 **강제로 false** 로 덮어쓴다(+ warn). 키 mismatch 는
|
|
458
|
+
항상 거부, 평문은 절대 통과하지 않는다. `masterSecret` 누락 시 부팅 fail-fast.
|
|
459
|
+
|
|
460
|
+
### ASP HTTP terminator
|
|
461
|
+
|
|
462
|
+
HTTP 쪽은 Fastify hook 4종으로 동작한다(`src/lib/asp/plugin.js`, `enabledPaths` glob 옵트인):
|
|
463
|
+
|
|
464
|
+
| hook | 역할 |
|
|
465
|
+
|------|------|
|
|
466
|
+
| `onRequest` | ASP 스코프 판별 + timestamp/drift 검증 + per-request 키 유도(시그널 없으면 `signal_required` fail-closed). |
|
|
467
|
+
| `preParsing` | body ciphertext 복호화 → 평문 stream(JSON 파서가 평문을 보게). |
|
|
468
|
+
| `preValidation` | query `?q=`(URL-safe base64) 복호화 → `req.query` 치환. |
|
|
469
|
+
| `onSend` | 응답 envelope JSON 암호화 + 시그널/timestamp 헤더. |
|
|
470
|
+
|
|
471
|
+
실패 종류(`ASP_RULES`): `key_mismatch`, `invalid_payload`, `drift`, `replay`, `missing_timestamp`,
|
|
472
|
+
`signal_required`. 모두 `MegaAspDecryptError`(403)로 throw 된다 — 평문 통과 없음.
|
|
473
|
+
|
|
474
|
+
**replay 방어**(옵션): AES-GCM 의 12-byte random nonce 를 nonce 캐시에 SETNX 한다. 동일 nonce 가 두
|
|
475
|
+
번째 등장하면 replay → reject. timestamp drift(±60s) 와 결합해 윈도우 내 재전송까지 차단한다. 멀티
|
|
476
|
+
인스턴스는 redis 백엔드 권장(in-memory 는 프로세스별 분리).
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## 8. WASM 클라이언트 (MegaSocket)
|
|
481
|
+
|
|
482
|
+
브라우저에서 ASP `E:` 프레임을 직접 암복호화하려면 WASM 클라이언트가 필요하다. 산출물은
|
|
483
|
+
`packages/mega-client-wasm/pkg/` 에 **commit 되어 있다**(빌드 없이 바로 vendoring).
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
import init, { MegaSocket } from '/static/vendor/mega-client-wasm/mega_client_wasm.js'
|
|
487
|
+
|
|
488
|
+
await init() // WASM 초기화(CSP 'wasm-unsafe-eval' 필요)
|
|
489
|
+
|
|
490
|
+
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/chat`
|
|
491
|
+
// envelope 모드 — 서버 정본 envelope({v,id,type,ts,payload})로 송수신해 /ws/* 라우트와 정합.
|
|
492
|
+
const sock = new MegaSocket(url, aspSecret, { encrypt: true, protocol: 'envelope' })
|
|
493
|
+
|
|
494
|
+
sock.on('connect', () => setStatus('open'))
|
|
495
|
+
sock.on('asp-decrypt-failed', (info) => {
|
|
496
|
+
// ASP 복호화 실패(키 mismatch 등) — 표면화(fail-closed).
|
|
497
|
+
appendNotice(`ASP: ${info?.rule ?? 'decrypt_failed'}`)
|
|
498
|
+
})
|
|
499
|
+
sock.on('chat.msg', (payload) => appendMessage(payload ?? {}))
|
|
500
|
+
|
|
501
|
+
sock.send('chat.send', { text }, {}) // (type, data, opts)
|
|
502
|
+
sock.connect()
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
생성자 옵션:
|
|
506
|
+
|
|
507
|
+
- `encrypt`(디폴트 `true`) — `E:` 프레임 송신.
|
|
508
|
+
- `protocol` — `'event'`(디폴트, `{type,data}` 하위호환) 또는 `'envelope'`(프레임워크 WS 서버 정본
|
|
509
|
+
envelope). `/ws/*` 라우트에 붙을 때는 **`'envelope'`** 를 쓴다.
|
|
510
|
+
|
|
511
|
+
`domain`/`ws_path` 는 URL 에서, `ua` 는 브라우저에서 추출되어 서버와 같은 키를 유도한다(공유-키 구조).
|
|
512
|
+
그래서 데모 페이지는 masterSecret 을 브라우저에 주입해야 한다 — CSP(`script-src 'self'`)가 인라인
|
|
513
|
+
스크립트를 막으므로 **data 속성**으로 심고 외부 ESM 모듈이 읽는다.
|
|
514
|
+
|
|
515
|
+
helmet CSP 는 WASM 컴파일을 위해 `'wasm-unsafe-eval'` 을 더해야 한다:
|
|
516
|
+
|
|
517
|
+
```js
|
|
518
|
+
// app.config.js
|
|
519
|
+
helmet: {
|
|
520
|
+
contentSecurityPolicy: {
|
|
521
|
+
directives: {
|
|
522
|
+
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
> `useDefaults`(기본 true)라 이 지시문만 교체되고 나머지(`connect-src 'self'` = 동일 출처 WebSocket
|
|
529
|
+
> 허용 등)는 helmet 기본을 유지한다.
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
## 9. Hub (옵션)
|
|
534
|
+
|
|
535
|
+
Hub 는 여러 Bridge(server) 인스턴스를 가로질러 presence·broadcast·direct 를 라우팅하는 **별도
|
|
536
|
+
프로세스**다. 클러스터 전송 두 경로(§1) 중 하나로, **NATS 없이 WS 전송으로 여러 호스트에 흩어진
|
|
537
|
+
인스턴스를 묶거나 기존 hub 토폴로지를 쓸 때** 적합하다. **sample/crud 가 현재 이 경로(WS Hub)를 쓴다** —
|
|
538
|
+
app config 의 `bridgeHub` 를 boot 가 자동 배선한다(아래). NATS `wsCluster`(§5)와는 상호배타다.
|
|
539
|
+
|
|
540
|
+
### Bridge 측 연결 — `app.connectHub`
|
|
541
|
+
|
|
542
|
+
```js
|
|
543
|
+
const link = await app.connectHub({
|
|
544
|
+
url: 'ws://hub1.internal:19991/_hub',
|
|
545
|
+
token: process.env.MEGA_WS_HUB_TOKEN,
|
|
546
|
+
bridgeId: 'main-1',
|
|
547
|
+
channels: ['chat'], // 자동 구독할 채널(zero-config 브로드캐스트 수신)
|
|
548
|
+
retry: { retries: 10, minTimeout: 500, maxTimeout: 10_000 }, // 지정 시 자동 재연결
|
|
549
|
+
})
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
연결 후 `app.broadcast`/`directToUser` 는 로컬 전달에 더해 Hub 로도 fan-out 한다. 재연결 시
|
|
553
|
+
presence(채널·세션 JOIN)를 자동 재동기화한다(Hub 는 절단 시점 presence 를 잃기 때문).
|
|
554
|
+
|
|
555
|
+
**자동배선(ADR-176)**: 위처럼 `connectHub` 를 직접 부르지 않아도, app config 에 `bridgeHub` 를 두면
|
|
556
|
+
**boot 가 `app.connectHub` 를 자동 호출**한다(개발자 배선 코드 불요). 즉 WS Hub 도 `wsCluster`(NATS,
|
|
557
|
+
§5)처럼 **config 만으로 동작하는 클러스터 전송**이다 — **둘은 상호배타**(앱당 하나, 동시 설정 시 부팅
|
|
558
|
+
`config.cluster_transport_conflict` fail-fast). `bridgeHub.retry` 를 주면 허브 재시작·drain(4503) 시
|
|
559
|
+
지수 백오프로 재연결한다.
|
|
560
|
+
|
|
561
|
+
```js
|
|
562
|
+
// app.config.js — boot 가 자동으로 connectHub 한다(ADR-176).
|
|
563
|
+
bridgeHub: {
|
|
564
|
+
url: process.env.MEGA_WSHUB_URL ?? 'ws://localhost:3100',
|
|
565
|
+
token: process.env.MEGA_WSHUB_TOKEN,
|
|
566
|
+
bridgeId: 'main-1',
|
|
567
|
+
channels: ['chat'],
|
|
568
|
+
retry: { retries: 30, minTimeout: 1000, maxTimeout: 10_000 },
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
> **hub 경로의 접속자 목록은 redis 로 (ADR-177)**: broadcast(메시지)는 허브가 fan-out 하지만 **roster(접속자 목록)는 redis 공유 스토어**로 분리한다(`bridgeHub.roster:{ driver:'redis', cache:'<redis 캐시 키>', ttlMs }`). roster 는 "상태"라 허브 개수와 무관한 공유 스토어가 맞다 — 이로써 **멀티 허브에서도 명단 정합**, 신규/재연결 브릿지가 **즉시 전체 명단**(`HGETALL`), TTL+heartbeat 로 crash 정리. 프레임워크는 **채널별 전체 접속자**만 관리하고(키 `ws:roster:<channel>`), **룸은 앱 비즈니스 로직**이다. `ctx.presence.list()` 는 **async**(redis 조회) — `await` 한다. NATS `wsCluster` 경로는 자체 roster(sync_request) 유지라 redis 불요(redis roster 는 허브 경로 옵션).
|
|
573
|
+
>
|
|
574
|
+
> ⚠️ **클러스터 시 bridgeId 유일화**: 워커마다 별개 브릿지라, boot 자동배선이 설정 `bridgeId` 에 워커 식별자(`cluster.worker.id`/`pid`)를 붙여 유일화한다(`crud-1-w3`). 직접 `connectHub` 를 쓸 때도 다중 인스턴스면 bridgeId 를 인스턴스별로 유일하게 줘야 한다(안 그러면 허브가 bridge-subscriber sessionId 충돌로 thrashing).
|
|
575
|
+
|
|
576
|
+
### Hub 프로세스 — `mega-ws-hub`
|
|
577
|
+
|
|
578
|
+
Hub 는 `mega-ws-hub` bin 으로 독립 실행한다. Bridge 는 핸드셰이크 `Authorization: Bearer` 헤더와
|
|
579
|
+
`hub.register` payload 양쪽에 토큰을 싣고, Hub 는 `acceptedTokens` 와 **timing-safe 비교**한다.
|
|
580
|
+
|
|
581
|
+
```bash
|
|
582
|
+
MEGA_WSHUB_TOKENS=tok1,tok2 \
|
|
583
|
+
MEGA_WSHUB_PORT=3100 \
|
|
584
|
+
MEGA_WSHUB_HOST=0.0.0.0 \
|
|
585
|
+
MEGA_WSHUB_HEARTBEAT_MS=25000 \
|
|
586
|
+
mega-ws-hub
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
| env | 의미 |
|
|
590
|
+
|-----|------|
|
|
591
|
+
| `MEGA_WSHUB_TOKENS` | **필수** — 콤마 구분 `acceptedTokens`. |
|
|
592
|
+
| `MEGA_WSHUB_PORT` | 기본 3100. |
|
|
593
|
+
| `MEGA_WSHUB_HOST` | 기본 `0.0.0.0`. |
|
|
594
|
+
| `MEGA_WSHUB_HEARTBEAT_MS` | 선택 — heartbeat 주기. |
|
|
595
|
+
| `MEGA_WSHUB_MAX_PAYLOAD` | 선택 — 최대 프레임 bytes. |
|
|
596
|
+
| `MEGA_WSHUB_COMPRESSION` | `'true'` 면 per-message deflate ON(`MEGA_WSHUB_COMPRESSION_THRESHOLD` 로 임계값). |
|
|
597
|
+
|
|
598
|
+
Hub link close code: `1001`(going away), `1008`(unauthorized — Bearer 불일치), `4503`(drain — Hub 가
|
|
599
|
+
의도적으로 비우는 중 → bridge 는 다른 Hub 로 재연결). 데이터 보장은 **at-most-once** 다(in-memory
|
|
600
|
+
라우팅 — 절단 시점 메시지 손실 가능). 토큰 mismatch 면 bridge 가 401 을 받고 backoff 재시도한다.
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
## 10. 함정 (자주 하는 실수)
|
|
605
|
+
|
|
606
|
+
1. **`before` 팩토리를 호출하지 않음** — `before: [makeWsRequireAuth(router.app)]` 처럼 팩토리를
|
|
607
|
+
**호출**해 클로저(세션 스토어)를 주입해야 한다. 팩토리 자체를 넘기면 인증이 동작하지 않는다.
|
|
608
|
+
2. **`transform`/`after` 를 `router.ws` 에 전달** — HTTP 전용이라 부팅 시 throw 한다. WS 메시지는
|
|
609
|
+
채널 안에서 `type` 으로 디스패치된다.
|
|
610
|
+
3. **ChannelClass 가 `MegaWebSocketController` 미상속** — 부팅 fail-fast
|
|
611
|
+
(`route.ws_channel_not_controller`).
|
|
612
|
+
4. **ASP decrypt 실패를 silent 처리** — 금지. fail-closed 로 `asp.error` 통지 후 **close 4500**.
|
|
613
|
+
서버 내부 오류(`onConnect` throw 등)는 4500 이 아니라 **1011** 로 닫아 의미를 분리한다.
|
|
614
|
+
5. **`app.broadcast` 로 클러스터 전파를 기대했는데 전송 미설정** — 클러스터 전송(§1)을 안 켜면 로컬 ns
|
|
615
|
+
전용이다. 워커·인스턴스 경계를 넘으려면 **NATS `wsCluster`(§5) 또는 WS Hub `bridgeHub`(§9) 중 하나**를
|
|
616
|
+
config 에 둔다(상호배타 — 둘 다 켜면 부팅 fail-fast).
|
|
617
|
+
6. **`joinSession` 없이 `directToUser` 기대** — 매핑 없는 연결은 DIRECT 대상에서 제외된다. `onConnect`
|
|
618
|
+
에서 신원을 매핑해야 한다.
|
|
619
|
+
7. **브라우저 ASP 인데 WASM 산출물 누락** — `E:` 프레임을 직접 암복호화하려면 commit 된
|
|
620
|
+
`mega-client-wasm/pkg/` 가 필요하고, CSP 에 `'wasm-unsafe-eval'` 이 있어야 한다.
|
|
621
|
+
8. **HTTP `before` 미들웨어의 arity** — async preHandler 가 인자 3개 이상이면 Fastify 가 거부한다.
|
|
622
|
+
프레임워크가 arity-2 wrapper 로 감싸므로 라우트의 `before` 는 `ctx` 를 직접 받지 않는다(WS 의
|
|
623
|
+
`before(req)` 와는 별개 평면).
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## 관련 ADR
|
|
628
|
+
|
|
629
|
+
015(envelope), 030(cluster), 035(presence/directToUser/broadcast), 065(앱별 bridgeHub),
|
|
630
|
+
083(E:/P: prefix), 084(fail-closed), 097(hub `type` 네이밍), 137(wsHub embedded), 156(arity-2 wrapper),
|
|
631
|
+
158(채팅 데모 — redis→NATS 전환), 159(readSession WS 인증), 160(WASM envelope 모드), 161(WASM pkg
|
|
632
|
+
commit), 176(NATS `wsCluster` 자동배선 — broadcast/roster, redis 브릿지 제거).
|