@gaonjs/cli 0.58.0 → 0.58.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.
|
@@ -368,6 +368,112 @@ export default channel({})
|
|
|
368
368
|
`room-closed` 를 반영한다 — 리스너의 `broadcast('lobby', …)` 단일 발행이 전
|
|
369
369
|
서버 로비 구독자에게 팬아웃된다(§2.5).
|
|
370
370
|
|
|
371
|
+
### 2.9 룸 프리미티브 — 정원·kick·메타·입장순 (결정 444)
|
|
372
|
+
|
|
373
|
+
인스턴스 채널(§2.7) 위의 "방 운영" 표면 4종. 전부 도메인 중립이다 — 채팅
|
|
374
|
+
(모더레이터 강퇴·방 인원 제한·방 규칙)과 게임(안티치트 축출·매치 정원·매치
|
|
375
|
+
설정)이 같은 프리미티브를 쓴다.
|
|
376
|
+
|
|
377
|
+
**① 정원 `maxMembers` — 허브 원자 판정.** 정의에 선언하면 초과 입장이
|
|
378
|
+
`4409` 로 거부된다(허브가 전역 로스터 단일 권위로 **동시 입장 race 없이**
|
|
379
|
+
판정 · joinAck 프로토콜). **멤버 수 기준**이다 — 기존 멤버의 멀티탭 추가
|
|
380
|
+
연결은 정원 무관 통과(결정 225 축). 함수형이면 접속 시점마다 평가돼 방별
|
|
381
|
+
동적 정원이 된다:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// 채팅방 — 고정 인원 제한
|
|
385
|
+
export default channel({ instance: true, maxMembers: 100 })
|
|
386
|
+
|
|
387
|
+
// 게임 매치 — 방별 정원(메타·DB 조회)
|
|
388
|
+
export default channel({
|
|
389
|
+
instance: true,
|
|
390
|
+
async maxMembers(ctx) {
|
|
391
|
+
const meta = await instanceMeta('match', { instance: ctx.instance })
|
|
392
|
+
return typeof meta?.capacity === 'number' ? meta.capacity : 4
|
|
393
|
+
},
|
|
394
|
+
})
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**② kick — 강제 퇴장(서버 전용 표면).** 대상 멤버의 그 인스턴스 연결
|
|
398
|
+
전부(멀티탭·멀티서버)가 `{t:'kicked', reason?}` 프레임 후 `4403` 으로
|
|
399
|
+
닫힌다. 대상 표기는 **`PresenceMember.id` 형식**(`user:<id>` · 익명
|
|
400
|
+
`conn:<uuid>`) — `presenceList` 가 주는 값을 무변환으로 넘긴다. 반환 =
|
|
401
|
+
present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가는 호출
|
|
402
|
+
지점(컨트롤러·서비스)의 앱 인가가 정한다.**
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
// 채팅 모더레이터 강퇴 / 게임 안티치트 축출 — 같은 한 줄
|
|
406
|
+
await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
- 마지막 멤버를 kick 하면 `InstanceClosed` 가 **자동 발화**한다(§2.8 앵커
|
|
410
|
+
그대로 · 특례 없음).
|
|
411
|
+
- 전달은 at-most-once(즉시성 도구) — **영구 차단 보증은 ban 패턴(③)이
|
|
412
|
+
백스톱**이다. kick 은 일시 조치라 대상이 수동 재입장할 수 있다(클라 자동
|
|
413
|
+
재연결은 4403 종단으로 차단됨).
|
|
414
|
+
|
|
415
|
+
**③ ban 은 프리미티브가 아니라 3단 패턴이다(정본).** 저장·만료·범위가
|
|
416
|
+
도메인마다 달라 프레임웍이 정하지 않는다 — DB(정본) + authorize(보증) +
|
|
417
|
+
kick(즉시성):
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
// ① 도메인 기록(정본 = DB) ② 즉시 축출 ③ 재입장 차단(4401)
|
|
421
|
+
await RoomBan.create({ roomId, userId, reason })
|
|
422
|
+
await kick('room', `user:${userId}`, { instance: roomId, reason })
|
|
423
|
+
// channels/room.ts — authorize 가 보증 층이다(kick 유실·재접속을 막는 백스톱)
|
|
424
|
+
async authorize(ctx) {
|
|
425
|
+
const u = ctx.user as { id: bigint } | null
|
|
426
|
+
if (!u) return false
|
|
427
|
+
return !(await RoomBan.where({ roomId: BigInt(ctx.instance), userId: u.id }).exists())
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
익명 멤버(`conn:<uuid>`)는 신원 영속이 없어 ban 이 불가능하다(kick 만
|
|
432
|
+
유효) — 차단이 필요한 채널은 인증을 요구하라(`authorize` 에서 `ctx.user`
|
|
433
|
+
확인).
|
|
434
|
+
|
|
435
|
+
**④ 인스턴스 메타 + 입장순 `joinSeq`.** 메타는 로스터와 함께 노출되는
|
|
436
|
+
**얇은 보조층**(방 규칙·매치 설정 · LWW · 4096B 상한)이다 — **정본은
|
|
437
|
+
DB + authorize** 고, 메타의 존재 이유는 닫힘 시 자동 소멸이다(정적 채널은
|
|
438
|
+
소멸 앵커가 없어 메타 미지원 · 4400):
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
export default channel({
|
|
442
|
+
instance: true,
|
|
443
|
+
instanceMeta(ctx) { // 열림(첫 점유) 시 1회 기록 — 허브가 open 앵커에서만
|
|
444
|
+
return { mode: ctx.query.mode ?? 'ranked', capacity: 4 }
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
await setInstanceMeta('match', { mode: 'casual' }, { instance: '42' }) // 인가된 액션 갱신(LWW)
|
|
449
|
+
const meta = await instanceMeta('match', { instance: '42' }) // 읽기(미설정·닫힘 = null)
|
|
450
|
+
const rooms = await instancesOf('match', { meta: true }) // 로비: [{ instance, meta }]
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
`joinSeq` 는 허브가 부여하는 **단조 입장 순번**이다(로스터 등장 시 1회 ·
|
|
454
|
+
멀티탭 불변 · 완전 이탈 후 재입장 = 새 순번 · failover 에도 보존).
|
|
455
|
+
`presenceList`/`ctx.presence()`/클라 `members` 가 **입장순 정렬을 보장**
|
|
456
|
+
하므로 "가장 먼저 들어온 남은 멤버" = `[0]` 이 전 서버 어디서나 결정적이다.
|
|
457
|
+
|
|
458
|
+
**방장(호스트) 승계는 앱 도메인 패턴이다** — 프레임웍은 결정적 순서만 준다
|
|
459
|
+
("owner" 프리미티브 없음):
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
// 채팅 방장 / 게임 호스트 공통 골격: 메타 owner + 입장순 승격
|
|
463
|
+
export default on(InstanceOpened, async ({ channel, instance }) => {
|
|
464
|
+
if (channel !== 'match') return
|
|
465
|
+
const first = (await presenceList('match', { instance }))[0] // 입장순 정렬 보장
|
|
466
|
+
if (first) await setInstanceMeta('match', { owner: first.id }, { instance })
|
|
467
|
+
})
|
|
468
|
+
// 승계(owner 이탈 감지 시 — 도메인 액션·리스너 어디서든):
|
|
469
|
+
const roster = await presenceList('match', { instance })
|
|
470
|
+
const next = roster[0] // 가장 먼저 들어온 남은 멤버
|
|
471
|
+
if (next) {
|
|
472
|
+
await setInstanceMeta('match', { owner: next.id }, { instance })
|
|
473
|
+
broadcast('match', { type: 'owner-changed', owner: next.id }, { instance })
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
371
477
|
### 3. 프레즌스
|
|
372
478
|
|
|
373
479
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
@@ -445,6 +551,7 @@ export function useRoom(roomId: number) {
|
|
|
445
551
|
| `path` | 자동 | WS 경로 override(탈출구 · 기본은 앱 프리픽스 자동 · 결정 154) |
|
|
446
552
|
| `onMessage(data, frame)` | — | `msg` 프레임 — `messages` 축적과 함께 호출 |
|
|
447
553
|
| `onPresence(members, frame)` | — | 접속자 명단 변화 — 첫 인자는 **갱신된 전체 명단**, 둘째는 트리거한 프레임 |
|
|
554
|
+
| `onKicked(reason)` | — | 강제 퇴장 통지(§2.9 · 결정 444) — 직후 소켓이 `4403` 으로 닫힌다(종단) |
|
|
448
555
|
| `onFrame(frame)` | — | **모든 프레임**에 호출(아래) |
|
|
449
556
|
| `onReconnect()` | — | 재연결 **성공** 시(최초 접속엔 호출 안 됨) |
|
|
450
557
|
|
|
@@ -480,14 +587,16 @@ export function useRoom(roomId: number) {
|
|
|
480
587
|
(Inertia partial reload). 끄려면 `reconnect: false`, 튜닝은
|
|
481
588
|
`reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·수동 `close()` =
|
|
482
589
|
의도적 종료라 재연결하지 않는다.
|
|
483
|
-
- **재연결하지 않는 종단 close code
|
|
484
|
-
코드로 가른다.
|
|
485
|
-
알리고
|
|
590
|
+
- **재연결하지 않는 종단 close code 5종** — 서버 다운(재시도)과 계약 위반·조치(포기)를
|
|
591
|
+
코드로 가른다. 전부 `status` 가 `'closed'` 로 고정되므로, 앱은 이 상태를 사용자에게
|
|
592
|
+
알리고 다음 행동(로그인·재입장·새로고침)을 준다.
|
|
486
593
|
|
|
487
594
|
| code | 언제 | 뒤처리 |
|
|
488
595
|
|---|---|---|
|
|
489
596
|
| `4400` | **인스턴스 계약 위반**(§2.7 · 결정 440 — 인스턴스 채널에 키 누락 · 정적 채널에 키 지정 · 128자 초과) | 재연결 안 함 — 설정 오류. 서버 에러 프레임에 수리 안내가 실린다 |
|
|
490
|
-
| `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 포함) | 재연결 안 함 —
|
|
597
|
+
| `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 · **ban 패턴 차단** 포함) | 재연결 안 함 — 로그인/안내로 유도 |
|
|
598
|
+
| `4403` | **강제 퇴장(kick)**(§2.9 · 결정 444) — 직전에 `{t:'kicked', reason?}` 프레임 + `onKicked` | 재연결 안 함 — 자동 재접속은 kick 을 무효화한다. 수동 `connect()` 재입장은 허용(영구 차단은 ban 패턴) |
|
|
599
|
+
| `4409` | **정원 초과**(§2.9 · 결정 444 — `maxMembers` 허브 원자 판정 거부) | 재연결 안 함 — 만석 dogpile 방지. 자리가 나면 앱 UX 로 재입장(로비 갱신 후 `connect()`) |
|
|
491
600
|
| `4500` | **seal 개봉 실패**(봉인 계약 위반 · 결정 222·318) | 재연결 안 함 — transient 가 아니라 주입/변조/키 불일치. 콘솔에 원인이 찍힌다(`agents/seal.md`) |
|
|
492
601
|
|
|
493
602
|
그 외 코드(서버 재시작·네트워크 blip·핸들러 실패 `1011`)는 전부 자동 재연결 대상이다.
|
|
@@ -648,6 +757,19 @@ export default channel({
|
|
|
648
757
|
- **방 생성/삭제를 허브·채널 훅에서 직접 DB 로 밀지 말 것** — 훅(onJoin/onLeave)은
|
|
649
758
|
연결 단위라 첫/마지막 판정이 서버 로컬에 갇히고(멀티서버에서 틀림), 허브는
|
|
650
759
|
도메인을 모른다. 전역 첫/마지막은 생명주기 이벤트(§2.8)가 정답 경로다.
|
|
760
|
+
훅의 단위 경계 3층: **onJoin/onLeave = 연결(멀티탭이면 연결마다 ·
|
|
761
|
+
`ctx.connectionId` 로 구분) / presence 델타 = 멤버(첫·마지막 연결) /
|
|
762
|
+
InstanceOpened·Closed = 인스턴스(전역 첫 점유·점유 0)**.
|
|
763
|
+
- **`authorize` 안 `presenceList` 로 정원을 검사하지 말 것** — 웹서버 로컬 판정이라
|
|
764
|
+
동시 입장 race 에서 초과 입장이 뚫린다. 정원은 **`maxMembers` 선언**(§2.9 ·
|
|
765
|
+
허브 원자 판정 · 4409)이 정본이다.
|
|
766
|
+
- **kick 만으로 "차단했다" 고 믿지 말 것** — kick 은 at-most-once 즉시성 도구다
|
|
767
|
+
(대상이 수동 재입장 가능·롤링 창 미집행 가능). 영구 차단은 반드시 ban 3단 패턴
|
|
768
|
+
(§2.9 — DB 기록 + `authorize` 차단 + kick 병행)으로 보증한다.
|
|
769
|
+
- **메타에 강일관·민감 데이터 금지** — 인스턴스 메타는 LWW·최종 일관의 얇은
|
|
770
|
+
표시층이다(4096B 상한 · 닫힘 시 소멸). 게임 상태·잔액·비공개 정보는 DB 로
|
|
771
|
+
(정본 = DB + authorize). 클라로 흘리는 메타는 `presenceInfo` 와 같은 공개
|
|
772
|
+
경계다.
|
|
651
773
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
652
774
|
(`agents/testing.md`).
|
|
653
775
|
|
|
@@ -680,6 +802,7 @@ export default channel({
|
|
|
680
802
|
| 결정 439 | `sendToUsers` 주소지정 = per-user subject(§2.6) — 대상 member 의 `gaon.user.<member>` 로 직접 발행(NATS 관심 라우팅) · 비대상 서버 수신 0 · 채널 스코프·인가 계약 불변 |
|
|
681
803
|
| 결정 440 | 파라미터화(인스턴스) 채널(§2.7) — `instance: true` 선언 · identity = `<이름>:<인스턴스>`(Phoenix topic 모델) · 전송 subject·프레즌스·인가·broadcast 네 축 identity 격리 · 역방향 인덱스(`member.<멤버>.<채널>`)로 `presenceOf(member)` 한 번 스캔 · `presenceList(name, {instance})` 서버 개시 로스터 · 선언·연결 부정합 = `4400` fail-loud(재연결 없음) · doctor `channel-instance-authorize` 경고 · 정적 채널은 identity=이름 그대로(와이어·KV 무변경) |
|
|
682
804
|
| 결정 442 | 소켓 앱 DX 표면 완성(§2.8) — 인스턴스 생명주기 이벤트 `InstanceOpened`/`InstanceClosed`(허브 발행 · 활성 인스턴스 인덱스 `chan.<이름>.<인스턴스>` 가 dedup 앵커 · 리스너 durable 공유로 워커 하나만 처리) · `instancesOf(name)` 채널→활성 인스턴스 목록(로비 첫 렌더) · 로비 = 일반 채널 + `broadcast` 패턴(전용 API 신설 없음) · `gaon g channel <이름> [--instance]` 스캐폴드(정의 + 컴포저블) |
|
|
805
|
+
| 결정 444 | 룸 프리미티브 Wave 2(§2.9) — 정원 `maxMembers`(허브 원자 판정 joinAck · 초과 `4409` 종단 · 멤버 수 기준 멀티탭 통과) · `kick(name, member, {instance, reason})`(userSubject 제어 봉투 · `4403` 종단 + `onKicked` · 반환 = present 수 · 마지막 멤버면 closed 자동) · ban = 3단 문서 패턴(DB + authorize + kick · 프리미티브 없음) · 인스턴스 메타(`instanceMeta` 훅 = open 앵커 1회 기록 · `setInstanceMeta` LWW 갱신 · 닫힘 자동 삭제 · 4096B · 정적 채널 미지원) · `joinSeq` 입장 순번(허브 부여 · `presenceList`/`members` 입장순 정렬 보장 · 방장 승계 패턴 기반) · `ctx.connectionId` |
|
|
683
806
|
|
|
684
807
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
685
808
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.1",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
33
|
"typescript": "^5.9.0",
|
|
34
34
|
"vite": "^7.0.0",
|
|
35
|
-
"@gaonjs/async": "0.
|
|
36
|
-
"@gaonjs/config": "0.25.
|
|
37
|
-
"@gaonjs/core": "0.3.0",
|
|
35
|
+
"@gaonjs/async": "0.21.0",
|
|
36
|
+
"@gaonjs/config": "0.25.5",
|
|
38
37
|
"@gaonjs/data": "0.25.3",
|
|
39
38
|
"@gaonjs/i18n": "0.3.1",
|
|
39
|
+
"@gaonjs/core": "0.3.0",
|
|
40
40
|
"@gaonjs/mail": "0.5.1",
|
|
41
|
-
"@gaonjs/web": "0.31.
|
|
41
|
+
"@gaonjs/web": "0.31.1"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
|