@gaonjs/async 0.2.0 → 0.2.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.
@@ -0,0 +1,89 @@
1
+ // @gaonjs/async · 리스너 소비 (§7 M7 · line 798~826)
2
+ //
3
+ // 워커(`gaon work`)가 등록된 리스너마다 이벤트 스트림에 durable 컨슈머를
4
+ // 붙여 소비한다. 리스너 id 가 durable 이름의 근간이라, 재시작해도 같은
5
+ // 지점부터 이어 받는다(중복 배달 가능 — 리스너는 멱등 권장).
6
+ //
7
+ // 이벤트는 알림성이라 잡보다 단순하게 다룬다: 성공→ack, 실패→백오프 후
8
+ // 재전달(nak), max_deliver 소진→폐기(term, 로깅). 잡 같은 DLQ 는 두지 않되
9
+ // 재시도는 준다.
10
+ import { jetstreamManager } from '@nats-io/jetstream';
11
+ import { EVENTS_STREAM, eventSubject, listenerConsumerName, toNanos, ensureEventsStream, } from './streams.js';
12
+ import { decodePayloadBytes } from './codec.js';
13
+ import { backoffDelayMs } from './backoff.js';
14
+ import { registeredListeners } from './events.js';
15
+ const DEFAULT_ACK_WAIT_MS = 30000;
16
+ const DEFAULT_MAX_DELIVER = 6;
17
+ const DEFAULT_DRAIN_MS = 30000;
18
+ /** 등록된 리스너들을 이벤트 스트림에 붙여 소비를 시작한다. */
19
+ export async function runListeners(opts) {
20
+ const ackWaitMs = opts.ackWaitMs ?? DEFAULT_ACK_WAIT_MS;
21
+ const maxDeliver = opts.maxDeliver ?? DEFAULT_MAX_DELIVER;
22
+ const drainTimeoutMs = opts.drainTimeoutMs ?? DEFAULT_DRAIN_MS;
23
+ const emit = (e) => opts.onEvent?.(e);
24
+ await ensureEventsStream(opts.nats, opts.tuning);
25
+ const jsm = await jetstreamManager(opts.nats.nc);
26
+ const listeners = registeredListeners();
27
+ const closers = [];
28
+ const inflightAll = new Set();
29
+ for (const listener of listeners) {
30
+ const durable = listenerConsumerName(listener.id);
31
+ await jsm.consumers.add(EVENTS_STREAM, {
32
+ durable_name: durable,
33
+ filter_subject: eventSubject(listener.eventName),
34
+ ack_policy: 'explicit',
35
+ ack_wait: toNanos(ackWaitMs),
36
+ max_deliver: maxDeliver,
37
+ });
38
+ const handle = async (m) => {
39
+ const attempt = m.info.deliveryCount;
40
+ try {
41
+ const payload = decodePayloadBytes(m.data);
42
+ await listener.handler(payload);
43
+ m.ack();
44
+ emit({ kind: 'handled', listener: listener.id, event: listener.eventName });
45
+ }
46
+ catch (err) {
47
+ const error = err instanceof Error ? err.message : String(err);
48
+ if (attempt < maxDeliver) {
49
+ m.nak(backoffDelayMs(attempt, { jitter: 0.2 }));
50
+ emit({ kind: 'retrying', listener: listener.id, event: listener.eventName, attempt });
51
+ }
52
+ else {
53
+ m.term();
54
+ emit({ kind: 'dropped', listener: listener.id, event: listener.eventName, error });
55
+ }
56
+ }
57
+ };
58
+ const consumer = await opts.nats.js.consumers.get(EVENTS_STREAM, durable);
59
+ const messages = await consumer.consume({ max_messages: 1 });
60
+ const loop = (async () => {
61
+ for await (const m of messages) {
62
+ const p = handle(m).finally(() => inflightAll.delete(p));
63
+ inflightAll.add(p);
64
+ await Promise.race(inflightAll);
65
+ }
66
+ })();
67
+ closers.push(async () => {
68
+ messages.stop();
69
+ await loop.catch(() => { });
70
+ });
71
+ }
72
+ let stopped = false;
73
+ return {
74
+ get inflight() {
75
+ return inflightAll.size;
76
+ },
77
+ async stop() {
78
+ if (stopped)
79
+ return;
80
+ stopped = true;
81
+ for (const close of closers)
82
+ await close();
83
+ const deadline = Date.now() + drainTimeoutMs;
84
+ while (inflightAll.size > 0 && Date.now() < deadline) {
85
+ await Promise.race([...inflightAll, new Promise((r) => setTimeout(r, 200))]);
86
+ }
87
+ },
88
+ };
89
+ }
package/dist/nats.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface GaonNats {
15
15
  readonly kvm: Kvm;
16
16
  /** 이름있는 KV 버킷을 열거나(없으면) 만든다. */
17
17
  kv(bucket: string, opts?: Partial<KvOptions>): Promise<KV>;
18
+ /** 이름으로 JetStream 스트림을 삭제한다(없으면 무시). 운영 리셋·테스트 정리용. */
19
+ deleteStream(name: string): Promise<void>;
18
20
  /** 처리 중 메시지를 흘려보내고 연결을 닫는다(graceful). */
19
21
  close(): Promise<void>;
20
22
  }
package/dist/nats.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // 띄우는 NATS 다. 이 저장소의 테스트 인프라는 mega-nats(4222) 회피용으로
9
9
  // 4223 을 쓰므로 `GAON_NATS_URL` 로 재지정한다(§9 테스트 픽스처).
10
10
  import { connect } from '@nats-io/transport-node';
11
- import { jetstream } from '@nats-io/jetstream';
11
+ import { jetstream, jetstreamManager } from '@nats-io/jetstream';
12
12
  import { Kvm } from '@nats-io/kv';
13
13
  function resolveServers(opts) {
14
14
  if (opts.servers)
@@ -43,6 +43,10 @@ export async function connectNats(opts = {}) {
43
43
  // 최초 생성 시에만 적용된다.
44
44
  return kvm.create(bucket, kvOpts);
45
45
  },
46
+ async deleteStream(name) {
47
+ const jsm = await jetstreamManager(nc);
48
+ await jsm.streams.delete(name).catch(() => { });
49
+ },
46
50
  async close() {
47
51
  // drain 은 처리 중 메시지를 흘려보내고 닫는다. 이미 닫히는 중이거나
48
52
  // in-flight 요청이 끊겨 throw 하면 강제 close 로 마무리한다.
@@ -0,0 +1,34 @@
1
+ import type { Kysely, Transaction } from 'kysely';
2
+ export declare const OUTBOX_TABLE = "_gaon_outbox";
3
+ /**
4
+ * `_gaon_outbox` 테이블을 보장한다(CREATE TABLE IF NOT EXISTS). id 는 클라이언트
5
+ * 생성 UUID 라 dialect 별 auto-increment 차이를 피한다. created_at 은 코드에서
6
+ * 넣어 dialect default 함수 의존을 없앤다.
7
+ */
8
+ export declare function ensureOutboxTable(db: Kysely<any>): Promise<void>;
9
+ /**
10
+ * 서비스 트랜잭션을 연다. 이 안에서의 emit 은 같은 트랜잭션으로 아웃박스에
11
+ * 적재된다(트랜잭션 밖 emit 은 즉시 발행). fn 이 throw 하면 롤백 —
12
+ * 비즈니스 쓰기와 아웃박스 적재가 함께 취소돼 정합이 유지된다.
13
+ */
14
+ export declare function runInTransaction<T>(db: Kysely<any>, fn: (trx: Transaction<any>) => Promise<T>): Promise<T>;
15
+ export interface RelayOptions {
16
+ /** 한 번에 처리할 최대 행 수. 기본 100. */
17
+ readonly batchSize?: number;
18
+ /** 폴링 주기(ms). 기본 1000. */
19
+ readonly pollMs?: number;
20
+ /** 진행 통지(로깅·테스트). */
21
+ onError?(err: Error): void;
22
+ onRelayed?(count: number): void;
23
+ }
24
+ export interface RelayHandle {
25
+ stop(): Promise<void>;
26
+ /** 한 번 즉시 폴링(테스트·부팅 직후 소진용). 발행 건수를 돌려준다. */
27
+ drainOnce(): Promise<number>;
28
+ }
29
+ /**
30
+ * 아웃박스 릴레이를 시작한다. 미발행 행을 폴링해 NATS 로 발행하고 표시한다.
31
+ * SKIP LOCKED 로 잠긴 행을 건너뛰어 여러 릴레이가 같은 행을 두 번 잡지
32
+ * 않는다(중복은 at-least-once 로 허용되지만 낭비를 줄인다).
33
+ */
34
+ export declare function runOutboxRelay(db: Kysely<any>, opts?: RelayOptions): RelayHandle;
package/dist/outbox.js ADDED
@@ -0,0 +1,130 @@
1
+ // @gaonjs/async · 아웃박스 패턴 (§7 M7 · line 828~836 · v1 코어 내장)
2
+ //
3
+ // 이중 쓰기(dual-write) 문제 해결: 서비스 트랜잭션이 커밋됐는데 이벤트 발행이
4
+ // 유실되는 일을 막는다. 트랜잭션 안 emit 은 같은 트랜잭션으로 `_gaon_outbox`
5
+ // 에 적재되고(runInTransaction + outboxContext), 백그라운드 릴레이(단순 폴링)가
6
+ // 미발행 행을 NATS 로 발행한 뒤 발행 표시한다. **at-least-once** — 발행 후
7
+ // 표시 사이에 죽으면 다음 폴링이 재발행하고, msgID 중복 제거가 흡수한다.
8
+ //
9
+ // DB 결합을 피하려 Kysely 핸들을 주입받는다(async→data 의존 없음). 테이블은
10
+ // 프레임웍 내부용이라 사용자 스키마 diff 밖에서 ensureOutboxTable 로 보장한다.
11
+ import { randomUUID } from 'node:crypto';
12
+ import { runWithOutbox } from './outboxContext.js';
13
+ import { encodePayload, decodePayload } from './codec.js';
14
+ import { publishToSubject } from './events.js';
15
+ export const OUTBOX_TABLE = '_gaon_outbox';
16
+ /**
17
+ * `_gaon_outbox` 테이블을 보장한다(CREATE TABLE IF NOT EXISTS). id 는 클라이언트
18
+ * 생성 UUID 라 dialect 별 auto-increment 차이를 피한다. created_at 은 코드에서
19
+ * 넣어 dialect default 함수 의존을 없앤다.
20
+ */
21
+ export async function ensureOutboxTable(db) {
22
+ await db.schema
23
+ .createTable(OUTBOX_TABLE)
24
+ .ifNotExists()
25
+ .addColumn('id', 'varchar(36)', (c) => c.primaryKey())
26
+ .addColumn('subject', 'varchar(255)', (c) => c.notNull())
27
+ .addColumn('payload', 'text', (c) => c.notNull())
28
+ .addColumn('created_at', 'timestamp', (c) => c.notNull())
29
+ .addColumn('published_at', 'timestamp')
30
+ .execute();
31
+ // 미발행 행 조회를 빠르게(published_at IS NULL 스캔).
32
+ await db.schema
33
+ .createIndex(`${OUTBOX_TABLE}_unpublished`)
34
+ .ifNotExists()
35
+ .on(OUTBOX_TABLE)
36
+ .column('published_at')
37
+ .execute()
38
+ .catch(() => { });
39
+ }
40
+ /** 트랜잭션에 묶인 stager — 같은 트랜잭션으로 outbox 행을 insert 한다. */
41
+ function makeStager(trx) {
42
+ return {
43
+ async stage(subject, payload) {
44
+ await trx
45
+ .insertInto(OUTBOX_TABLE)
46
+ .values({
47
+ id: randomUUID(),
48
+ subject,
49
+ payload: encodePayload(payload),
50
+ created_at: new Date(),
51
+ published_at: null,
52
+ })
53
+ .execute();
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * 서비스 트랜잭션을 연다. 이 안에서의 emit 은 같은 트랜잭션으로 아웃박스에
59
+ * 적재된다(트랜잭션 밖 emit 은 즉시 발행). fn 이 throw 하면 롤백 —
60
+ * 비즈니스 쓰기와 아웃박스 적재가 함께 취소돼 정합이 유지된다.
61
+ */
62
+ export function runInTransaction(db, fn) {
63
+ return db.transaction().execute((trx) => runWithOutbox(makeStager(trx), () => fn(trx)));
64
+ }
65
+ const DEFAULT_BATCH = 100;
66
+ const DEFAULT_POLL_MS = 1000;
67
+ /**
68
+ * 아웃박스 릴레이를 시작한다. 미발행 행을 폴링해 NATS 로 발행하고 표시한다.
69
+ * SKIP LOCKED 로 잠긴 행을 건너뛰어 여러 릴레이가 같은 행을 두 번 잡지
70
+ * 않는다(중복은 at-least-once 로 허용되지만 낭비를 줄인다).
71
+ */
72
+ export function runOutboxRelay(db, opts = {}) {
73
+ const batchSize = opts.batchSize ?? DEFAULT_BATCH;
74
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
75
+ let stopped = false;
76
+ let timer;
77
+ const relayBatch = async () => {
78
+ // 미발행 행을 트랜잭션 안에서 잠그고(SKIP LOCKED) 발행 후 표시한다.
79
+ // 발행(NATS)은 트랜잭션 밖 부수효과라, 표시 커밋 전에 죽으면 재발행된다
80
+ // (at-least-once). msgID=행 id 로 이벤트 스트림에서 중복 제거된다.
81
+ const rows = await db.transaction().execute(async (trx) => {
82
+ const locked = (await trx
83
+ .selectFrom(OUTBOX_TABLE)
84
+ .selectAll()
85
+ .where('published_at', 'is', null)
86
+ .orderBy('created_at')
87
+ .limit(batchSize)
88
+ .forUpdate()
89
+ .skipLocked()
90
+ .execute());
91
+ for (const row of locked) {
92
+ await publishToSubject(row.subject, decodePayload(row.payload), row.id);
93
+ await trx
94
+ .updateTable(OUTBOX_TABLE)
95
+ .set({ published_at: new Date() })
96
+ .where('id', '=', row.id)
97
+ .execute();
98
+ }
99
+ return locked;
100
+ });
101
+ if (rows.length > 0)
102
+ opts.onRelayed?.(rows.length);
103
+ return rows.length;
104
+ };
105
+ const tick = async () => {
106
+ try {
107
+ // 밀린 행이 있으면 배치를 연달아 비운다(폴링 지연 없이 소진).
108
+ let n = 0;
109
+ do {
110
+ n = await relayBatch();
111
+ } while (n >= batchSize && !stopped);
112
+ }
113
+ catch (err) {
114
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
115
+ }
116
+ finally {
117
+ if (!stopped)
118
+ timer = setTimeout(() => void tick(), pollMs);
119
+ }
120
+ };
121
+ void tick();
122
+ return {
123
+ async stop() {
124
+ stopped = true;
125
+ if (timer)
126
+ clearTimeout(timer);
127
+ },
128
+ drainOnce: relayBatch,
129
+ };
130
+ }
@@ -0,0 +1,8 @@
1
+ /** 트랜잭션에 묶인 아웃박스 적재기. 같은 트랜잭션으로 outbox 행을 쓴다. */
2
+ export interface OutboxStager {
3
+ stage(subject: string, payload: unknown): Promise<void>;
4
+ }
5
+ /** 현재 활성 아웃박스 stager(트랜잭션 안일 때만). 없으면 undefined. */
6
+ export declare function currentOutbox(): OutboxStager | undefined;
7
+ /** stager 를 문맥에 세우고 fn 을 실행한다(트랜잭션 런타임이 호출). */
8
+ export declare function runWithOutbox<T>(stager: OutboxStager, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,16 @@
1
+ // @gaonjs/async · 아웃박스 트랜잭션 컨텍스트 (§7 M7 · line 828~836)
2
+ //
3
+ // emit() 이 "지금 트랜잭션 안인가"를 알아야 아웃박스로 적재할지 즉시 발행할지
4
+ // 정한다. 스택을 타고 내려가는 이 문맥을 AsyncLocalStorage 로 전달한다 —
5
+ // 서비스 트랜잭션이 열리면 트랜잭션에 묶인 stager 를 세우고, 그 안에서의
6
+ // emit 은 같은 트랜잭션으로 _gaon_outbox 에 적재된다. 개발자에겐 보이지 않는다.
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
+ const storage = new AsyncLocalStorage();
9
+ /** 현재 활성 아웃박스 stager(트랜잭션 안일 때만). 없으면 undefined. */
10
+ export function currentOutbox() {
11
+ return storage.getStore();
12
+ }
13
+ /** stager 를 문맥에 세우고 fn 을 실행한다(트랜잭션 런타임이 호출). */
14
+ export function runWithOutbox(stager, fn) {
15
+ return storage.run(stager, fn);
16
+ }
@@ -4,19 +4,22 @@ import { type PresenceEvent } from './protocol.js';
4
4
  export declare const PRESENCE_BUCKET = "gaon_presence";
5
5
  export interface PresenceClientOptions {
6
6
  readonly nats: GaonNats;
7
- /** 이 웹서버 식별자(하트비트·디버깅). */
7
+ /** 이 웹서버 식별자(허브가 서버→멤버 매핑에 사용). */
8
8
  readonly server: string;
9
- /** 하트비트 주기(ms). 기본 10000. 허브 만료 임계의 1/3 이하 권장. */
9
+ /** 허브 TCP 주소 'host:port'. 생략 NATS KV 공지된 리더 엔드포인트를 발견. */
10
+ readonly hubAddr?: string;
11
+ /** ping 주기(ms). 반열림 감지 백스톱. 기본 3000(허브 pingTimeout 의 1/3 이하). */
10
12
  readonly heartbeatMs?: number;
11
- /** 허브 응답 대기(ms). 기본 2000. 리더 부재(failover) 시 타임아웃. */
12
- readonly requestTimeoutMs?: number;
13
+ /** 재접속 백오프 최소·최대(ms). 기본 250·2000. */
14
+ readonly reconnectMinMs?: number;
15
+ readonly reconnectMaxMs?: number;
13
16
  }
14
17
  export interface PresenceClient {
15
18
  join(channel: string, member: string, info: Record<string, unknown>): Promise<void>;
16
19
  leave(channel: string, member: string): Promise<void>;
17
- /** 채널의 현재 접속자 목록(허브 KV 권위 · 서버 동일). */
20
+ /** 채널의 현재 접속자 목록(허브 KV 권위 로컬 멤버). */
18
21
  list(channel: string): Promise<PresenceMember[]>;
19
- /** 채널 프레즌스 델타 구독. 반환 함수로 해지. */
22
+ /** 채널 프레즌스 델타 구독(허브가 NATS 로 broadcast). 반환 함수로 해지. */
20
23
  subscribe(channel: string, onEvent: (e: PresenceEvent) => void): () => void;
21
24
  close(): Promise<void>;
22
25
  }
package/dist/presence.js CHANGED
Binary file
@@ -42,6 +42,26 @@ export type PresenceCommand = {
42
42
  readonly member: string;
43
43
  readonly server: string;
44
44
  };
45
+ export type HubCommand = {
46
+ readonly op: 'register';
47
+ readonly server: string;
48
+ } | {
49
+ readonly op: 'join';
50
+ readonly channel: string;
51
+ readonly member: string;
52
+ readonly info: Record<string, unknown>;
53
+ } | {
54
+ readonly op: 'leave';
55
+ readonly channel: string;
56
+ readonly member: string;
57
+ } | {
58
+ readonly op: 'synced';
59
+ } | {
60
+ readonly op: 'unregister';
61
+ readonly server: string;
62
+ } | {
63
+ readonly op: 'ping';
64
+ };
45
65
  export type PresenceEvent = {
46
66
  readonly type: 'join';
47
67
  readonly channel: string;
@@ -59,6 +79,17 @@ export interface BroadcastEnvelope {
59
79
  export declare const HUB_PRESENCE_SUBJECT = "gaon.hub.presence";
60
80
  /** 허브 → 전 서버 프레즌스 델타(채널별). 채널명은 내부에서 인코딩한다. */
61
81
  export declare function presenceEventSubject(channel: string): string;
82
+ /** 리더 허브가 자신의 TCP 엔드포인트를 공지하는 KV 버킷·키(멀티호스트 발견). */
83
+ export declare const HUB_ENDPOINT_BUCKET = "gaon_hub";
84
+ export declare const HUB_ENDPOINT_KEY = "endpoint";
85
+ /** 값을 개행 종결 JSON 한 줄로 인코딩(TCP 전송 단위). */
86
+ export declare function encodeLine(value: unknown): string;
87
+ /**
88
+ * TCP 청크를 누적해 완결된 라인(JSON)만 파싱해 내는 디코더를 만든다.
89
+ * 소켓은 메시지 경계를 보장하지 않으므로(청크 분할·병합) 개행으로 잘라
90
+ * 완성된 줄만 넘긴다. 파싱 불가한 줄은 건너뛴다.
91
+ */
92
+ export declare function createLineDecoder<T>(): (chunk: string) => T[];
62
93
  /** JSON 을 NATS 페이로드(Uint8Array)로. */
63
94
  export declare function encodeJson(value: unknown): Uint8Array;
64
95
  /** NATS 페이로드(Uint8Array)를 JSON 으로. 실패 시 undefined. */
package/dist/protocol.js CHANGED
@@ -1,10 +1,14 @@
1
- // @gaonjs/async · 실시간 와이어 프로토콜 (§7 · 질문 19 — 프로토콜은 구현 단계)
1
+ // @gaonjs/async · 실시간 와이어 프로토콜 (§7 · errata E-2 2026-07-23)
2
2
  //
3
- // 경로의 메시지 형식을 한곳에 모은다:
3
+ // 경로별 메시지 형식을 한곳에 모은다:
4
4
  // 1) 클라이언트 ↔ 웹서버 (WebSocket JSON 프레임)
5
- // 2) 웹서버 → 허브 (프레즌스 등록/해제/하트비트 · NATS)
6
- // 3) 허브 → 전 웹서버 (프레즌스 델타 브로드캐스트 · NATS)
7
- // 그리고 채널 브로드캐스트(웹서버 ↔ 웹서버, NATS)까지.
5
+ // 2) 웹서버 → 허브 (프레즌스 명령 · **TCP 지속 연결 · 라인 구분 JSON**)
6
+ // 3) 허브 → 전 웹서버 (프레즌스 델타 브로드캐스트 · NATS pub/sub)
7
+ // 4) 채널 브로드캐스트 (웹서버 ↔ 웹서버 · NATS pub/sub)
8
+ //
9
+ // errata E-2: 웹서버↔허브는 **TCP 지속 연결**이다(소켓 close = 즉시 죽음
10
+ // 감지). NATS 는 broadcast(허브→웹서버 델타)와 KV 영속·리더선출 전용.
11
+ // (구 방식의 NATS request/reply 명령 경로는 HubCommand+TCP 로 대체됐다.)
8
12
  import { encodeSegment } from './keys.js';
9
13
  // ─── NATS subject 상수 ──────────────────────────────────────────────────
10
14
  /** 웹서버 → 허브 프레즌스 명령(허브가 큐 구독). */
@@ -13,6 +17,40 @@ export const HUB_PRESENCE_SUBJECT = 'gaon.hub.presence';
13
17
  export function presenceEventSubject(channel) {
14
18
  return `gaon.presence.${encodeSegment(channel)}`;
15
19
  }
20
+ /** 리더 허브가 자신의 TCP 엔드포인트를 공지하는 KV 버킷·키(멀티호스트 발견). */
21
+ export const HUB_ENDPOINT_BUCKET = 'gaon_hub';
22
+ export const HUB_ENDPOINT_KEY = 'endpoint';
23
+ // ─── TCP 라인 프레이밍 (라인 구분 JSON) ─────────────────────────────────
24
+ /** 값을 개행 종결 JSON 한 줄로 인코딩(TCP 전송 단위). */
25
+ export function encodeLine(value) {
26
+ return JSON.stringify(value) + '\n';
27
+ }
28
+ /**
29
+ * TCP 청크를 누적해 완결된 라인(JSON)만 파싱해 내는 디코더를 만든다.
30
+ * 소켓은 메시지 경계를 보장하지 않으므로(청크 분할·병합) 개행으로 잘라
31
+ * 완성된 줄만 넘긴다. 파싱 불가한 줄은 건너뛴다.
32
+ */
33
+ export function createLineDecoder() {
34
+ let buf = '';
35
+ return (chunk) => {
36
+ buf += chunk;
37
+ const out = [];
38
+ let idx;
39
+ while ((idx = buf.indexOf('\n')) >= 0) {
40
+ const line = buf.slice(0, idx);
41
+ buf = buf.slice(idx + 1);
42
+ if (!line.trim())
43
+ continue;
44
+ try {
45
+ out.push(JSON.parse(line));
46
+ }
47
+ catch {
48
+ // 손상 라인 무시(다음 라인부터 정상 복구)
49
+ }
50
+ }
51
+ return out;
52
+ };
53
+ }
16
54
  /** JSON 을 NATS 페이로드(Uint8Array)로. */
17
55
  export function encodeJson(value) {
18
56
  return new TextEncoder().encode(JSON.stringify(value));
package/dist/runtime.d.ts CHANGED
@@ -12,6 +12,9 @@ export interface ChannelRuntimeOptions {
12
12
  readonly server: string;
13
13
  /** 프레즌스 클라이언트(주입). 생략 시 내부 생성. */
14
14
  readonly presence?: PresenceClient;
15
+ /** 허브 TCP 주소 'host:port'(errata E-2). 생략 시 NATS KV 공지 발견. */
16
+ readonly hubAddr?: string;
17
+ /** 프레즌스 ping 주기(ms) — 반열림 백스톱. */
15
18
  readonly heartbeatMs?: number;
16
19
  }
17
20
  export interface JoinRequest<User = unknown> {
package/dist/runtime.js CHANGED
@@ -11,7 +11,12 @@ import { createPresenceClient } from './presence.js';
11
11
  export async function createChannelRuntime(opts) {
12
12
  const nats = opts.nats;
13
13
  const presence = opts.presence ??
14
- (await createPresenceClient({ nats, server: opts.server, heartbeatMs: opts.heartbeatMs }));
14
+ (await createPresenceClient({
15
+ nats,
16
+ server: opts.server,
17
+ hubAddr: opts.hubAddr,
18
+ heartbeatMs: opts.heartbeatMs,
19
+ }));
15
20
  const ownPresence = !opts.presence;
16
21
  const channels = new Map();
17
22
  const sendFrame = (socket, frame) => {
@@ -0,0 +1,71 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import { type CronMatcher } from './cron.js';
3
+ /** 스케줄 대상(잡). later() 만 있으면 된다 — 순환 의존을 피해 최소 형태로. */
4
+ export interface Schedulable {
5
+ readonly name: string;
6
+ later(...args: never[]): Promise<void>;
7
+ }
8
+ interface EveryEntry {
9
+ readonly kind: 'every';
10
+ readonly intervalMs: number;
11
+ readonly job: Schedulable;
12
+ readonly label: string;
13
+ }
14
+ interface CronEntry {
15
+ readonly kind: 'cron';
16
+ readonly matcher: CronMatcher;
17
+ readonly job: Schedulable;
18
+ readonly label: string;
19
+ }
20
+ export type ScheduleEntry = EveryEntry | CronEntry;
21
+ /** 하루 중 시각 지정 빌더(`s.daily.at('04:00', Job)`). */
22
+ export interface DailyBuilder {
23
+ at(hhmm: string, job: Schedulable): void;
24
+ }
25
+ export interface ScheduleBuilder {
26
+ /** 고정 간격 반복. '10m'·30_000(ms). 스케줄러 시작 후 interval 마다. */
27
+ every(interval: string | number, job: Schedulable): void;
28
+ /** 매일 지정 시각(로컬 타임존). 'HH:MM'. */
29
+ readonly daily: DailyBuilder;
30
+ /** 크론식(5필드). 탈출구. */
31
+ cron(expr: string, job: Schedulable): void;
32
+ }
33
+ export interface ScheduleDef {
34
+ readonly entries: readonly ScheduleEntry[];
35
+ }
36
+ /** 스케줄을 선언한다. 콜백에서 s.every/daily.at/cron 으로 항목을 등록한다. */
37
+ export declare function schedule(build: (s: ScheduleBuilder) => void): ScheduleDef;
38
+ export type SchedulerEvent = {
39
+ readonly kind: 'leader';
40
+ readonly leader: boolean;
41
+ } | {
42
+ readonly kind: 'fired';
43
+ readonly job: string;
44
+ readonly label: string;
45
+ } | {
46
+ readonly kind: 'error';
47
+ readonly error: string;
48
+ };
49
+ export interface SchedulerOptions {
50
+ readonly nats: GaonNats;
51
+ readonly def: ScheduleDef;
52
+ /** 인스턴스 식별자(리스 값). */
53
+ readonly id: string;
54
+ /** 리스 TTL(ms). 기본 leaseLeader 기본(5000). */
55
+ readonly ttlMs?: number;
56
+ /** 틱 주기(ms). 기본 1000 — 분 경계 크론을 놓치지 않게 1초 이하 권장. */
57
+ readonly tickMs?: number;
58
+ /** 시각 주입(테스트 결정성). 기본 Date.now. */
59
+ now?(): number;
60
+ onEvent?(e: SchedulerEvent): void;
61
+ }
62
+ export interface SchedulerHandle {
63
+ readonly isLeader: boolean;
64
+ stop(): Promise<void>;
65
+ }
66
+ /**
67
+ * 스케줄러를 시작한다. 리더로 선출된 인스턴스만 틱을 발행한다. 리더십을
68
+ * 잃으면 발행을 멈추고, 되찾으면 재개한다(중복 발행 없음).
69
+ */
70
+ export declare function runScheduler(opts: SchedulerOptions): Promise<SchedulerHandle>;
71
+ export {};