@gaonjs/cli 0.58.0 → 0.59.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.
package/dist/index.d.ts
CHANGED
|
@@ -100,5 +100,11 @@ export declare function readPortFlag(argv: readonly string[]): number | undefine
|
|
|
100
100
|
export declare function parseServeArgs(argv: readonly string[]): ServeCommandOptions;
|
|
101
101
|
/** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
|
|
102
102
|
export declare function parseDevArgs(argv: readonly string[]): DevCommandOptions;
|
|
103
|
+
/**
|
|
104
|
+
* 결정 448: 명령 공통 `--help`/`-h` 판정. `gaon test -- <인자>` 의 `--` 뒤는
|
|
105
|
+
* vitest 전달분이라 제외한다(패스스루 계약 유지 — `gaon test -- --help` 는
|
|
106
|
+
* vitest 의 도움말이지 gaon 의 도움말이 아니다).
|
|
107
|
+
*/
|
|
108
|
+
export declare function helpRequested(argv: readonly string[]): boolean;
|
|
103
109
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
104
110
|
export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
|
package/dist/index.js
CHANGED
|
@@ -141,7 +141,7 @@ function renderHelp(version = VERSION) {
|
|
|
141
141
|
" gaon db seed domain/seed.ts 실행 (M8)",
|
|
142
142
|
" gaon --json 같은 정보를 JSON 으로 출력",
|
|
143
143
|
" gaon --version 버전 출력",
|
|
144
|
-
" gaon --help 이 도움말",
|
|
144
|
+
" gaon --help 이 도움말 (어느 명령 뒤에 붙여도 부팅 없이 이 도움말 · 결정 448)",
|
|
145
145
|
"",
|
|
146
146
|
" 문서: " + HOMEPAGE,
|
|
147
147
|
"",
|
|
@@ -360,6 +360,19 @@ export function parseDevArgs(argv) {
|
|
|
360
360
|
host,
|
|
361
361
|
};
|
|
362
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* 결정 448: 명령 공통 `--help`/`-h` 판정. `gaon test -- <인자>` 의 `--` 뒤는
|
|
365
|
+
* vitest 전달분이라 제외한다(패스스루 계약 유지 — `gaon test -- --help` 는
|
|
366
|
+
* vitest 의 도움말이지 gaon 의 도움말이 아니다).
|
|
367
|
+
*/
|
|
368
|
+
export function helpRequested(argv) {
|
|
369
|
+
const sepIdx = argv.indexOf("--");
|
|
370
|
+
const before = (flag) => {
|
|
371
|
+
const i = argv.indexOf(flag);
|
|
372
|
+
return i >= 0 && (sepIdx < 0 || i < sepIdx);
|
|
373
|
+
};
|
|
374
|
+
return before("--help") || before("-h");
|
|
375
|
+
}
|
|
363
376
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
364
377
|
export function runCli(argv, opts = {}) {
|
|
365
378
|
const version = opts.version ?? VERSION;
|
|
@@ -370,6 +383,13 @@ export function runCli(argv, opts = {}) {
|
|
|
370
383
|
// 명령이 추가될 때마다 또 빠지므로 여기서 공통화한다. loadDotEnv 는 이미
|
|
371
384
|
// 설정된 env 를 덮지 않고 파일이 없으면 조용히 지나가 멱등하다(재호출 안전).
|
|
372
385
|
loadDotEnv();
|
|
386
|
+
// 결정 448: `--help`/`-h` 는 **어느 명령에서든** 부팅·실행 대신 도움말을 낸다 —
|
|
387
|
+
// 종전엔 말미(명령 미매치 경로)에서만 처리해 `gaon serve --help`·`gaon hub --help`
|
|
388
|
+
// 가 실제 부팅을 시도했다(redis/NATS 미기동이면 접속 실패로 종료 · 실사용 보고).
|
|
389
|
+
if (helpRequested(argv)) {
|
|
390
|
+
process.stdout.write(renderHelp(version) + "\n");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
373
393
|
// `gaon dev` — 통합 개발 오케스트레이션(M9-C · v0.15 §13.5). Docker Compose
|
|
374
394
|
// 자동 기동 + .gaon 재생성 + serve·work·hub 자식(운영 3종 all-in-one · 결정 211)
|
|
375
395
|
// + tsc/vue-tsc watch + 서버 재시작 워처. SIGINT/SIGTERM 시 순서대로 정리
|
package/dist/scaffold/channel.js
CHANGED
|
@@ -39,6 +39,9 @@ export function channelScaffold(rawName, app, instance) {
|
|
|
39
39
|
` instance: true,`,
|
|
40
40
|
` // 인스턴스 단위 입장 판정 — ctx.instance 가 인스턴스 키(예: 매치 id·게시물 id)다.`,
|
|
41
41
|
` // 참가자 검증이 필요하면 DB 로 확인한다(예: 매치 참가자 테이블 조회).`,
|
|
42
|
+
` // 함정(결정 447): 숫자 키를 DB id 로 파싱해 검증한다면 **정규형 비교**까지 —`,
|
|
43
|
+
` // BigInt("042")·BigInt("0x2a") 도 42 라서, String(id) !== ctx.instance 면 거부해야`,
|
|
44
|
+
` // 같은 방의 유령 인스턴스(로스터에 안 보이는 우회 접속)를 막는다(agents/realtime.md §2.7).`,
|
|
42
45
|
` authorize(ctx) {`,
|
|
43
46
|
` return ctx.user != null // 로그인 사용자만 — 공개 관전형이면 이 훅을 지운다`,
|
|
44
47
|
` },`,
|
|
@@ -209,10 +209,19 @@ import { MatchPlayer } from '../../../domain/models/MatchPlayer.js'
|
|
|
209
209
|
export default channel({
|
|
210
210
|
instance: true, // ← 파라미터화 선언
|
|
211
211
|
async authorize(ctx) {
|
|
212
|
-
// ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만)
|
|
212
|
+
// ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만).
|
|
213
|
+
// 결정 447: 숫자 키는 **정규형 비교까지** — BigInt("042")·BigInt("0x2a") 도 42 라서
|
|
214
|
+
// 파싱만 하면 같은 매치의 "유령 인스턴스"(격리는 따로, DB 는 같은 방)가 열린다.
|
|
213
215
|
const u = ctx.user as { id: bigint } | null
|
|
214
216
|
if (!u) return false
|
|
215
|
-
|
|
217
|
+
let matchId: bigint
|
|
218
|
+
try {
|
|
219
|
+
matchId = BigInt(ctx.instance)
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
if (String(matchId) !== ctx.instance) return false // 비정규 표기("042"·"0x2a"·공백) 거부
|
|
224
|
+
return await MatchPlayer.where({ matchId, userId: u.id }).exists()
|
|
216
225
|
},
|
|
217
226
|
onMessage(ctx, data) {
|
|
218
227
|
// ctx.broadcast 는 자기 인스턴스(match:<id>)로 자동 스코프 — 다른 매치는 못 받는다
|
|
@@ -284,6 +293,13 @@ const draft = ref('')
|
|
|
284
293
|
- **인스턴스 키는 임의 문자열이다**(128자 이하) — 매치 id·게시물 id 같은
|
|
285
294
|
식별자를 그대로 쓴다. subject/KV 에는 identity 전체가 base64url 로 인코딩돼
|
|
286
295
|
들어가므로 특수문자 주입 걱정이 없다.
|
|
296
|
+
- **숫자 키를 DB id 로 파싱해 검증한다면 정규형 비교까지(결정 447).** 격리는
|
|
297
|
+
인스턴스 **문자열** 단위인데 `BigInt()`/`Number()` 파싱은 `"042"`·`"0x2a"`·
|
|
298
|
+
`" 42"` 를 전부 42 로 받으므로, 파싱 성공만 검사하면 인증 사용자가 같은 방의
|
|
299
|
+
**유령 인스턴스**를 열 수 있다 — 로스터·라이브에는 안 보이면서 `onMessage` 가
|
|
300
|
+
같은 DB row(방 42)에 기록을 남긴다. 위 match 예시처럼 파싱 후
|
|
301
|
+
`String(id) !== ctx.instance → false` 로 정규형만 통과시킨다(uuid·slug 처럼
|
|
302
|
+
문자열 그대로 조회하는 키는 해당 없음).
|
|
287
303
|
- **`ctx.instance` 는 타입으로 갈린다** — `instance: true` 채널의 훅에서는
|
|
288
304
|
`string`(항상 존재), 정적 채널에서는 `undefined`(string 으로 쓰면 컴파일 에러).
|
|
289
305
|
- **`presenceOf(member)` 는 역방향 조회다** — 멤버(`user:<id>`·`conn:<uuid>`)가
|
|
@@ -353,7 +369,11 @@ import { instancesOf } from 'gaonjs/async'
|
|
|
353
369
|
export default controller({
|
|
354
370
|
async show() {
|
|
355
371
|
// 영속 방이면 DB(Room.all())가, 일시적 방이면 instancesOf 가 첫 목록이다.
|
|
356
|
-
|
|
372
|
+
// 결정 446: 인원 수·메타가 필요하면 옵션으로 동봉한다 — 방마다 presenceList
|
|
373
|
+
// 를 따로 부르는 N+1 을 만들지 말 것(counts 는 프레즌스 키 단일 스캔).
|
|
374
|
+
const rooms = await instancesOf('match', { counts: true })
|
|
375
|
+
// rooms = [{ instance: '42', members: 3 }, …] · { meta: true, counts: true } 조합도 된다
|
|
376
|
+
return this.render('Lobby', { rooms })
|
|
357
377
|
},
|
|
358
378
|
})
|
|
359
379
|
```
|
|
@@ -368,6 +388,154 @@ export default channel({})
|
|
|
368
388
|
`room-closed` 를 반영한다 — 리스너의 `broadcast('lobby', …)` 단일 발행이 전
|
|
369
389
|
서버 로비 구독자에게 팬아웃된다(§2.5).
|
|
370
390
|
|
|
391
|
+
### 2.9 룸 프리미티브 — 정원·kick·메타·입장순·멤버 이탈 (결정 444·445)
|
|
392
|
+
|
|
393
|
+
인스턴스 채널(§2.7) 위의 "방 운영" 표면 5종. 전부 도메인 중립이다 — 채팅
|
|
394
|
+
(모더레이터 강퇴·방 인원 제한·방 규칙·방장 승계)과 게임(안티치트 축출·매치
|
|
395
|
+
정원·매치 설정·호스트 승계)이 같은 프리미티브를 쓴다.
|
|
396
|
+
|
|
397
|
+
**① 정원 `maxMembers` — 허브 원자 판정.** 정의에 선언하면 초과 입장이
|
|
398
|
+
`4409` 로 거부된다(허브가 전역 로스터 단일 권위로 **동시 입장 race 없이**
|
|
399
|
+
판정 · joinAck 프로토콜). **멤버 수 기준**이다 — 기존 멤버의 멀티탭 추가
|
|
400
|
+
연결은 정원 무관 통과(결정 225 축). 함수형이면 접속 시점마다 평가돼 방별
|
|
401
|
+
동적 정원이 된다:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
// 채팅방 — 고정 인원 제한
|
|
405
|
+
export default channel({ instance: true, maxMembers: 100 })
|
|
406
|
+
|
|
407
|
+
// 게임 매치 — 방별 정원(메타·DB 조회)
|
|
408
|
+
export default channel({
|
|
409
|
+
instance: true,
|
|
410
|
+
async maxMembers(ctx) {
|
|
411
|
+
const meta = await instanceMeta('match', { instance: ctx.instance })
|
|
412
|
+
return typeof meta?.capacity === 'number' ? meta.capacity : 4
|
|
413
|
+
},
|
|
414
|
+
})
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**② kick — 강제 퇴장(서버 전용 표면).** 대상 멤버의 그 인스턴스 연결
|
|
418
|
+
전부(멀티탭·멀티서버)가 `{t:'kicked', reason?}` 프레임 후 `4403` 으로
|
|
419
|
+
닫힌다. 대상 표기는 **`PresenceMember.id` 형식**(`user:<id>` · 익명
|
|
420
|
+
`conn:<uuid>`) — `presenceList` 가 주는 값을 무변환으로 넘긴다. 반환 =
|
|
421
|
+
present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가는 호출
|
|
422
|
+
지점(컨트롤러·서비스)의 앱 인가가 정한다.**
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
// 채팅 모더레이터 강퇴 / 게임 안티치트 축출 — 같은 한 줄
|
|
426
|
+
await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
- 마지막 멤버를 kick 하면 `InstanceClosed` 가 **자동 발화**한다(§2.8 앵커
|
|
430
|
+
그대로 · 특례 없음).
|
|
431
|
+
- 전달은 at-most-once(즉시성 도구) — **영구 차단 보증은 ban 패턴(③)이
|
|
432
|
+
백스톱**이다. kick 은 일시 조치라 대상이 수동 재입장할 수 있다(클라 자동
|
|
433
|
+
재연결은 4403 종단으로 차단됨).
|
|
434
|
+
|
|
435
|
+
**③ ban 은 프리미티브가 아니라 3단 패턴이다(정본).** 저장·만료·범위가
|
|
436
|
+
도메인마다 달라 프레임웍이 정하지 않는다 — DB(정본) + authorize(보증) +
|
|
437
|
+
kick(즉시성):
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// ① 도메인 기록(정본 = DB) ② 즉시 축출 ③ 재입장 차단(4401)
|
|
441
|
+
await RoomBan.create({ roomId, userId, reason })
|
|
442
|
+
await kick('room', `user:${userId}`, { instance: roomId, reason })
|
|
443
|
+
// channels/room.ts — authorize 가 보증 층이다(kick 유실·재접속을 막는 백스톱)
|
|
444
|
+
async authorize(ctx) {
|
|
445
|
+
const u = ctx.user as { id: bigint } | null
|
|
446
|
+
if (!u) return false
|
|
447
|
+
let roomId: bigint
|
|
448
|
+
try {
|
|
449
|
+
roomId = BigInt(ctx.instance)
|
|
450
|
+
} catch {
|
|
451
|
+
return false
|
|
452
|
+
}
|
|
453
|
+
if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
|
|
454
|
+
return !(await RoomBan.where({ roomId, userId: u.id }).exists())
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
익명 멤버(`conn:<uuid>`)는 신원 영속이 없어 ban 이 불가능하다(kick 만
|
|
459
|
+
유효) — 차단이 필요한 채널은 인증을 요구하라(`authorize` 에서 `ctx.user`
|
|
460
|
+
확인).
|
|
461
|
+
|
|
462
|
+
**④ 인스턴스 메타 + 입장순 `joinSeq`.** 메타는 로스터와 함께 노출되는
|
|
463
|
+
**얇은 보조층**(방 규칙·매치 설정 · LWW · 4096B 상한)이다 — **정본은
|
|
464
|
+
DB + authorize** 고, 메타의 존재 이유는 닫힘 시 자동 소멸이다(정적 채널은
|
|
465
|
+
소멸 앵커가 없어 메타 미지원 · 4400):
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
export default channel({
|
|
469
|
+
instance: true,
|
|
470
|
+
instanceMeta(ctx) { // 열림(첫 점유) 시 1회 기록 — 허브가 open 앵커에서만
|
|
471
|
+
return { mode: ctx.query.mode ?? 'ranked', capacity: 4 }
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
await setInstanceMeta('match', { mode: 'casual' }, { instance: '42' }) // 인가된 액션 갱신(LWW)
|
|
476
|
+
const meta = await instanceMeta('match', { instance: '42' }) // 읽기(미설정·닫힘 = null)
|
|
477
|
+
const rooms = await instancesOf('match', { meta: true }) // 로비: [{ instance, meta }]
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
`joinSeq` 는 허브가 부여하는 **단조 입장 순번**이다(로스터 등장 시 1회 ·
|
|
481
|
+
멀티탭 불변 · 완전 이탈 후 재입장 = 새 순번 · failover 에도 보존).
|
|
482
|
+
`presenceList`/`ctx.presence()`/클라 `members` 가 **입장순 정렬을 보장**
|
|
483
|
+
하므로 "가장 먼저 들어온 남은 멤버" = `[0]` 이 전 서버 어디서나 결정적이다.
|
|
484
|
+
|
|
485
|
+
**⑤ `MemberLeft` — 멤버 이탈의 race-free 앵커(결정 445).** 인스턴스 채널에서
|
|
486
|
+
멤버가 **완전히 떠나면**(마지막 연결 소멸 — 멀티탭 중간 탭 닫힘은 침묵 · 서버
|
|
487
|
+
프로세스 死 포함) 허브가 **로스터에서 제거한 뒤** 발행한다. 페이로드에 "누가
|
|
488
|
+
나갔는가 + **제거가 반영된** 잔존 로스터(입장순)"가 실리므로, "떠난 뒤의
|
|
489
|
+
로스터로 한 곳에서 결정" 하는 로직의 정본 앵커다. `InstanceOpened/Closed` 와
|
|
490
|
+
같은 전달(워커 하나만 처리 · at-least-once — 핸들러 멱등):
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
494
|
+
// roster = 제거 반영 후 잔존 멤버 [{ id, joinSeq? }] · joinSeq 오름차순.
|
|
495
|
+
// 빈 배열 = 마지막 이탈(InstanceClosed 도 발행되지만 컨슈머 축이 달라
|
|
496
|
+
// 상호 순서는 보장 없음 — 빈 로스터 자체를 마지막 이탈 신호로 쓰라).
|
|
497
|
+
})
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
- **채널 `onLeave` 안 `ctx.presence()` 로 이탈 후 로스터를 읽지 말 것** — leave
|
|
501
|
+
는 훅 앞에서 **발신**될 뿐 KV 반영은 허브 비동기라, 떠나는 멤버 본인이 아직
|
|
502
|
+
로스터에 보일 수 있다(로컬에선 대개 허브가 이겨 테스트는 통과하고, 부하·순단
|
|
503
|
+
에서 뒤집히는 전형적 race — rooms 샘플 실측). 게다가 웹서버가 통째로 죽으면
|
|
504
|
+
`onLeave` 는 아예 돌지 않는다. 둘 다 `MemberLeft` 가 닫는다.
|
|
505
|
+
- **발화는 이탈만·인스턴스 채널만이다** — 입장 후 로직은 `onJoin`(연결)·
|
|
506
|
+
`InstanceOpened`(첫 점유)가 담당하고, 정적 채널은 전역 churn 고빈도 + 소멸
|
|
507
|
+
앵커 부재로 발화하지 않는다(결정 442 스코프 동형).
|
|
508
|
+
|
|
509
|
+
**방장(호스트) 승계는 앱 도메인 패턴이다** — 프레임웍은 결정적 순서(joinSeq)와
|
|
510
|
+
race-free 앵커(MemberLeft)만 준다("owner" 프리미티브 없음):
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
// domain/listeners/onRoomMemberLeft.ts — 채팅 방장 / 게임 호스트 공통 골격.
|
|
514
|
+
// 워커 하나만 처리하므로 멀티서버 동시 이탈에도 승계가 한 곳에서 결정된다.
|
|
515
|
+
import { on, MemberLeft, broadcast } from 'gaonjs/async'
|
|
516
|
+
import { Room } from '../models/Room.js'
|
|
517
|
+
|
|
518
|
+
export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
519
|
+
if (channel !== 'room') return
|
|
520
|
+
if (roster.length === 0) return // 마지막 이탈 — 닫힘은 InstanceClosed 몫
|
|
521
|
+
const room = await Room.where('key', '=', instance).first()
|
|
522
|
+
if (!room) return
|
|
523
|
+
if (roster.some((m) => m.id === `user:${String(room.ownerId)}`)) return // 방장 건재
|
|
524
|
+
const next = roster[0] // 가장 먼저 들어온 남은 멤버(입장순 보장)
|
|
525
|
+
if (!next.id.startsWith('user:')) return
|
|
526
|
+
// 조건부 갱신(멱등) — at-least-once 재전달·경합에서 두 번 승격되지 않는다.
|
|
527
|
+
const changed = await Room.where('id', '=', room.id)
|
|
528
|
+
.where('ownerId', '=', room.ownerId)
|
|
529
|
+
.updateAll({ ownerId: BigInt(next.id.slice('user:'.length)) })
|
|
530
|
+
if (changed > 0) broadcast('room', { type: 'owner-changed', owner: next.id }, { instance })
|
|
531
|
+
})
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
영속 방(위 예시)은 **DB 컬럼(rooms.ownerId)이 owner 의 정본**이다 — 메타
|
|
535
|
+
(`setInstanceMeta({ owner })`)는 닫힘 시 소멸하는 표시층이라, 방 row 가 남는
|
|
536
|
+
도메인이면 DB 로 두고 메타는 로비 표시 등에만 쓴다. 일시적 방(row 없는 매치)
|
|
537
|
+
이면 메타 owner 로 충분하다.
|
|
538
|
+
|
|
371
539
|
### 3. 프레즌스
|
|
372
540
|
|
|
373
541
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
@@ -445,6 +613,7 @@ export function useRoom(roomId: number) {
|
|
|
445
613
|
| `path` | 자동 | WS 경로 override(탈출구 · 기본은 앱 프리픽스 자동 · 결정 154) |
|
|
446
614
|
| `onMessage(data, frame)` | — | `msg` 프레임 — `messages` 축적과 함께 호출 |
|
|
447
615
|
| `onPresence(members, frame)` | — | 접속자 명단 변화 — 첫 인자는 **갱신된 전체 명단**, 둘째는 트리거한 프레임 |
|
|
616
|
+
| `onKicked(reason)` | — | 강제 퇴장 통지(§2.9 · 결정 444) — 직후 소켓이 `4403` 으로 닫힌다(종단) |
|
|
448
617
|
| `onFrame(frame)` | — | **모든 프레임**에 호출(아래) |
|
|
449
618
|
| `onReconnect()` | — | 재연결 **성공** 시(최초 접속엔 호출 안 됨) |
|
|
450
619
|
|
|
@@ -480,14 +649,16 @@ export function useRoom(roomId: number) {
|
|
|
480
649
|
(Inertia partial reload). 끄려면 `reconnect: false`, 튜닝은
|
|
481
650
|
`reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·수동 `close()` =
|
|
482
651
|
의도적 종료라 재연결하지 않는다.
|
|
483
|
-
- **재연결하지 않는 종단 close code
|
|
484
|
-
코드로 가른다.
|
|
485
|
-
알리고
|
|
652
|
+
- **재연결하지 않는 종단 close code 5종** — 서버 다운(재시도)과 계약 위반·조치(포기)를
|
|
653
|
+
코드로 가른다. 전부 `status` 가 `'closed'` 로 고정되므로, 앱은 이 상태를 사용자에게
|
|
654
|
+
알리고 다음 행동(로그인·재입장·새로고침)을 준다.
|
|
486
655
|
|
|
487
656
|
| code | 언제 | 뒤처리 |
|
|
488
657
|
|---|---|---|
|
|
489
658
|
| `4400` | **인스턴스 계약 위반**(§2.7 · 결정 440 — 인스턴스 채널에 키 누락 · 정적 채널에 키 지정 · 128자 초과) | 재연결 안 함 — 설정 오류. 서버 에러 프레임에 수리 안내가 실린다 |
|
|
490
|
-
| `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 포함) | 재연결 안 함 —
|
|
659
|
+
| `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 · **ban 패턴 차단** 포함) | 재연결 안 함 — 로그인/안내로 유도 |
|
|
660
|
+
| `4403` | **강제 퇴장(kick)**(§2.9 · 결정 444) — 직전에 `{t:'kicked', reason?}` 프레임 + `onKicked` | 재연결 안 함 — 자동 재접속은 kick 을 무효화한다. 수동 `connect()` 재입장은 허용(영구 차단은 ban 패턴) |
|
|
661
|
+
| `4409` | **정원 초과**(§2.9 · 결정 444 — `maxMembers` 허브 원자 판정 거부) | 재연결 안 함 — 만석 dogpile 방지. 자리가 나면 앱 UX 로 재입장(로비 갱신 후 `connect()`) |
|
|
491
662
|
| `4500` | **seal 개봉 실패**(봉인 계약 위반 · 결정 222·318) | 재연결 안 함 — transient 가 아니라 주입/변조/키 불일치. 콘솔에 원인이 찍힌다(`agents/seal.md`) |
|
|
492
663
|
|
|
493
664
|
그 외 코드(서버 재시작·네트워크 blip·핸들러 실패 `1011`)는 전부 자동 재연결 대상이다.
|
|
@@ -648,6 +819,26 @@ export default channel({
|
|
|
648
819
|
- **방 생성/삭제를 허브·채널 훅에서 직접 DB 로 밀지 말 것** — 훅(onJoin/onLeave)은
|
|
649
820
|
연결 단위라 첫/마지막 판정이 서버 로컬에 갇히고(멀티서버에서 틀림), 허브는
|
|
650
821
|
도메인을 모른다. 전역 첫/마지막은 생명주기 이벤트(§2.8)가 정답 경로다.
|
|
822
|
+
훅의 단위 경계 3층 + 멤버층 서버 이벤트: **onJoin/onLeave = 연결(멀티탭이면
|
|
823
|
+
연결마다 · `ctx.connectionId` 로 구분) / presence 델타 = 멤버(첫·마지막 연결 ·
|
|
824
|
+
클라 통지) / `MemberLeft` = 멤버 이탈의 서버측 앵커(§2.9 ⑤ · 결정 445 ·
|
|
825
|
+
로스터 반영 후 · 워커 1회 처리) / InstanceOpened·Closed = 인스턴스(전역 첫
|
|
826
|
+
점유·점유 0)**.
|
|
827
|
+
- **이탈 후 로스터 판정을 `onLeave` + `ctx.presence()` 로 조립하지 말 것(결정 445)** —
|
|
828
|
+
leave 는 훅 앞에서 발신될 뿐 KV 반영은 허브 비동기라 이탈자 본인이 로스터에
|
|
829
|
+
잔존할 수 있고(로컬에선 대개 통과하다 부하·순단에서 뒤집히는 race), 서버
|
|
830
|
+
프로세스 死 경로에서는 훅이 아예 안 돈다. 방장 승계처럼 "떠난 뒤의 로스터로
|
|
831
|
+
결정" 하는 로직은 `on(MemberLeft, …)` 리스너가 정본이다(§2.9 ⑤).
|
|
832
|
+
- **`authorize` 안 `presenceList` 로 정원을 검사하지 말 것** — 웹서버 로컬 판정이라
|
|
833
|
+
동시 입장 race 에서 초과 입장이 뚫린다. 정원은 **`maxMembers` 선언**(§2.9 ·
|
|
834
|
+
허브 원자 판정 · 4409)이 정본이다.
|
|
835
|
+
- **kick 만으로 "차단했다" 고 믿지 말 것** — kick 은 at-most-once 즉시성 도구다
|
|
836
|
+
(대상이 수동 재입장 가능·롤링 창 미집행 가능). 영구 차단은 반드시 ban 3단 패턴
|
|
837
|
+
(§2.9 — DB 기록 + `authorize` 차단 + kick 병행)으로 보증한다.
|
|
838
|
+
- **메타에 강일관·민감 데이터 금지** — 인스턴스 메타는 LWW·최종 일관의 얇은
|
|
839
|
+
표시층이다(4096B 상한 · 닫힘 시 소멸). 게임 상태·잔액·비공개 정보는 DB 로
|
|
840
|
+
(정본 = DB + authorize). 클라로 흘리는 메타는 `presenceInfo` 와 같은 공개
|
|
841
|
+
경계다.
|
|
651
842
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
652
843
|
(`agents/testing.md`).
|
|
653
844
|
|
|
@@ -680,6 +871,11 @@ export default channel({
|
|
|
680
871
|
| 결정 439 | `sendToUsers` 주소지정 = per-user subject(§2.6) — 대상 member 의 `gaon.user.<member>` 로 직접 발행(NATS 관심 라우팅) · 비대상 서버 수신 0 · 채널 스코프·인가 계약 불변 |
|
|
681
872
|
| 결정 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
873
|
| 결정 442 | 소켓 앱 DX 표면 완성(§2.8) — 인스턴스 생명주기 이벤트 `InstanceOpened`/`InstanceClosed`(허브 발행 · 활성 인스턴스 인덱스 `chan.<이름>.<인스턴스>` 가 dedup 앵커 · 리스너 durable 공유로 워커 하나만 처리) · `instancesOf(name)` 채널→활성 인스턴스 목록(로비 첫 렌더) · 로비 = 일반 채널 + `broadcast` 패턴(전용 API 신설 없음) · `gaon g channel <이름> [--instance]` 스캐폴드(정의 + 컴포저블) |
|
|
874
|
+
| 결정 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` |
|
|
875
|
+
| 결정 445 | `MemberLeft` 멤버 이탈 이벤트(§2.9 ⑤) — 허브가 로스터 **제거 후** 발행(제거 반영 로스터 동봉 · 입장순) · 마지막 연결에서만(멀티탭 침묵) · graceful·synced 대사·서버 死(cleanupServer)·failover 회수 전 경로 단일 수렴점(delMember) · 인스턴스 채널 한정·이탈만(444(F) 볼륨 우려 수용) · 워커 1회 처리(442 전달 동형) · 방장 승계 정본 앵커("onLeave + ctx.presence() 재조회" 패턴은 leave 발신≠반영 race + 서버 死 미커버로 반정본 명문화 — rooms 샘플 실측) |
|
|
876
|
+
| 결정 446 | `instancesOf(name, { counts: true })`(§2.8) — 인스턴스별 **멤버 수** 동봉(프레즌스 키 단일 스캔 · presenceStats 동형) · `{ meta: true, counts: true }` 조합 지원 · 로비 인원 수 per-인스턴스 `presenceList` N+1 제거 · 기존 시그니처 불변 |
|
|
877
|
+
| 결정 447 | 인스턴스 키 정규형 비교 정본화(§2.7·§2.9 ban) — 숫자 키를 `BigInt()`/`Number()` 로 파싱해 검증하는 authorize 는 `String(id) !== ctx.instance → false` 정규형 가드까지(비정규 표기 "042"·"0x2a" 가 로스터에 안 보이는 유령 인스턴스로 같은 DB row 에 기록을 남기는 우회 차단) · 프레임웍 자동 정규화 기각(키는 앱 정의 불투명 문자열 — uuid·slug 는 파싱 무관) |
|
|
878
|
+
| 결정 448 | CLI 명령 공통 `--help`(`packages/cli`) — `gaon serve --help`·`gaon hub --help` 가 부팅을 시도하던 것을 디스패치 전 공통 처리로 봉합 · `gaon test -- <인자>` 의 `--` 뒤는 패스스루 보존 |
|
|
683
879
|
|
|
684
880
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
685
881
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
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.4",
|
|
35
|
+
"@gaonjs/async": "0.22.0",
|
|
37
36
|
"@gaonjs/core": "0.3.0",
|
|
38
|
-
"@gaonjs/
|
|
37
|
+
"@gaonjs/config": "0.25.6",
|
|
39
38
|
"@gaonjs/i18n": "0.3.1",
|
|
39
|
+
"@gaonjs/data": "0.25.3",
|
|
40
40
|
"@gaonjs/mail": "0.5.1",
|
|
41
|
-
"@gaonjs/web": "0.31.
|
|
41
|
+
"@gaonjs/web": "0.31.2"
|
|
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})\""
|