@gaonjs/async 0.1.2 → 0.2.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.
@@ -0,0 +1,7 @@
1
+ import { type GaonNats } from '../nats.js';
2
+ export declare const TEST_NATS_URL: string;
3
+ export declare function createTestNats(name?: string): Promise<GaonNats>;
4
+ /** 충돌 없는 버킷/키 프리픽스. */
5
+ export declare function uniqueName(prefix: string): string;
6
+ /** KV 버킷(스트림 KV_<bucket>)을 삭제해 테스트 잔여물을 정리한다. */
7
+ export declare function dropBucket(nats: GaonNats, bucket: string): Promise<void>;
@@ -0,0 +1,21 @@
1
+ // 실 NATS 테스트 인프라 (CLAUDE.md §9 — 목업·인메모리 금지).
2
+ // compose.yaml 의 nats(4223, JetStream)에 붙는다. mega-nats(4222) 회피용
3
+ // 전용 포트라 GAON_TEST_NATS_URL 로 재지정한다.
4
+ //
5
+ // 격리: NATS KV 는 트랜잭션이 없으므로, 테스트마다 고유 버킷/subject
6
+ // 프리픽스를 쓰고 끝나면 스트림을 삭제해 정리한다.
7
+ import { connectNats } from '../nats.js';
8
+ import { jetstreamManager } from '@nats-io/jetstream';
9
+ export const TEST_NATS_URL = process.env.GAON_TEST_NATS_URL ?? 'nats://127.0.0.1:4223';
10
+ export async function createTestNats(name) {
11
+ return connectNats({ servers: TEST_NATS_URL, name: name ?? 'gaon-test' });
12
+ }
13
+ /** 충돌 없는 버킷/키 프리픽스. */
14
+ export function uniqueName(prefix) {
15
+ return `${prefix}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
16
+ }
17
+ /** KV 버킷(스트림 KV_<bucket>)을 삭제해 테스트 잔여물을 정리한다. */
18
+ export async function dropBucket(nats, bucket) {
19
+ const jsm = await jetstreamManager(nats.nc);
20
+ await jsm.streams.delete(`KV_${bucket}`).catch(() => { });
21
+ }
@@ -0,0 +1,52 @@
1
+ /** 연결 인가/프레즌스 메타 계산 시점의 컨텍스트(소켓 attach 전). */
2
+ export interface ChannelAuthContext<User = unknown> {
3
+ /** 채널 이름(파일명 관례). */
4
+ readonly channel: string;
5
+ /** 세션 인증 사용자(M5). 비로그인 연결이면 null. */
6
+ readonly user: User | null;
7
+ /** 연결이 보낸 쿼리 파라미터(예: ?room=42). */
8
+ readonly query: Readonly<Record<string, string>>;
9
+ }
10
+ /** 접속자 목록에 실리는 한 멤버. */
11
+ export interface PresenceMember {
12
+ /** 안정적 멤버 식별자(로그인=user:<id>, 익명=conn:<uuid>). */
13
+ readonly id: string;
14
+ /** presenceInfo 로 노출한 공개 메타(이름 등). */
15
+ readonly info: Record<string, unknown>;
16
+ }
17
+ /** 연결된 소켓 하나의 런타임 컨텍스트(onJoin/onMessage/onLeave). */
18
+ export interface ChannelContext<User = unknown> extends ChannelAuthContext<User> {
19
+ /** 이 연결의 멤버 식별자. */
20
+ readonly member: string;
21
+ /** 이 연결에만 전송. */
22
+ send(data: unknown): void;
23
+ /** 채널 전체(모든 서버의 모든 연결)로 브로드캐스트. */
24
+ broadcast(data: unknown): void;
25
+ /** 현재 채널 접속자 목록(허브 권위 · 전 서버 동기화). */
26
+ presence(): Promise<PresenceMember[]>;
27
+ }
28
+ /** 채널 정의 — 전 훅 선택. */
29
+ export interface ChannelDef<User = unknown> {
30
+ /**
31
+ * 연결 인가. false 를 반환하면 연결을 거부한다(4401 close).
32
+ * 생략 시 모두 허용(공개 채널).
33
+ */
34
+ authorize?(ctx: ChannelAuthContext<User>): boolean | Promise<boolean>;
35
+ /**
36
+ * 접속자 목록에 노출할 공개 메타를 만든다(민감 정보 제외 — §4.2 경계).
37
+ * 생략 시 빈 객체.
38
+ */
39
+ presenceInfo?(ctx: ChannelAuthContext<User>): Record<string, unknown>;
40
+ /** 참여(연결 수립·프레즌스 등록 후). */
41
+ onJoin?(ctx: ChannelContext<User>): void | Promise<void>;
42
+ /** 클라이언트 메시지 수신. */
43
+ onMessage?(ctx: ChannelContext<User>, data: unknown): void | Promise<void>;
44
+ /** 이탈(연결 종료·프레즌스 해제 후). */
45
+ onLeave?(ctx: ChannelContext<User>): void | Promise<void>;
46
+ }
47
+ /**
48
+ * 채널을 정의한다. 런타임 동작은 없고 정의 객체를 그대로 돌려주는
49
+ * 타입 앵커다(model()/controller() 와 같은 헬퍼 패턴). 파일의 default
50
+ * export 로 두면 websocket 런타임이 파일명을 채널 이름으로 등록한다.
51
+ */
52
+ export declare function channel<User = unknown>(def: ChannelDef<User>): ChannelDef<User>;
@@ -0,0 +1,17 @@
1
+ // @gaonjs/async · 채널 정의 API (§7 실시간 · 질문 19)
2
+ //
3
+ // 채널은 `apps/<app>/channels/<이름>.ts` 파일 하나로 정의한다 —
4
+ // 파일 존재 = 등록(다른 배터리와 동일 관례). 정의는 `channel(def)`
5
+ // 헬퍼 + 설정 객체 스타일이다(클래스·데코레이터 금지 — CLAUDE.md §1).
6
+ //
7
+ // 브로드캐스트는 NATS pub/sub 을 타므로 다중 인스턴스에서 별도 어댑터가
8
+ // 필요 없다(§7 line 891). 프레즌스(접속자 동기화)는 presence.ts 가
9
+ // 이 컨텍스트에 얹는다.
10
+ /**
11
+ * 채널을 정의한다. 런타임 동작은 없고 정의 객체를 그대로 돌려주는
12
+ * 타입 앵커다(model()/controller() 와 같은 헬퍼 패턴). 파일의 default
13
+ * export 로 두면 websocket 런타임이 파일명을 채널 이름으로 등록한다.
14
+ */
15
+ export function channel(def) {
16
+ return def;
17
+ }
package/dist/hub.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { GaonNats } from './nats.js';
2
+ export interface HubOptions {
3
+ readonly nats: GaonNats;
4
+ /** 인스턴스 식별자(리스 값). */
5
+ readonly id: string;
6
+ /** 리스 TTL(ms). 기본 5000. */
7
+ readonly ttlMs?: number;
8
+ /** 멤버 만료 임계(ms) — 이 시간 넘게 하트비트 없으면 leave 처리. 기본 30000. */
9
+ readonly memberTimeoutMs?: number;
10
+ /** 만료 스윕 주기(ms). 기본 5000. */
11
+ readonly sweepMs?: number;
12
+ /** 리더십·상태 통지(옵션). */
13
+ onState?(state: {
14
+ leader: boolean;
15
+ }): void;
16
+ }
17
+ export interface HubHandle {
18
+ readonly isLeader: boolean;
19
+ stop(): Promise<void>;
20
+ }
21
+ /**
22
+ * 허브를 시작한다. 리더가 되기 전에는 대기하고, 리더가 되면 프레즌스
23
+ * 명령 구독·KV 권위 갱신·델타 중계·만료 스윕을 켠다.
24
+ */
25
+ export declare function runHub(opts: HubOptions): Promise<HubHandle>;
package/dist/hub.js ADDED
@@ -0,0 +1,143 @@
1
+ // @gaonjs/async · 허브 서버 (§7 실시간 · 질문 19)
2
+ //
3
+ // 허브는 접속자 목록의 **단일 권위이자 중계**다. 웹서버들이 보내는
4
+ // 프레즌스 명령(join/leave/heartbeat)을 받아 **NATS KV 에 영속**하고(허브만
5
+ // KV 를 쓴다), 변경 델타를 전 웹서버로 브로드캐스트한다. HA 는 스케줄러와
6
+ // 동일한 **리스 기반 리더 선출**(active-standby) — 리더만 명령을 처리한다.
7
+ //
8
+ // - 재시작 복원: 리더가 되면 KV 의 기존 로스터로 생존 추적을 재구축한다.
9
+ // 접속자 목록 자체는 KV 에 남아 있으므로 허브가 죽어도 유실되지 않는다
10
+ // (웹서버의 list() 는 KV 를 직접 읽는다).
11
+ // - 생존 추적: 웹서버가 죽어 하트비트가 끊긴 멤버는 임계 시간 후 만료시켜
12
+ // leave 로 처리한다(§7 line 898).
13
+ import { leaseLeader } from './lease.js';
14
+ import { PRESENCE_BUCKET } from './presence.js';
15
+ import { presenceKey, parsePresenceKey } from './keys.js';
16
+ import { HUB_PRESENCE_SUBJECT, presenceEventSubject, encodeJson, decodeJson, } from './protocol.js';
17
+ /** now 주입 없이 테스트 결정성을 위해 Date.now 를 감싼다. */
18
+ const now = () => Date.now();
19
+ /**
20
+ * 허브를 시작한다. 리더가 되기 전에는 대기하고, 리더가 되면 프레즌스
21
+ * 명령 구독·KV 권위 갱신·델타 중계·만료 스윕을 켠다.
22
+ */
23
+ export async function runHub(opts) {
24
+ const memberTimeoutMs = opts.memberTimeoutMs ?? 30000;
25
+ const sweepMs = opts.sweepMs ?? 5000;
26
+ const kv = await opts.nats.kv(PRESENCE_BUCKET);
27
+ // 리더일 때만 살아 있는 자원.
28
+ let sub;
29
+ let sweepTimer;
30
+ // 멤버별 마지막 생존 시각(KV 키 → ts). 리더 승격 시 KV 에서 복원.
31
+ const lastSeen = new Map();
32
+ const broadcast = (channel, ev) => {
33
+ opts.nats.nc.publish(presenceEventSubject(channel), encodeJson(ev));
34
+ };
35
+ const applyJoin = async (channel, member, info, server) => {
36
+ const key = presenceKey(channel, member);
37
+ const rec = { info, server, ts: now() };
38
+ await kv.put(key, encodeJson(rec));
39
+ lastSeen.set(key, rec.ts);
40
+ broadcast(channel, { type: 'join', channel, member: { id: member, info } });
41
+ };
42
+ const applyLeave = async (channel, member) => {
43
+ const key = presenceKey(channel, member);
44
+ await kv.delete(key).catch(() => { });
45
+ lastSeen.delete(key);
46
+ broadcast(channel, { type: 'leave', channel, member });
47
+ };
48
+ const applyHeartbeat = async (channel, member) => {
49
+ const key = presenceKey(channel, member);
50
+ const entry = await kv.get(key);
51
+ if (!entry)
52
+ return; // join 을 놓친 하트비트는 무시(다음 join 에서 정합).
53
+ const rec = decodeJson(entry.value);
54
+ if (!rec)
55
+ return;
56
+ const bumped = { ...rec, ts: now() };
57
+ await kv.put(key, encodeJson(bumped));
58
+ lastSeen.set(key, bumped.ts);
59
+ };
60
+ const handleCommand = async (cmd) => {
61
+ if (cmd.op === 'join')
62
+ await applyJoin(cmd.channel, cmd.member, cmd.info, cmd.server);
63
+ else if (cmd.op === 'leave')
64
+ await applyLeave(cmd.channel, cmd.member);
65
+ else
66
+ await applyHeartbeat(cmd.channel, cmd.member);
67
+ };
68
+ const sweep = async () => {
69
+ const cutoff = now() - memberTimeoutMs;
70
+ for (const [key, ts] of [...lastSeen.entries()]) {
71
+ if (ts < cutoff) {
72
+ const { channel, member } = parsePresenceKey(key);
73
+ await applyLeave(channel, member);
74
+ }
75
+ }
76
+ };
77
+ // 리더 승격: KV 로스터로 생존 추적 재구축(재시작 복원).
78
+ const restoreFromKv = async () => {
79
+ lastSeen.clear();
80
+ const iter = await kv.keys();
81
+ for await (const key of iter) {
82
+ const entry = await kv.get(key);
83
+ if (!entry)
84
+ continue;
85
+ const rec = decodeJson(entry.value);
86
+ if (rec)
87
+ lastSeen.set(key, rec.ts);
88
+ }
89
+ };
90
+ const startLeading = async () => {
91
+ // 구독을 먼저 켜서 승격 직후 도착하는 명령을 놓치지 않는다(복원은 그 뒤).
92
+ // 리더만 구독한다 — 대기 인스턴스는 명령을 처리하지 않는다. 큐 그룹으로
93
+ // 리더 교대 순간의 중복 처리를 막는다.
94
+ const subscription = opts.nats.nc.subscribe(HUB_PRESENCE_SUBJECT, { queue: 'gaon-hub' });
95
+ sub = subscription;
96
+ (async () => {
97
+ for await (const m of subscription) {
98
+ const cmd = decodeJson(m.data);
99
+ if (!cmd)
100
+ continue;
101
+ try {
102
+ await handleCommand(cmd);
103
+ }
104
+ finally {
105
+ // join/leave 는 request/reply — 반영 후 ack. heartbeat 는 reply 없음.
106
+ if (m.reply)
107
+ m.respond(encodeJson({ ok: true }));
108
+ }
109
+ }
110
+ })();
111
+ sweepTimer = setInterval(() => void sweep(), sweepMs);
112
+ // KV 로스터로 생존 추적 재구축(재시작 복원). 명령 처리와 병행돼도
113
+ // 안전하다(둘 다 lastSeen 을 덮어쓸 뿐).
114
+ await restoreFromKv();
115
+ opts.onState?.({ leader: true });
116
+ };
117
+ const stopLeading = () => {
118
+ sub?.unsubscribe();
119
+ sub = undefined;
120
+ if (sweepTimer)
121
+ clearInterval(sweepTimer);
122
+ sweepTimer = undefined;
123
+ lastSeen.clear();
124
+ opts.onState?.({ leader: false });
125
+ };
126
+ const lease = await leaseLeader({
127
+ nats: opts.nats,
128
+ key: 'hub.leader',
129
+ id: opts.id,
130
+ ttlMs: opts.ttlMs,
131
+ onElected: () => void startLeading(),
132
+ onRevoked: () => stopLeading(),
133
+ });
134
+ return {
135
+ get isLeader() {
136
+ return lease.isLeader;
137
+ },
138
+ async stop() {
139
+ stopLeading();
140
+ await lease.stop();
141
+ },
142
+ };
143
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  export declare const version: string;
2
- export declare const status: "planned";
2
+ export { connectNats, type NatsOptions, type GaonNats, type NatsConnection, type JetStreamClient, type KV, } from './nats.js';
3
+ export { leaseLeader, type LeaseOptions, type LeaseHandle } from './lease.js';
4
+ export { encodeSegment, decodeSegment, presenceKey, presenceChannelFilter, parsePresenceKey, channelSubject, } from './keys.js';
5
+ export { channel, type ChannelDef, type ChannelContext, type ChannelAuthContext, type PresenceMember, } from './channel.js';
6
+ export { createPresenceClient, PRESENCE_BUCKET, type PresenceClient, type PresenceClientOptions, } from './presence.js';
7
+ export { createChannelRuntime, type ChannelRuntime, type ChannelRuntimeOptions, type SocketAdapter, type JoinRequest, type Connection, } from './runtime.js';
8
+ export { runHub, type HubOptions, type HubHandle } from './hub.js';
9
+ export { HUB_PRESENCE_SUBJECT, presenceEventSubject, encodeJson, decodeJson, type ClientMessage, type ServerFrame, type PresenceCommand, type PresenceEvent, type BroadcastEnvelope, } from './protocol.js';
package/dist/index.js CHANGED
@@ -1,8 +1,24 @@
1
1
  /**
2
- * @gaonjs/async — Gaon NATS 통합 (잡·이벤트·스케줄러·채널·허브 프레즌스).
2
+ * @gaonjs/async — Gaon NATS 통합 (실시간: 채널·프레즌스·허브 / 비동기: 잡·이벤트).
3
3
  *
4
- * 실시간 세로 조각은 로드맵 M6. 현재는 개발 상태만 노출하는 스텁이다.
4
+ * 실시간 세로 조각(M6): 채널 브로드캐스트·프레즌스·허브가 NATS
5
+ * 백본으로 동작한다(§7). 잡·이벤트·스케줄러(M7)는 후속.
5
6
  */
6
- import { VERSION } from "@gaonjs/core";
7
+ import { VERSION } from '@gaonjs/core';
7
8
  export const version = VERSION;
8
- export const status = "planned";
9
+ // NATS 연결 (연결 + JetStream + KV)
10
+ export { connectNats, } from './nats.js';
11
+ // 리스 기반 리더 선출 (허브 HA · 스케줄러 공용)
12
+ export { leaseLeader } from './lease.js';
13
+ // subject/KV 키 인코딩
14
+ export { encodeSegment, decodeSegment, presenceKey, presenceChannelFilter, parsePresenceKey, channelSubject, } from './keys.js';
15
+ // 채널 정의 API (§7 · 파일=등록 관례)
16
+ export { channel, } from './channel.js';
17
+ // 프레즌스 클라이언트 (웹서버 측 · 허브 동기화)
18
+ export { createPresenceClient, PRESENCE_BUCKET, } from './presence.js';
19
+ // 채널 런타임 (웹서버 측 · 브로드캐스트 + 프레즌스 + 소켓 어댑터)
20
+ export { createChannelRuntime, } from './runtime.js';
21
+ // 허브 서버 (프레즌스 권위·중계 · 리더 선출 HA)
22
+ export { runHub } from './hub.js';
23
+ // 와이어 프로토콜 (프레임·subject·직렬화)
24
+ export { HUB_PRESENCE_SUBJECT, presenceEventSubject, encodeJson, decodeJson, } from './protocol.js';
package/dist/keys.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** 임의 문자열을 subject/KV 세그먼트로 안전하게 인코딩(base64url). */
2
+ export declare function encodeSegment(value: string): string;
3
+ /** encodeSegment 의 역함수. */
4
+ export declare function decodeSegment(token: string): string;
5
+ /** 프레즌스 멤버의 KV 키: `presence.<채널>.<멤버>`. */
6
+ export declare function presenceKey(channel: string, member: string): string;
7
+ /** 한 채널의 모든 멤버를 훑는 KV 키 필터(와일드카드). */
8
+ export declare function presenceChannelFilter(channel: string): string;
9
+ /** presenceKey 를 되돌려 { channel, member } 를 얻는다. */
10
+ export declare function parsePresenceKey(key: string): {
11
+ channel: string;
12
+ member: string;
13
+ };
14
+ /** 채널 브로드캐스트 subject: `gaon.chan.<채널>`. */
15
+ export declare function channelSubject(channel: string): string;
package/dist/keys.js ADDED
@@ -0,0 +1,43 @@
1
+ // @gaonjs/async · NATS subject/KV 키 인코딩 (§7 실시간)
2
+ //
3
+ // NATS 의 subject 토큰과 KV 키는 문자 집합이 제한된다: `.` 는 토큰
4
+ // 구분자로 예약이고, 공백·`:`·`*`·`>` 등은 쓸 수 없다(실측 2026-07-22 —
5
+ // `chan:room:user:42` 는 "invalid key" 로 거부됐다). 채널명은 파일명
6
+ // 관례라 대개 안전하지만 멤버 ID(유저 키)는 임의 문자열일 수 있으므로,
7
+ // 세그먼트를 base64url 로 인코딩해 항상 유효한 키를 만든다. base64url 의
8
+ // 문자 집합(A–Z a–z 0–9 - _)은 전부 KV 키에 허용되고 `.` 를 만들지
9
+ // 않으므로 구분자와 충돌하지 않는다.
10
+ /** 임의 문자열을 subject/KV 세그먼트로 안전하게 인코딩(base64url). */
11
+ export function encodeSegment(value) {
12
+ // 빈 문자열은 base64 가 ''(빈 세그먼트 → 유효하지 않은 키)이 되므로
13
+ // 단일 토큰 '_' 로 표기한다(디코드에서 되돌린다).
14
+ if (value === '')
15
+ return '_';
16
+ return Buffer.from(value, 'utf8').toString('base64url');
17
+ }
18
+ /** encodeSegment 의 역함수. */
19
+ export function decodeSegment(token) {
20
+ if (token === '_')
21
+ return '';
22
+ return Buffer.from(token, 'base64url').toString('utf8');
23
+ }
24
+ /** 프레즌스 멤버의 KV 키: `presence.<채널>.<멤버>`. */
25
+ export function presenceKey(channel, member) {
26
+ return `presence.${encodeSegment(channel)}.${encodeSegment(member)}`;
27
+ }
28
+ /** 한 채널의 모든 멤버를 훑는 KV 키 필터(와일드카드). */
29
+ export function presenceChannelFilter(channel) {
30
+ return `presence.${encodeSegment(channel)}.*`;
31
+ }
32
+ /** presenceKey 를 되돌려 { channel, member } 를 얻는다. */
33
+ export function parsePresenceKey(key) {
34
+ const parts = key.split('.');
35
+ if (parts.length !== 3 || parts[0] !== 'presence') {
36
+ throw new Error(`[@gaonjs/async] presence 키 형식이 아닙니다: ${key}`);
37
+ }
38
+ return { channel: decodeSegment(parts[1]), member: decodeSegment(parts[2]) };
39
+ }
40
+ /** 채널 브로드캐스트 subject: `gaon.chan.<채널>`. */
41
+ export function channelSubject(channel) {
42
+ return `gaon.chan.${encodeSegment(channel)}`;
43
+ }
@@ -0,0 +1,31 @@
1
+ import type { GaonNats } from './nats.js';
2
+ export interface LeaseOptions {
3
+ readonly nats: GaonNats;
4
+ /** 리스 전용 KV 버킷. 생략 시 'gaon_lease'. */
5
+ readonly bucket?: string;
6
+ /** 리더 키. 역할마다 다르게(예: 'hub.leader', 'scheduler.leader'). */
7
+ readonly key: string;
8
+ /** 이 인스턴스 식별자(리더가 누구인지 값으로 저장). */
9
+ readonly id: string;
10
+ /** 리스 TTL(ms). 기본 5000. 리더가 죽고 이 시간 안에 승격이 일어난다. */
11
+ readonly ttlMs?: number;
12
+ /** 갱신 주기(ms). 기본 ttlMs/2 — 네트워크 지연 여유. */
13
+ readonly refreshMs?: number;
14
+ /** 리더가 됐을 때. */
15
+ onElected?(): void;
16
+ /** 리더에서 내려왔을 때(리스 상실). */
17
+ onRevoked?(): void;
18
+ /** 배경 루프 에러(연결 순단 등). 생략 시 무시하고 다음 틱 재시도. */
19
+ onError?(err: Error): void;
20
+ }
21
+ export interface LeaseHandle {
22
+ /** 현재 이 인스턴스가 리더인가. */
23
+ readonly isLeader: boolean;
24
+ /** 리더면 키를 비우고(즉시 승계 유도) 루프를 멈춘다. */
25
+ stop(): Promise<void>;
26
+ }
27
+ /**
28
+ * 리더 선출을 시작한다. 반환 핸들의 isLeader 로 상태를 읽고 stop() 으로
29
+ * 사임한다. 콜백(onElected/onRevoked)으로 리더십 전이를 통지한다.
30
+ */
31
+ export declare function leaseLeader(opts: LeaseOptions): Promise<LeaseHandle>;
package/dist/lease.js ADDED
@@ -0,0 +1,86 @@
1
+ // @gaonjs/async · 리스 기반 리더 선출 (§7 · 질문 19)
2
+ //
3
+ // 허브의 HA(active-standby)와 스케줄러 틱(§7 잡, M7)이 같은 메커니즘을
4
+ // 쓴다: NATS KV 버킷에 TTL 을 걸고, 리더 후보가 리더 키를 **원자적으로
5
+ // 생성**(create — 이미 있으면 실패)해 상호배제를 얻는다. 리더는 TTL 의
6
+ // 절반 주기로 키를 갱신(revision CAS)해 리스를 연장하고, 리더가 죽으면
7
+ // 갱신이 끊겨 TTL 만료 후 대기 인스턴스가 승격한다. (2026-07-22 실 NATS
8
+ // 실측: create 상호배제·TTL 만료 승격 확인.)
9
+ const DEFAULT_TTL_MS = 5000;
10
+ /**
11
+ * 리더 선출을 시작한다. 반환 핸들의 isLeader 로 상태를 읽고 stop() 으로
12
+ * 사임한다. 콜백(onElected/onRevoked)으로 리더십 전이를 통지한다.
13
+ */
14
+ export async function leaseLeader(opts) {
15
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
16
+ const refreshMs = opts.refreshMs ?? Math.max(500, Math.floor(ttlMs / 2));
17
+ const bucket = opts.bucket ?? 'gaon_lease';
18
+ // TTL 은 버킷 단위(MaxAge) — 갱신이 끊긴 리더 키가 만료돼 승계가 열린다.
19
+ const kv = await opts.nats.kv(bucket, { ttl: ttlMs });
20
+ let leader = false;
21
+ let revision = 0;
22
+ let stopped = false;
23
+ let timer;
24
+ const setLeader = (next) => {
25
+ if (next === leader)
26
+ return;
27
+ leader = next;
28
+ if (next)
29
+ opts.onElected?.();
30
+ else
31
+ opts.onRevoked?.();
32
+ };
33
+ const tick = async () => {
34
+ try {
35
+ if (!leader) {
36
+ // 후보: 리더 키를 원자적으로 생성 시도. 성공 = 승격.
37
+ try {
38
+ revision = await kv.create(opts.key, opts.id);
39
+ setLeader(true);
40
+ }
41
+ catch {
42
+ // 키가 이미 있음(다른 리더 보유) — 대기 유지.
43
+ }
44
+ }
45
+ else {
46
+ // 리더: revision CAS 로 갱신해 리스 연장. 실패 = 리스 상실.
47
+ try {
48
+ revision = await kv.update(opts.key, opts.id, revision);
49
+ }
50
+ catch {
51
+ setLeader(false);
52
+ revision = 0;
53
+ }
54
+ }
55
+ }
56
+ catch (err) {
57
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
58
+ }
59
+ finally {
60
+ if (!stopped)
61
+ timer = setTimeout(() => void tick(), refreshMs);
62
+ }
63
+ };
64
+ // 첫 시도는 즉시 — 부팅 직후 리더가 정해지도록.
65
+ await tick();
66
+ return {
67
+ get isLeader() {
68
+ return leader;
69
+ },
70
+ async stop() {
71
+ stopped = true;
72
+ if (timer)
73
+ clearTimeout(timer);
74
+ if (leader) {
75
+ // 사임: 내 revision 에서만 삭제(내가 여전히 리더일 때만) — 즉시 승계.
76
+ try {
77
+ await kv.delete(opts.key);
78
+ }
79
+ catch {
80
+ // 이미 만료·교체됐으면 무시.
81
+ }
82
+ setLeader(false);
83
+ }
84
+ },
85
+ };
86
+ }
package/dist/nats.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type NatsConnection } from '@nats-io/transport-node';
2
+ import { type JetStreamClient } from '@nats-io/jetstream';
3
+ import { Kvm } from '@nats-io/kv';
4
+ import type { KV, KvOptions } from '@nats-io/kv';
5
+ export interface NatsOptions {
6
+ /** 접속지. 생략 시 env GAON_NATS_URL, 그다음 nats://127.0.0.1:4222. */
7
+ readonly servers?: string | readonly string[];
8
+ /** 연결 이름(모니터링 식별). serve/work/hub 프로세스가 각자 붙인다. */
9
+ readonly name?: string;
10
+ }
11
+ /** 연결 + JetStream + KV 를 한데 묶은 Gaon NATS 핸들. */
12
+ export interface GaonNats {
13
+ readonly nc: NatsConnection;
14
+ readonly js: JetStreamClient;
15
+ readonly kvm: Kvm;
16
+ /** 이름있는 KV 버킷을 열거나(없으면) 만든다. */
17
+ kv(bucket: string, opts?: Partial<KvOptions>): Promise<KV>;
18
+ /** 처리 중 메시지를 흘려보내고 연결을 닫는다(graceful). */
19
+ close(): Promise<void>;
20
+ }
21
+ /**
22
+ * NATS 에 연결하고 JetStream·KV 핸들을 준비한다. 연결 실패는 수리
23
+ * 안내를 포함한 에러로 즉시 던진다(§7.5.3 — 에러가 곧 수리 안내서).
24
+ */
25
+ export declare function connectNats(opts?: NatsOptions): Promise<GaonNats>;
26
+ export type { NatsConnection, JetStreamClient, KV };
package/dist/nats.js ADDED
@@ -0,0 +1,57 @@
1
+ // @gaonjs/async · NATS 연결 (§7 실시간 · 백본)
2
+ //
3
+ // NATS 는 실시간(채널 브로드캐스트·프레즌스 전송)과 비동기(잡·이벤트,
4
+ // M7)의 공통 백본이다. 여기서는 연결 + JetStream + KV 핸들을 하나로
5
+ // 묶어 노출한다. 목업은 없다(§9) — 개발·테스트·운영 모두 실 NATS 로 돈다.
6
+ //
7
+ // 기본 접속지는 표준 포트(4222) — 사용자 프로젝트의 `gaon dev` compose 가
8
+ // 띄우는 NATS 다. 이 저장소의 테스트 인프라는 mega-nats(4222) 회피용으로
9
+ // 4223 을 쓰므로 `GAON_NATS_URL` 로 재지정한다(§9 테스트 픽스처).
10
+ import { connect } from '@nats-io/transport-node';
11
+ import { jetstream } from '@nats-io/jetstream';
12
+ import { Kvm } from '@nats-io/kv';
13
+ function resolveServers(opts) {
14
+ if (opts.servers)
15
+ return Array.isArray(opts.servers) ? [...opts.servers] : opts.servers;
16
+ return process.env.GAON_NATS_URL ?? 'nats://127.0.0.1:4222';
17
+ }
18
+ /**
19
+ * NATS 에 연결하고 JetStream·KV 핸들을 준비한다. 연결 실패는 수리
20
+ * 안내를 포함한 에러로 즉시 던진다(§7.5.3 — 에러가 곧 수리 안내서).
21
+ */
22
+ export async function connectNats(opts = {}) {
23
+ const servers = resolveServers(opts);
24
+ let nc;
25
+ try {
26
+ nc = await connect({ servers, name: opts.name });
27
+ }
28
+ catch (err) {
29
+ const where = Array.isArray(servers) ? servers.join(', ') : servers;
30
+ const msg = err instanceof Error ? err.message : String(err);
31
+ throw new Error(`[@gaonjs/async] NATS 연결 실패 (${where}): ${msg}\n` +
32
+ `→ 개발 인프라를 기동하세요: gaon dev (또는 docker compose up -d nats)\n` +
33
+ `→ 접속지를 바꾸려면 GAON_NATS_URL 환경변수를 설정하세요.`);
34
+ }
35
+ const js = jetstream(nc);
36
+ const kvm = new Kvm(js);
37
+ return {
38
+ nc,
39
+ js,
40
+ kvm,
41
+ async kv(bucket, kvOpts) {
42
+ // create 는 있으면 열고 없으면 만든다(멱등). 버킷 설정(ttl 등)은
43
+ // 최초 생성 시에만 적용된다.
44
+ return kvm.create(bucket, kvOpts);
45
+ },
46
+ async close() {
47
+ // drain 은 처리 중 메시지를 흘려보내고 닫는다. 이미 닫히는 중이거나
48
+ // in-flight 요청이 끊겨 throw 하면 강제 close 로 마무리한다.
49
+ try {
50
+ await nc.drain();
51
+ }
52
+ catch {
53
+ await nc.close().catch(() => { });
54
+ }
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,23 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import type { PresenceMember } from './channel.js';
3
+ import { type PresenceEvent } from './protocol.js';
4
+ export declare const PRESENCE_BUCKET = "gaon_presence";
5
+ export interface PresenceClientOptions {
6
+ readonly nats: GaonNats;
7
+ /** 이 웹서버 식별자(하트비트·디버깅). */
8
+ readonly server: string;
9
+ /** 하트비트 주기(ms). 기본 10000. 허브 만료 임계의 1/3 이하 권장. */
10
+ readonly heartbeatMs?: number;
11
+ /** 허브 응답 대기(ms). 기본 2000. 리더 부재(failover) 시 타임아웃. */
12
+ readonly requestTimeoutMs?: number;
13
+ }
14
+ export interface PresenceClient {
15
+ join(channel: string, member: string, info: Record<string, unknown>): Promise<void>;
16
+ leave(channel: string, member: string): Promise<void>;
17
+ /** 채널의 현재 접속자 목록(허브 KV 권위 · 전 서버 동일). */
18
+ list(channel: string): Promise<PresenceMember[]>;
19
+ /** 채널 프레즌스 델타 구독. 반환 함수로 해지. */
20
+ subscribe(channel: string, onEvent: (e: PresenceEvent) => void): () => void;
21
+ close(): Promise<void>;
22
+ }
23
+ export declare function createPresenceClient(opts: PresenceClientOptions): Promise<PresenceClient>;
Binary file
@@ -0,0 +1,65 @@
1
+ import type { PresenceMember } from './channel.js';
2
+ /** 클라이언트가 채널로 보내는 메시지. data 는 onMessage 로 전달된다. */
3
+ export interface ClientMessage {
4
+ readonly t: 'msg';
5
+ readonly data: unknown;
6
+ }
7
+ export type ServerFrame = {
8
+ readonly t: 'joined';
9
+ readonly channel: string;
10
+ readonly member: string;
11
+ } | {
12
+ readonly t: 'msg';
13
+ readonly data: unknown;
14
+ } | {
15
+ readonly t: 'presence';
16
+ readonly members: readonly PresenceMember[];
17
+ } | {
18
+ readonly t: 'presence:join';
19
+ readonly member: PresenceMember;
20
+ } | {
21
+ readonly t: 'presence:leave';
22
+ readonly member: string;
23
+ } | {
24
+ readonly t: 'error';
25
+ readonly message: string;
26
+ };
27
+ /** 프레즌스 상태 변경을 허브에 알리는 이벤트. 허브가 KV 권위를 갱신한다. */
28
+ export type PresenceCommand = {
29
+ readonly op: 'join';
30
+ readonly channel: string;
31
+ readonly member: string;
32
+ readonly info: Record<string, unknown>;
33
+ readonly server: string;
34
+ } | {
35
+ readonly op: 'leave';
36
+ readonly channel: string;
37
+ readonly member: string;
38
+ readonly server: string;
39
+ } | {
40
+ readonly op: 'heartbeat';
41
+ readonly channel: string;
42
+ readonly member: string;
43
+ readonly server: string;
44
+ };
45
+ export type PresenceEvent = {
46
+ readonly type: 'join';
47
+ readonly channel: string;
48
+ readonly member: PresenceMember;
49
+ } | {
50
+ readonly type: 'leave';
51
+ readonly channel: string;
52
+ readonly member: string;
53
+ };
54
+ /** channelSubject(name) 로 오가는 브로드캐스트 봉투. */
55
+ export interface BroadcastEnvelope {
56
+ readonly data: unknown;
57
+ }
58
+ /** 웹서버 → 허브 프레즌스 명령(허브가 큐 구독). */
59
+ export declare const HUB_PRESENCE_SUBJECT = "gaon.hub.presence";
60
+ /** 허브 → 전 서버 프레즌스 델타(채널별). 채널명은 내부에서 인코딩한다. */
61
+ export declare function presenceEventSubject(channel: string): string;
62
+ /** JSON 을 NATS 페이로드(Uint8Array)로. */
63
+ export declare function encodeJson(value: unknown): Uint8Array;
64
+ /** NATS 페이로드(Uint8Array)를 JSON 으로. 실패 시 undefined. */
65
+ export declare function decodeJson<T>(bytes: Uint8Array): T | undefined;
@@ -0,0 +1,28 @@
1
+ // @gaonjs/async · 실시간 와이어 프로토콜 (§7 · 질문 19 — 프로토콜은 구현 단계)
2
+ //
3
+ // 세 경로의 메시지 형식을 한곳에 모은다:
4
+ // 1) 클라이언트 ↔ 웹서버 (WebSocket JSON 프레임)
5
+ // 2) 웹서버 → 허브 (프레즌스 등록/해제/하트비트 · NATS)
6
+ // 3) 허브 → 전 웹서버 (프레즌스 델타 브로드캐스트 · NATS)
7
+ // 그리고 채널 브로드캐스트(웹서버 ↔ 웹서버, NATS)까지.
8
+ import { encodeSegment } from './keys.js';
9
+ // ─── NATS subject 상수 ──────────────────────────────────────────────────
10
+ /** 웹서버 → 허브 프레즌스 명령(허브가 큐 구독). */
11
+ export const HUB_PRESENCE_SUBJECT = 'gaon.hub.presence';
12
+ /** 허브 → 전 서버 프레즌스 델타(채널별). 채널명은 내부에서 인코딩한다. */
13
+ export function presenceEventSubject(channel) {
14
+ return `gaon.presence.${encodeSegment(channel)}`;
15
+ }
16
+ /** JSON 을 NATS 페이로드(Uint8Array)로. */
17
+ export function encodeJson(value) {
18
+ return new TextEncoder().encode(JSON.stringify(value));
19
+ }
20
+ /** NATS 페이로드(Uint8Array)를 JSON 으로. 실패 시 undefined. */
21
+ export function decodeJson(bytes) {
22
+ try {
23
+ return JSON.parse(new TextDecoder().decode(bytes));
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
@@ -0,0 +1,36 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import type { ChannelDef } from './channel.js';
3
+ import { type PresenceClient } from './presence.js';
4
+ /** 전송 계층이 구현하는 소켓 인터페이스. */
5
+ export interface SocketAdapter {
6
+ send(text: string): void;
7
+ close(code?: number, reason?: string): void;
8
+ }
9
+ export interface ChannelRuntimeOptions {
10
+ readonly nats: GaonNats;
11
+ /** 이 웹서버 식별자. */
12
+ readonly server: string;
13
+ /** 프레즌스 클라이언트(주입). 생략 시 내부 생성. */
14
+ readonly presence?: PresenceClient;
15
+ readonly heartbeatMs?: number;
16
+ }
17
+ export interface JoinRequest<User = unknown> {
18
+ readonly channel: string;
19
+ readonly def: ChannelDef<User>;
20
+ readonly member: string;
21
+ readonly user: User | null;
22
+ readonly query: Readonly<Record<string, string>>;
23
+ readonly socket: SocketAdapter;
24
+ }
25
+ export interface Connection {
26
+ /** 클라이언트가 보낸 원시 텍스트 프레임을 처리한다. */
27
+ receive(raw: string): Promise<void>;
28
+ /** 연결 종료 — 프레즌스 해제·onLeave·구독 정리. */
29
+ leave(): Promise<void>;
30
+ }
31
+ export interface ChannelRuntime {
32
+ /** 인가 실패 시 소켓을 닫고 null 을 반환한다. */
33
+ join<User>(req: JoinRequest<User>): Promise<Connection | null>;
34
+ close(): Promise<void>;
35
+ }
36
+ export declare function createChannelRuntime(opts: ChannelRuntimeOptions): Promise<ChannelRuntime>;
@@ -0,0 +1,124 @@
1
+ // @gaonjs/async · 채널 런타임 (웹서버 측 · §7)
2
+ //
3
+ // 웹서버(serve 프로세스)마다 하나. 채널 정의(channel())의 훅을 구동하고,
4
+ // 브로드캐스트는 NATS pub/sub 으로 전 인스턴스에 팬아웃하며(별도 어댑터
5
+ // 불필요 §7 line 891), 프레즌스는 허브와 동기화한다. 소켓 전송은 어댑터로
6
+ // 주입받아 전송 계층(@fastify/websocket)과 분리한다 — 런타임 자체는 실
7
+ // NATS 로 소켓 없이도 테스트할 수 있다.
8
+ import { channelSubject } from './keys.js';
9
+ import { encodeJson, decodeJson } from './protocol.js';
10
+ import { createPresenceClient } from './presence.js';
11
+ export async function createChannelRuntime(opts) {
12
+ const nats = opts.nats;
13
+ const presence = opts.presence ??
14
+ (await createPresenceClient({ nats, server: opts.server, heartbeatMs: opts.heartbeatMs }));
15
+ const ownPresence = !opts.presence;
16
+ const channels = new Map();
17
+ const sendFrame = (socket, frame) => {
18
+ socket.send(JSON.stringify(frame));
19
+ };
20
+ const deliverToChannel = (channel, frame) => {
21
+ const state = channels.get(channel);
22
+ if (!state)
23
+ return;
24
+ for (const c of state.conns)
25
+ sendFrame(c.socket, frame);
26
+ };
27
+ /** 채널 인프라(브로드캐스트·프레즌스 구독)를 필요 시 최초 1회 만든다. */
28
+ const ensureChannel = (channel) => {
29
+ const existing = channels.get(channel);
30
+ if (existing)
31
+ return existing;
32
+ // 브로드캐스트: 모든 서버가 같은 subject 를 구독 → 발행 1건이 전
33
+ // 인스턴스의 로컬 소켓으로 팬아웃(발행자 자신 포함, 중복 없음).
34
+ const broadcastSub = nats.nc.subscribe(channelSubject(channel));
35
+ (async () => {
36
+ for await (const m of broadcastSub) {
37
+ const env = decodeJson(m.data);
38
+ if (env)
39
+ deliverToChannel(channel, { t: 'msg', data: env.data });
40
+ }
41
+ })();
42
+ // 프레즌스 델타: 허브 방송을 로컬 소켓으로 전달.
43
+ const unsubPresence = presence.subscribe(channel, (ev) => {
44
+ if (ev.type === 'join')
45
+ deliverToChannel(channel, { t: 'presence:join', member: ev.member });
46
+ else
47
+ deliverToChannel(channel, { t: 'presence:leave', member: ev.member });
48
+ });
49
+ const state = { conns: new Set(), broadcastSub, unsubPresence };
50
+ channels.set(channel, state);
51
+ return state;
52
+ };
53
+ const teardownIfEmpty = (channel) => {
54
+ const state = channels.get(channel);
55
+ if (!state || state.conns.size > 0)
56
+ return;
57
+ state.broadcastSub.unsubscribe();
58
+ state.unsubPresence();
59
+ channels.delete(channel);
60
+ };
61
+ return {
62
+ async join(req) {
63
+ // 런타임은 채널을 타입 소거해 다룬다(전송 계층이 User 를 알 필요 없음).
64
+ const def = req.def;
65
+ const authCtx = { channel: req.channel, user: req.user, query: req.query };
66
+ if (def.authorize) {
67
+ const ok = await def.authorize(authCtx);
68
+ if (!ok) {
69
+ sendFrame(req.socket, { t: 'error', message: '채널 인가 거부' });
70
+ req.socket.close(4401, 'unauthorized');
71
+ return null;
72
+ }
73
+ }
74
+ const info = def.presenceInfo ? def.presenceInfo(authCtx) : {};
75
+ const ctx = {
76
+ channel: req.channel,
77
+ member: req.member,
78
+ user: req.user,
79
+ query: req.query,
80
+ send: (data) => sendFrame(req.socket, { t: 'msg', data }),
81
+ broadcast: (data) => {
82
+ const env = { data };
83
+ nats.nc.publish(channelSubject(req.channel), encodeJson(env));
84
+ },
85
+ presence: () => presence.list(req.channel),
86
+ };
87
+ const state = ensureChannel(req.channel);
88
+ const conn = { member: req.member, socket: req.socket, ctx, def };
89
+ state.conns.add(conn);
90
+ // 허브에 등록(KV 반영까지 대기) → 초기 스냅샷을 KV 권위에서 읽어 전송.
91
+ await presence.join(req.channel, req.member, info);
92
+ const members = await presence.list(req.channel);
93
+ sendFrame(req.socket, { t: 'joined', channel: req.channel, member: req.member });
94
+ sendFrame(req.socket, { t: 'presence', members });
95
+ await def.onJoin?.(ctx);
96
+ let left = false;
97
+ return {
98
+ async receive(raw) {
99
+ const msg = decodeJson(new TextEncoder().encode(raw));
100
+ if (msg?.t === 'msg')
101
+ await def.onMessage?.(ctx, msg.data);
102
+ },
103
+ async leave() {
104
+ if (left)
105
+ return;
106
+ left = true;
107
+ await presence.leave(req.channel, req.member);
108
+ await def.onLeave?.(ctx);
109
+ state.conns.delete(conn);
110
+ teardownIfEmpty(req.channel);
111
+ },
112
+ };
113
+ },
114
+ async close() {
115
+ for (const [, state] of channels) {
116
+ state.broadcastSub.unsubscribe();
117
+ state.unsubPresence();
118
+ }
119
+ channels.clear();
120
+ if (ownPresence)
121
+ await presence.close();
122
+ },
123
+ };
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/async",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Gaon NATS 통합: 잡·이벤트·스케줄러·채널(ws)·허브(프레즌스) (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,10 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@gaonjs/core": "0.1.2"
27
+ "@nats-io/transport-node": "^3.1.0",
28
+ "@nats-io/jetstream": "^3.1.0",
29
+ "@nats-io/kv": "^3.1.0",
30
+ "@gaonjs/core": "0.1.3"
28
31
  },
29
32
  "scripts": {
30
33
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"