@lemoncloud/chatic-sockets-lib 0.1.0 → 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.
@@ -4,6 +4,8 @@ export interface ConnectionRotationControllerOptions {
4
4
  reconnect?: ReconnectController;
5
5
  maxLifetimeMs?: number;
6
6
  refreshBeforeMs?: number;
7
+ /** rotation delay에 적용할 ±jitter 비율 (0~1). 동시 부트스트랩 클라이언트군의 일제 rotate burst 분산용. 기본 0.1 (±10%) */
8
+ jitterRatio?: number;
7
9
  timerScheduler?: SharedTimerScheduler;
8
10
  timerKey?: string;
9
11
  }
@@ -11,6 +13,7 @@ export declare class ConnectionRotationController {
11
13
  private readonly options;
12
14
  private readonly maxLifetimeMs;
13
15
  private readonly refreshBeforeMs;
16
+ private readonly jitterRatio;
14
17
  private readonly timerScheduler?;
15
18
  private readonly timerKey;
16
19
  private readonly unsubs;
@@ -21,6 +24,7 @@ export declare class ConnectionRotationController {
21
24
  stop: () => void;
22
25
  destroy: () => void;
23
26
  private scheduleRotation;
27
+ private computeRotationDelay;
24
28
  private rotate;
25
29
  private clearTimer;
26
30
  }
@@ -12,7 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ConnectionRotationController = void 0;
13
13
  class ConnectionRotationController {
14
14
  constructor(options) {
15
- var _a, _b, _c;
15
+ var _a, _b, _c, _d;
16
16
  this.options = options;
17
17
  this.unsubs = [];
18
18
  this.active = false;
@@ -33,13 +33,18 @@ class ConnectionRotationController {
33
33
  this.clearTimer();
34
34
  if (!this.active)
35
35
  return;
36
- const delayMs = Math.max(0, this.maxLifetimeMs - this.refreshBeforeMs);
36
+ const delayMs = this.computeRotationDelay();
37
37
  if (this.timerScheduler) {
38
38
  this.timerScheduler.schedule(this.timerKey, delayMs, () => this.rotate());
39
39
  return;
40
40
  }
41
41
  this.timer = setTimeout(() => void this.rotate(), delayMs);
42
42
  };
43
+ this.computeRotationDelay = () => {
44
+ const baseDelay = Math.max(0, this.maxLifetimeMs - this.refreshBeforeMs);
45
+ const jitter = this.jitterRatio > 0 ? baseDelay * (Math.random() * 2 - 1) * this.jitterRatio : 0;
46
+ return Math.max(0, baseDelay + jitter);
47
+ };
43
48
  this.rotate = () => __awaiter(this, void 0, void 0, function* () {
44
49
  if (!this.active)
45
50
  return;
@@ -64,8 +69,9 @@ class ConnectionRotationController {
64
69
  };
65
70
  this.maxLifetimeMs = (_a = options.maxLifetimeMs) !== null && _a !== void 0 ? _a : 1000 * 60 * 110;
66
71
  this.refreshBeforeMs = (_b = options.refreshBeforeMs) !== null && _b !== void 0 ? _b : 1000 * 60 * 10;
72
+ this.jitterRatio = Math.max(0, Math.min(1, (_c = options.jitterRatio) !== null && _c !== void 0 ? _c : 0.1));
67
73
  this.timerScheduler = options.timerScheduler;
68
- this.timerKey = (_c = options.timerKey) !== null && _c !== void 0 ? _c : 'rotation';
74
+ this.timerKey = (_d = options.timerKey) !== null && _d !== void 0 ? _d : 'rotation';
69
75
  this.unsubs.push(options.client.onState(event => {
70
76
  if (!this.active)
71
77
  return;
@@ -11,8 +11,10 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.createClientSocketV2 = void 0;
13
13
  const common_1 = require("./common");
14
+ const keep_alive_loop_1 = require("./keep-alive-loop");
14
15
  const message_router_1 = require("./message-router");
15
16
  const pending_request_store_1 = require("./pending-request-store");
17
+ const reconnect_controller_1 = require("./reconnect-controller");
16
18
  const shared_timer_scheduler_1 = require("./shared-timer-scheduler");
17
19
  const socket_transport_1 = require("./socket-transport");
18
20
  const buildMid = () => `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -28,16 +30,29 @@ const parseMessage = (raw) => {
28
30
  }
29
31
  return (0, common_1.onlyDefined)(Object.assign(Object.assign({}, message), { type: (0, common_1.cleanString)(message.type) }));
30
32
  };
33
+ const normalizeRequestCap = (value, fallback) => {
34
+ const n = value !== null && value !== void 0 ? value : fallback;
35
+ return n > 0 ? n : Number.POSITIVE_INFINITY;
36
+ };
37
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
38
+ const DEFAULT_MAX_INFLIGHT_REQUESTS = 32;
39
+ const DEFAULT_MAX_PENDING_REQUESTS = 512;
31
40
  class ClientSocketV2Impl {
32
41
  constructor(options) {
33
- var _a;
42
+ var _a, _b;
34
43
  this.options = options;
35
44
  this.router = new message_router_1.MessageRouter();
36
45
  this.stateListeners = new Set();
37
46
  this.errorListeners = new Set();
38
47
  this.messageListeners = new Set();
48
+ this.requestQueue = [];
49
+ this.inflightRequests = 0;
50
+ this.transportUnsubs = [];
39
51
  this._state = 'idle';
52
+ /** reconnect.stop이 client.disconnect를 호출하므로 재귀 진입 차단용 */
53
+ this.disconnecting = false;
40
54
  this.connect = () => __awaiter(this, void 0, void 0, function* () {
55
+ var _c;
41
56
  if (this._state === 'connected' || this._state === 'connecting')
42
57
  return;
43
58
  this.setState('connecting');
@@ -49,10 +64,27 @@ class ClientSocketV2Impl {
49
64
  this.setState('closed');
50
65
  throw error;
51
66
  }
67
+ finally {
68
+ void ((_c = this.reconnect) === null || _c === void 0 ? void 0 : _c.start().catch(() => undefined));
69
+ }
52
70
  });
53
71
  this.disconnect = (code, reason) => __awaiter(this, void 0, void 0, function* () {
54
72
  if (this._state === 'closed' || this._state === 'idle')
55
73
  return;
74
+ if (this.reconnect && !this.disconnecting) {
75
+ this.disconnecting = true;
76
+ try {
77
+ yield this.reconnect.stop(code, reason);
78
+ }
79
+ catch (error) {
80
+ this.emitError({ error, phase: 'disconnect' });
81
+ throw error;
82
+ }
83
+ finally {
84
+ this.disconnecting = false;
85
+ }
86
+ return;
87
+ }
56
88
  this.setState('closing');
57
89
  try {
58
90
  yield this.transport.disconnect(code, reason);
@@ -73,26 +105,22 @@ class ClientSocketV2Impl {
73
105
  this.transport.send(JSON.stringify(message));
74
106
  };
75
107
  this.request = (type, data, options) => __awaiter(this, void 0, void 0, function* () {
76
- var _b, _c, _d, _e, _f;
77
- const mid = (_d = (_c = (_b = this.options).createMid) === null || _c === void 0 ? void 0 : _c.call(_b)) !== null && _d !== void 0 ? _d : buildMid();
108
+ var _d, _e, _f, _g;
109
+ const mid = (_f = (_e = (_d = this.options).createMid) === null || _e === void 0 ? void 0 : _e.call(_d)) !== null && _f !== void 0 ? _f : buildMid();
78
110
  const canonicalType = this.normalizeType(type);
79
- const promise = this.pending.create(mid, {
80
- timeoutMs: (_e = options === null || options === void 0 ? void 0 : options.timeoutMs) !== null && _e !== void 0 ? _e : this.options.requestTimeoutMs,
81
- onTimeout: requestMid => new Error(`408 REQUEST TIMEOUT - ${canonicalType}[${requestMid}]`),
82
- });
83
- try {
84
- this.transport.send(JSON.stringify((0, common_1.onlyDefined)({
85
- type: canonicalType,
86
- data: (_f = data) !== null && _f !== void 0 ? _f : null,
87
- mid,
88
- })));
89
- }
90
- catch (error) {
91
- this.pending.reject(mid, error);
92
- this.emitError({ error, phase: 'request' });
93
- throw error;
111
+ const timeoutMs = (_g = options === null || options === void 0 ? void 0 : options.timeoutMs) !== null && _g !== void 0 ? _g : this.requestTimeoutMs;
112
+ const totalAccepted = this.inflightRequests + this.requestQueue.length;
113
+ if (totalAccepted >= this.maxPendingRequests) {
114
+ throw new Error(`429 TOO MANY REQUESTS - pending queue full (${totalAccepted}/${this.maxPendingRequests})`);
94
115
  }
95
- return promise;
116
+ return new Promise((resolve, reject) => {
117
+ const job = { mid, type: canonicalType, data, timeoutMs, resolve, reject };
118
+ if (this.inflightRequests < this.maxInflightRequests) {
119
+ this.dispatchRequest(job);
120
+ return;
121
+ }
122
+ this.requestQueue.push(job);
123
+ });
96
124
  });
97
125
  this.onState = (listener) => {
98
126
  this.stateListeners.add(listener);
@@ -107,6 +135,80 @@ class ClientSocketV2Impl {
107
135
  return () => this.messageListeners.delete(listener);
108
136
  };
109
137
  this.onType = (type, listener) => this.router.onType(type, listener);
138
+ this.destroy = () => {
139
+ var _a, _b;
140
+ /** 순서 invariant: reconnect.destroy → keepAlive.destroy → transport.disconnect (그 close 이벤트를 reconnect가 못 듣게 하기 위함) → 나머지 정리 */
141
+ (_a = this.reconnect) === null || _a === void 0 ? void 0 : _a.destroy();
142
+ (_b = this.keepAlive) === null || _b === void 0 ? void 0 : _b.destroy();
143
+ void this.transport.disconnect().catch(() => undefined);
144
+ this.transportUnsubs.splice(0).forEach(unsub => unsub());
145
+ this.rejectQueuedRequests(new Error(`499 CLIENT CLOSED REQUEST - client destroyed`));
146
+ this.pending.clear(new Error(`499 CLIENT CLOSED REQUEST - client destroyed`));
147
+ this.router.clear();
148
+ this.stateListeners.clear();
149
+ this.errorListeners.clear();
150
+ this.messageListeners.clear();
151
+ if (this.ownsTimerScheduler)
152
+ this.timerScheduler.cancelAll();
153
+ this._state = 'closed';
154
+ };
155
+ this.dispatchRequest = (job) => {
156
+ var _a;
157
+ this.inflightRequests += 1;
158
+ let released = false;
159
+ const release = () => {
160
+ if (released)
161
+ return;
162
+ released = true;
163
+ this.inflightRequests = Math.max(0, this.inflightRequests - 1);
164
+ this.dispatchNextQueuedRequest();
165
+ };
166
+ let pending;
167
+ try {
168
+ pending = this.pending.create(job.mid, {
169
+ timeoutMs: job.timeoutMs,
170
+ onTimeout: (requestMid) => new Error(`408 REQUEST TIMEOUT - ${job.type}[${requestMid}]`),
171
+ });
172
+ }
173
+ catch (error) {
174
+ release();
175
+ job.reject(error);
176
+ this.emitError({ error, phase: 'request' });
177
+ return;
178
+ }
179
+ void pending.then(value => {
180
+ job.resolve(value);
181
+ release();
182
+ }, error => {
183
+ job.reject(error);
184
+ release();
185
+ });
186
+ try {
187
+ this.transport.send(JSON.stringify((0, common_1.onlyDefined)({
188
+ type: job.type,
189
+ data: (_a = job.data) !== null && _a !== void 0 ? _a : null,
190
+ mid: job.mid,
191
+ })));
192
+ }
193
+ catch (error) {
194
+ this.pending.reject(job.mid, error);
195
+ release();
196
+ this.emitError({ error, phase: 'request' });
197
+ }
198
+ };
199
+ this.dispatchNextQueuedRequest = () => {
200
+ while (this.inflightRequests < this.maxInflightRequests && this.requestQueue.length) {
201
+ const next = this.requestQueue.shift();
202
+ if (!next)
203
+ return;
204
+ this.dispatchRequest(next);
205
+ }
206
+ };
207
+ this.rejectQueuedRequests = (reason) => {
208
+ const queued = this.requestQueue.splice(0);
209
+ queued.forEach(job => job.reject(reason));
210
+ return queued.length;
211
+ };
110
212
  this.normalizeType = (type) => {
111
213
  var _a;
112
214
  const source = (0, common_1.cleanString)(type);
@@ -127,6 +229,12 @@ class ClientSocketV2Impl {
127
229
  this.emitError({ error, phase: 'message', raw });
128
230
  }
129
231
  };
232
+ this.closeTransportInternal = (code, reason) => {
233
+ if (this._state === 'closed' || this._state === 'idle')
234
+ return;
235
+ this.setState('closing');
236
+ void this.transport.disconnect(code, reason).catch(() => undefined);
237
+ };
130
238
  this.setState = (next) => {
131
239
  const prev = this._state;
132
240
  this._state = next;
@@ -139,22 +247,37 @@ class ClientSocketV2Impl {
139
247
  this.errorListeners.forEach(listener => listener(event));
140
248
  };
141
249
  this.deviceDefaults = options.device ? (0, common_1.omitKeys)(options.device, ['tick']) : undefined;
142
- this.timerScheduler = (_a = options.timerScheduler) !== null && _a !== void 0 ? _a : new shared_timer_scheduler_1.InMemorySharedTimerScheduler(options.now ? { now: options.now } : {});
250
+ this.requestTimeoutMs = (_a = options.requestTimeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_REQUEST_TIMEOUT_MS;
251
+ this.maxInflightRequests = normalizeRequestCap(options.maxInflightRequests, DEFAULT_MAX_INFLIGHT_REQUESTS);
252
+ this.maxPendingRequests = normalizeRequestCap(options.maxPendingRequests, DEFAULT_MAX_PENDING_REQUESTS);
253
+ this.ownsTimerScheduler = !options.timerScheduler;
254
+ this.timerScheduler =
255
+ (_b = options.timerScheduler) !== null && _b !== void 0 ? _b : new shared_timer_scheduler_1.InMemorySharedTimerScheduler(options.now ? { now: options.now } : {});
143
256
  this.pending = new pending_request_store_1.PendingRequestStore({
144
257
  timerScheduler: this.timerScheduler,
145
258
  timerPrefix: 'pending:',
146
259
  });
147
260
  this.aliases = Object.assign({ ping: 'system.ping' }, (options.aliases || {}));
148
- this.transport = new socket_transport_1.WebSocketTransport(options.url, options.protocols, options.socketFactory);
149
- this.transport.on('open', () => this.setState('connected'));
150
- this.transport.on('close', () => {
261
+ this.transport = new socket_transport_1.WebSocketTransport(options.url, options.protocols, options.socketFactory, {
262
+ connectTimeoutMs: options.connectTimeoutMs,
263
+ });
264
+ this.transportUnsubs.push(this.transport.on('open', () => this.setState('connected')));
265
+ this.transportUnsubs.push(this.transport.on('close', () => {
266
+ this.rejectQueuedRequests(new Error(`499 CLIENT CLOSED REQUEST - socket closed`));
151
267
  this.pending.clear(new Error(`499 CLIENT CLOSED REQUEST - socket closed`));
152
268
  this.setState('closed');
153
- });
154
- this.transport.on('error', event => {
269
+ }));
270
+ this.transportUnsubs.push(this.transport.on('error', event => {
155
271
  this.emitError({ error: event === null || event === void 0 ? void 0 : event.error, phase: 'transport' });
156
- });
157
- this.transport.on('message', event => this.handleRawMessage(event.data));
272
+ }));
273
+ this.transportUnsubs.push(this.transport.on('message', event => this.handleRawMessage(event.data)));
274
+ if (options.keepAlive !== false) {
275
+ this.keepAlive = new keep_alive_loop_1.KeepAliveLoop(Object.assign({ client: this, timerScheduler: this.timerScheduler, onPongTimeout: () => this.closeTransportInternal(1001, 'pong-timeout') }, (options.keepAlive || {})));
276
+ this.keepAlive.start();
277
+ }
278
+ if (options.reconnect !== false) {
279
+ this.reconnect = new reconnect_controller_1.AutoReconnectController(Object.assign({ client: this, timerScheduler: this.timerScheduler }, (options.reconnect || {})));
280
+ }
158
281
  }
159
282
  get state() {
160
283
  return this._state;
@@ -1,5 +1,6 @@
1
1
  import '../lib/sockets/packets';
2
2
  import '../lib/device/types';
3
+ import '../lib/auth/types';
3
4
  export * from './types';
4
5
  export * from './common';
5
6
  export { createClientSocketV2 as default } from './create-client-socket-v2';
@@ -21,3 +22,4 @@ export * from './gateways/device-gateway';
21
22
  export * from '../lib/types';
22
23
  export * from '../lib/device/types';
23
24
  export * from '../lib/device/contracts';
25
+ export * from '../lib/auth/types';
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.default = void 0;
18
18
  require("../lib/sockets/packets");
19
19
  require("../lib/device/types");
20
+ require("../lib/auth/types");
20
21
  __exportStar(require("./types"), exports);
21
22
  __exportStar(require("./common"), exports);
22
23
  var create_client_socket_v2_1 = require("./create-client-socket-v2");
@@ -39,3 +40,4 @@ __exportStar(require("./gateways/device-gateway"), exports);
39
40
  __exportStar(require("../lib/types"), exports);
40
41
  __exportStar(require("../lib/device/types"), exports);
41
42
  __exportStar(require("../lib/device/contracts"), exports);
43
+ __exportStar(require("../lib/auth/types"), exports);
@@ -1,16 +1,13 @@
1
- import type { KeepAliveLoopControl, ClientSocketV2, SharedTimerScheduler } from './types';
2
- export interface KeepAliveLoopOptions {
1
+ import type { KeepAliveLoopControl, ClientSocketV2, SharedTimerScheduler, KeepAliveLoopOptionsPartial } from './types';
2
+ export interface KeepAliveLoopOptions extends KeepAliveLoopOptionsPartial {
3
3
  client: ClientSocketV2;
4
- intervalMs?: number;
5
- timeoutMs?: number;
6
- buildPayload?: () => unknown;
7
- mode?: 'send' | 'request';
8
4
  timerScheduler?: SharedTimerScheduler;
9
- timerKey?: string;
10
5
  }
11
6
  export declare class KeepAliveLoop implements KeepAliveLoopControl {
12
7
  private readonly options;
13
8
  private readonly intervalMs;
9
+ private readonly pingTimeoutMs;
10
+ private readonly maxMissedPongs;
14
11
  private readonly mode;
15
12
  private readonly timerScheduler?;
16
13
  private readonly timerKey;
@@ -18,6 +15,7 @@ export declare class KeepAliveLoop implements KeepAliveLoopControl {
18
15
  private timer?;
19
16
  private running;
20
17
  private inFlight?;
18
+ private missedPongs;
21
19
  constructor(options: KeepAliveLoopOptions);
22
20
  start: () => void;
23
21
  stop: () => void;
@@ -10,12 +10,15 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.KeepAliveLoop = void 0;
13
+ const DEFAULT_PING_TIMEOUT_MS = 10000;
14
+ const DEFAULT_MAX_MISSED_PONGS = 2;
13
15
  class KeepAliveLoop {
14
16
  constructor(options) {
15
- var _a, _b, _c;
17
+ var _a, _b, _c, _d, _e;
16
18
  this.options = options;
17
19
  this.unsubs = [];
18
20
  this.running = false;
21
+ this.missedPongs = 0;
19
22
  this.start = () => {
20
23
  this.running = true;
21
24
  if (this.options.client.state === 'connected')
@@ -55,12 +58,12 @@ class KeepAliveLoop {
55
58
  this.timer = undefined;
56
59
  };
57
60
  this.tick = () => __awaiter(this, void 0, void 0, function* () {
58
- var _d, _e, _f;
61
+ var _f, _g, _h;
59
62
  if (!this.running || this.options.client.state !== 'connected')
60
63
  return;
61
64
  if (this.inFlight)
62
65
  return;
63
- const payload = (_f = (_e = (_d = this.options).buildPayload) === null || _e === void 0 ? void 0 : _e.call(_d)) !== null && _f !== void 0 ? _f : null;
66
+ const payload = (_h = (_g = (_f = this.options).buildPayload) === null || _g === void 0 ? void 0 : _g.call(_f)) !== null && _h !== void 0 ? _h : null;
64
67
  this.inFlight = Promise.resolve()
65
68
  .then(() => __awaiter(this, void 0, void 0, function* () {
66
69
  if (this.mode === 'send') {
@@ -68,10 +71,24 @@ class KeepAliveLoop {
68
71
  return;
69
72
  }
70
73
  yield this.options.client.request('system.ping', payload, {
71
- timeoutMs: this.options.timeoutMs,
74
+ timeoutMs: this.pingTimeoutMs,
72
75
  });
76
+ this.missedPongs = 0;
77
+ }))
78
+ .catch(() => __awaiter(this, void 0, void 0, function* () {
79
+ if (this.mode !== 'request')
80
+ return;
81
+ this.missedPongs += 1;
82
+ if (this.missedPongs < this.maxMissedPongs)
83
+ return;
84
+ this.missedPongs = 0;
85
+ if (this.options.onPongTimeout) {
86
+ yield Promise.resolve(this.options.onPongTimeout()).catch(() => undefined);
87
+ }
88
+ else {
89
+ yield this.options.client.disconnect(1001, 'pong-timeout').catch(() => undefined);
90
+ }
73
91
  }))
74
- .catch(() => undefined)
75
92
  .then(() => {
76
93
  this.inFlight = undefined;
77
94
  this.scheduleNext();
@@ -79,14 +96,20 @@ class KeepAliveLoop {
79
96
  yield this.inFlight;
80
97
  });
81
98
  this.intervalMs = (_a = options.intervalMs) !== null && _a !== void 0 ? _a : 30000;
82
- this.mode = (_b = options.mode) !== null && _b !== void 0 ? _b : 'request';
99
+ this.pingTimeoutMs = (_b = options.timeoutMs) !== null && _b !== void 0 ? _b : DEFAULT_PING_TIMEOUT_MS;
100
+ this.maxMissedPongs = Math.max(1, (_c = options.maxMissedPongs) !== null && _c !== void 0 ? _c : DEFAULT_MAX_MISSED_PONGS);
101
+ this.mode = (_d = options.mode) !== null && _d !== void 0 ? _d : 'request';
83
102
  this.timerScheduler = options.timerScheduler;
84
- this.timerKey = (_c = options.timerKey) !== null && _c !== void 0 ? _c : 'keepalive';
103
+ this.timerKey = (_e = options.timerKey) !== null && _e !== void 0 ? _e : 'keepalive';
85
104
  this.unsubs.push(options.client.onState(event => {
86
- if (event.next === 'connected' && this.running)
105
+ if (event.next === 'connected' && this.running) {
106
+ this.missedPongs = 0;
87
107
  this.scheduleNow();
88
- if (event.next === 'closing' || event.next === 'closed')
108
+ }
109
+ if (event.next === 'closing' || event.next === 'closed') {
110
+ this.missedPongs = 0;
89
111
  this.clearTimer();
112
+ }
90
113
  }));
91
114
  }
92
115
  }
@@ -5,5 +5,6 @@ export declare class MessageRouter {
5
5
  private readonly typeListeners;
6
6
  onAny: (listener: MessageListener) => (() => void);
7
7
  onType: <T = any>(type: string, listener: MessageListener<T>) => (() => void);
8
+ clear: () => void;
8
9
  route: (message: SocketMessage<any>) => number;
9
10
  }
@@ -23,6 +23,10 @@ class MessageRouter {
23
23
  this.typeListeners.delete(key);
24
24
  };
25
25
  };
26
+ this.clear = () => {
27
+ this.anyListeners.clear();
28
+ this.typeListeners.clear();
29
+ };
26
30
  this.route = (message) => {
27
31
  var _a;
28
32
  const key = `${(_a = message === null || message === void 0 ? void 0 : message.type) !== null && _a !== void 0 ? _a : ''}`.trim();
@@ -1,32 +1,48 @@
1
- import type { ClientSocketV2, ReconnectController, SharedTimerScheduler } from './types';
2
- export interface AutoReconnectControllerOptions {
1
+ import type { AutoReconnectOptionsPartial, ClientSocketV2, ReconnectController, SharedTimerScheduler } from './types';
2
+ export interface AutoReconnectGiveUpEvent {
3
+ attempts: number;
4
+ }
5
+ export interface AutoReconnectConnectFailedEvent {
6
+ attempt: number;
7
+ error: unknown;
8
+ }
9
+ export interface AutoReconnectControllerOptions extends AutoReconnectOptionsPartial {
3
10
  client: ClientSocketV2;
4
- minDelayMs?: number;
5
- maxDelayMs?: number;
6
- factor?: number;
7
11
  timerScheduler?: SharedTimerScheduler;
8
- timerKey?: string;
9
12
  }
10
13
  export declare class AutoReconnectController implements ReconnectController {
11
14
  private readonly options;
12
15
  private readonly minDelayMs;
13
16
  private readonly maxDelayMs;
14
17
  private readonly factor;
18
+ private readonly jitterRatio;
19
+ private readonly jitterMode;
20
+ private readonly maxAttempts;
21
+ private readonly minStableMs;
15
22
  private readonly timerScheduler?;
16
23
  private readonly timerKey;
24
+ private readonly now;
17
25
  private readonly unsubs;
26
+ private readonly giveUpListeners;
27
+ private readonly connectFailedListeners;
18
28
  private timer?;
19
29
  private active;
20
30
  private manualStop;
21
31
  private restarting;
22
32
  private attempt;
23
33
  private connecting?;
34
+ private stableSince?;
24
35
  constructor(options: AutoReconnectControllerOptions);
25
36
  start: () => Promise<void>;
26
- stop: () => Promise<void>;
37
+ stop: (code?: number, reason?: string) => Promise<void>;
27
38
  restart: () => Promise<void>;
39
+ onGiveUp: (listener: (event: AutoReconnectGiveUpEvent) => void) => (() => void);
40
+ onConnectFailed: (listener: (event: AutoReconnectConnectFailedEvent) => void) => (() => void);
28
41
  destroy: () => void;
42
+ private applyStableReset;
43
+ private giveUp;
29
44
  private scheduleReconnect;
30
45
  private tryConnect;
31
46
  private clearTimer;
47
+ private computeBackoffDelay;
32
48
  }
@@ -10,11 +10,14 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.AutoReconnectController = void 0;
13
+ const DEFAULT_MIN_STABLE_MS = 5000;
13
14
  class AutoReconnectController {
14
15
  constructor(options) {
15
- var _a, _b, _c, _d;
16
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
16
17
  this.options = options;
17
18
  this.unsubs = [];
19
+ this.giveUpListeners = new Set();
20
+ this.connectFailedListeners = new Set();
18
21
  this.active = false;
19
22
  this.manualStop = false;
20
23
  this.restarting = false;
@@ -23,17 +26,20 @@ class AutoReconnectController {
23
26
  this.active = true;
24
27
  this.manualStop = false;
25
28
  this.clearTimer();
29
+ this.attempt = 0;
30
+ this.stableSince = this.options.client.state === 'connected' ? this.now() : undefined;
26
31
  if (this.options.client.state !== 'connected') {
27
32
  yield this.tryConnect();
28
33
  }
29
34
  });
30
- this.stop = () => __awaiter(this, void 0, void 0, function* () {
35
+ this.stop = (code, reason) => __awaiter(this, void 0, void 0, function* () {
31
36
  this.active = false;
32
37
  this.manualStop = true;
33
38
  this.restarting = false;
34
39
  this.clearTimer();
35
40
  this.attempt = 0;
36
- yield this.options.client.disconnect();
41
+ this.stableSince = undefined;
42
+ yield this.options.client.disconnect(code, reason);
37
43
  });
38
44
  this.restart = () => __awaiter(this, void 0, void 0, function* () {
39
45
  if (!this.active) {
@@ -42,6 +48,7 @@ class AutoReconnectController {
42
48
  }
43
49
  this.clearTimer();
44
50
  this.attempt = 0;
51
+ this.stableSince = undefined;
45
52
  this.restarting = true;
46
53
  try {
47
54
  if (this.options.client.state === 'connected' || this.options.client.state === 'connecting') {
@@ -53,16 +60,48 @@ class AutoReconnectController {
53
60
  this.restarting = false;
54
61
  }
55
62
  });
63
+ this.onGiveUp = (listener) => {
64
+ this.giveUpListeners.add(listener);
65
+ return () => {
66
+ this.giveUpListeners.delete(listener);
67
+ };
68
+ };
69
+ this.onConnectFailed = (listener) => {
70
+ this.connectFailedListeners.add(listener);
71
+ return () => {
72
+ this.connectFailedListeners.delete(listener);
73
+ };
74
+ };
56
75
  this.destroy = () => {
57
76
  this.clearTimer();
77
+ this.giveUpListeners.clear();
78
+ this.connectFailedListeners.clear();
58
79
  this.unsubs.splice(0).forEach(unsub => unsub());
59
80
  };
81
+ this.applyStableReset = () => {
82
+ if (this.stableSince === undefined)
83
+ return;
84
+ const stableMs = this.now() - this.stableSince;
85
+ this.stableSince = undefined;
86
+ if (stableMs >= this.minStableMs)
87
+ this.attempt = 0;
88
+ };
89
+ this.giveUp = () => {
90
+ const attempts = this.attempt;
91
+ this.active = false;
92
+ this.clearTimer();
93
+ this.giveUpListeners.forEach(listener => listener({ attempts }));
94
+ };
60
95
  this.scheduleReconnect = () => {
61
96
  if (!this.active || this.manualStop)
62
97
  return;
63
98
  if (this.timer || this.connecting)
64
99
  return;
65
- const delayMs = Math.min(this.minDelayMs * Math.pow(this.factor, this.attempt), this.maxDelayMs);
100
+ if (this.maxAttempts > 0 && this.attempt >= this.maxAttempts) {
101
+ this.giveUp();
102
+ return;
103
+ }
104
+ const delayMs = this.computeBackoffDelay(this.attempt);
66
105
  if (this.timerScheduler) {
67
106
  this.timerScheduler.schedule(this.timerKey, delayMs, () => {
68
107
  void this.tryConnect();
@@ -81,13 +120,21 @@ class AutoReconnectController {
81
120
  return;
82
121
  if (this.connecting)
83
122
  return this.connecting;
123
+ const attempt = this.attempt;
124
+ let failed = false;
125
+ let failedError;
84
126
  this.connecting = this.options.client
85
127
  .connect()
86
- .catch(() => {
87
- this.scheduleReconnect();
128
+ .catch(error => {
129
+ failed = true;
130
+ failedError = error;
88
131
  })
89
132
  .then(() => {
90
133
  this.connecting = undefined;
134
+ if (failed) {
135
+ this.connectFailedListeners.forEach(listener => listener({ attempt, error: failedError }));
136
+ this.scheduleReconnect();
137
+ }
91
138
  });
92
139
  return this.connecting;
93
140
  });
@@ -98,20 +145,36 @@ class AutoReconnectController {
98
145
  clearTimeout(this.timer);
99
146
  this.timer = undefined;
100
147
  };
148
+ this.computeBackoffDelay = (attempt) => {
149
+ const expDelay = Math.min(this.minDelayMs * Math.pow(this.factor, attempt), this.maxDelayMs);
150
+ if (this.jitterMode === 'full') {
151
+ /** AWS full jitter: random in [0, expDelay). minDelay floor 적용. */
152
+ const delay = Math.random() * expDelay;
153
+ return Math.max(this.minDelayMs, Math.min(this.maxDelayMs, delay));
154
+ }
155
+ const jitter = this.jitterRatio > 0 ? expDelay * (Math.random() * 2 - 1) * this.jitterRatio : 0;
156
+ return Math.max(this.minDelayMs, Math.min(this.maxDelayMs, expDelay + jitter));
157
+ };
101
158
  this.minDelayMs = (_a = options.minDelayMs) !== null && _a !== void 0 ? _a : 500;
102
- this.maxDelayMs = (_b = options.maxDelayMs) !== null && _b !== void 0 ? _b : 10000;
159
+ this.maxDelayMs = (_b = options.maxDelayMs) !== null && _b !== void 0 ? _b : 30000;
103
160
  this.factor = (_c = options.factor) !== null && _c !== void 0 ? _c : 2;
161
+ this.jitterRatio = Math.max(0, Math.min(1, (_d = options.jitterRatio) !== null && _d !== void 0 ? _d : 0.3));
162
+ this.jitterMode = (_e = options.jitterMode) !== null && _e !== void 0 ? _e : 'equal';
163
+ this.maxAttempts = Math.max(0, (_f = options.maxAttempts) !== null && _f !== void 0 ? _f : 0);
164
+ this.minStableMs = Math.max(0, (_g = options.minStableMs) !== null && _g !== void 0 ? _g : DEFAULT_MIN_STABLE_MS);
104
165
  this.timerScheduler = options.timerScheduler;
105
- this.timerKey = (_d = options.timerKey) !== null && _d !== void 0 ? _d : 'reconnect';
166
+ this.timerKey = (_h = options.timerKey) !== null && _h !== void 0 ? _h : 'reconnect';
167
+ this.now = (_j = options.now) !== null && _j !== void 0 ? _j : (() => Date.now());
106
168
  this.unsubs.push(options.client.onState(event => {
107
169
  if (!this.active)
108
170
  return;
109
171
  if (event.next === 'connected') {
110
- this.attempt = 0;
172
+ this.stableSince = this.now();
111
173
  this.clearTimer();
112
174
  this.connecting = undefined;
113
175
  }
114
176
  if (event.next === 'closed' && !this.manualStop && !this.restarting) {
177
+ this.applyStableReset();
115
178
  this.scheduleReconnect();
116
179
  }
117
180
  }));
@@ -17,7 +17,7 @@ const shared_timer_scheduler_1 = require("./shared-timer-scheduler");
17
17
  const sync_scheduler_1 = require("./sync-scheduler");
18
18
  class SocketRuntime {
19
19
  constructor(options) {
20
- var _a, _b, _c, _d, _e, _f, _g;
20
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
21
21
  this.options = options;
22
22
  this.start = () => __awaiter(this, void 0, void 0, function* () {
23
23
  this.keepAlive.start();
@@ -42,11 +42,21 @@ class SocketRuntime {
42
42
  timerScheduler: this.timerScheduler,
43
43
  });
44
44
  this.keepAlive =
45
- (_e = options.keepAlive) !== null && _e !== void 0 ? _e : new keep_alive_loop_1.KeepAliveLoop(Object.assign({ client: options.client, timerScheduler: this.timerScheduler }, (options.keepAliveOptions || {})));
45
+ (_e = options.keepAlive) !== null && _e !== void 0 ? _e : (options.keepAliveOptions
46
+ ? new keep_alive_loop_1.KeepAliveLoop(Object.assign({ client: options.client, timerScheduler: this.timerScheduler }, options.keepAliveOptions))
47
+ : (_f = options.client.keepAlive) !== null && _f !== void 0 ? _f : new keep_alive_loop_1.KeepAliveLoop({
48
+ client: options.client,
49
+ timerScheduler: this.timerScheduler,
50
+ }));
46
51
  this.reconnect =
47
- (_f = options.reconnect) !== null && _f !== void 0 ? _f : new reconnect_controller_1.AutoReconnectController(Object.assign({ client: options.client, timerScheduler: this.timerScheduler }, (options.reconnectOptions || {})));
52
+ (_g = options.reconnect) !== null && _g !== void 0 ? _g : (options.reconnectOptions
53
+ ? new reconnect_controller_1.AutoReconnectController(Object.assign({ client: options.client, timerScheduler: this.timerScheduler }, options.reconnectOptions))
54
+ : (_h = options.client.reconnect) !== null && _h !== void 0 ? _h : new reconnect_controller_1.AutoReconnectController({
55
+ client: options.client,
56
+ timerScheduler: this.timerScheduler,
57
+ }));
48
58
  this.rotation =
49
- (_g = options.rotation) !== null && _g !== void 0 ? _g : new connection_rotation_controller_1.ConnectionRotationController(Object.assign({ client: options.client, reconnect: this.reconnect, timerScheduler: this.timerScheduler }, (options.rotationOptions || {})));
59
+ (_j = options.rotation) !== null && _j !== void 0 ? _j : new connection_rotation_controller_1.ConnectionRotationController(Object.assign({ client: options.client, reconnect: this.reconnect, timerScheduler: this.timerScheduler }, (options.rotationOptions || {})));
50
60
  }
51
61
  }
52
62
  exports.SocketRuntime = SocketRuntime;
@@ -1,4 +1,8 @@
1
1
  import type { ClientSocketState, SocketFactoryContext, SocketLike, SocketTransport, SocketTransportEventMap } from './types';
2
+ export interface WebSocketTransportOptions {
3
+ /** connect() open 이벤트 대기 timeout. 만료 시 socket close + Promise reject. 0이면 무한 대기. 기본 10000 */
4
+ connectTimeoutMs?: number;
5
+ }
2
6
  export declare class WebSocketTransport implements SocketTransport {
3
7
  readonly url: string;
4
8
  readonly protocols?: string | string[];
@@ -7,7 +11,10 @@ export declare class WebSocketTransport implements SocketTransport {
7
11
  private socket?;
8
12
  private unbinders;
9
13
  private readonly listeners;
10
- constructor(url: string, protocols?: string | string[], socketFactory?: (context: SocketFactoryContext) => SocketLike);
14
+ private readonly connectTimeoutMs;
15
+ private connecting?;
16
+ private cancelConnecting?;
17
+ constructor(url: string, protocols?: string | string[], socketFactory?: (context: SocketFactoryContext) => SocketLike, options?: WebSocketTransportOptions);
11
18
  get state(): ClientSocketState;
12
19
  on: <TType extends keyof SocketTransportEventMap>(type: TType, listener: (event: SocketTransportEventMap[TType]) => void) => (() => void);
13
20
  connect: () => Promise<void>;
@@ -25,8 +25,10 @@ const bindSocketListener = (socket, type, listener) => {
25
25
  socket[key] = prev !== null && prev !== void 0 ? prev : null;
26
26
  };
27
27
  };
28
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10000;
28
29
  class WebSocketTransport {
29
- constructor(url, protocols, socketFactory = context => new globalThis.WebSocket(context.url, context.protocols)) {
30
+ constructor(url, protocols, socketFactory = context => new globalThis.WebSocket(context.url, context.protocols), options) {
31
+ var _a;
30
32
  this.url = url;
31
33
  this.protocols = protocols;
32
34
  this.socketFactory = socketFactory;
@@ -47,20 +49,40 @@ class WebSocketTransport {
47
49
  this.connect = () => __awaiter(this, void 0, void 0, function* () {
48
50
  if (this._state === 'connected')
49
51
  return;
50
- if (this._state === 'connecting')
51
- return;
52
+ if (this.connecting)
53
+ return this.connecting;
52
54
  this.cleanupSocket();
53
55
  this._state = 'connecting';
54
56
  const socket = this.socketFactory({ url: this.url, protocols: this.protocols });
55
57
  this.socket = socket;
56
- yield new Promise((resolve, reject) => {
58
+ this.connecting = new Promise((resolve, reject) => {
57
59
  let connectSettled = false;
60
+ let timeoutTimer;
58
61
  const settleConnect = (handler) => {
59
62
  if (connectSettled)
60
63
  return;
61
64
  connectSettled = true;
65
+ if (timeoutTimer)
66
+ clearTimeout(timeoutTimer);
67
+ this.connecting = undefined;
68
+ this.cancelConnecting = undefined;
62
69
  handler();
63
70
  };
71
+ this.cancelConnecting = error => settleConnect(() => reject(error));
72
+ if (this.connectTimeoutMs > 0) {
73
+ timeoutTimer = setTimeout(() => {
74
+ if (connectSettled)
75
+ return;
76
+ const error = new Error(`408 CONNECT TIMEOUT - WebSocketTransport.connect() after ${this.connectTimeoutMs}ms`);
77
+ settleConnect(() => reject(error));
78
+ try {
79
+ socket.close(1000, 'connect-timeout');
80
+ }
81
+ catch (_a) {
82
+ /** ignore — best-effort cleanup */
83
+ }
84
+ }, this.connectTimeoutMs);
85
+ }
64
86
  this.unbinders = [
65
87
  bindSocketListener(socket, 'open', () => {
66
88
  this._state = 'connected';
@@ -89,8 +111,10 @@ class WebSocketTransport {
89
111
  }),
90
112
  ];
91
113
  });
114
+ return this.connecting;
92
115
  });
93
116
  this.disconnect = (code, reason) => __awaiter(this, void 0, void 0, function* () {
117
+ var _b;
94
118
  if (!this.socket) {
95
119
  this._state = 'closed';
96
120
  return;
@@ -98,6 +122,7 @@ class WebSocketTransport {
98
122
  this._state = 'closing';
99
123
  const socket = this.socket;
100
124
  socket.close(code, reason);
125
+ (_b = this.cancelConnecting) === null || _b === void 0 ? void 0 : _b.call(this, new Error(`499 CLIENT CLOSED REQUEST - disconnect during connecting`));
101
126
  this.cleanupSocket();
102
127
  this._state = 'closed';
103
128
  this.emit('close', { code, reason, wasClean: true });
@@ -116,6 +141,7 @@ class WebSocketTransport {
116
141
  this.unbinders.splice(0).forEach(unbind => unbind());
117
142
  this.socket = undefined;
118
143
  };
144
+ this.connectTimeoutMs = (_a = options === null || options === void 0 ? void 0 : options.connectTimeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_CONNECT_TIMEOUT_MS;
119
145
  }
120
146
  get state() {
121
147
  return this._state;
@@ -39,6 +39,26 @@ export interface SocketTransport {
39
39
  send(raw: string): void;
40
40
  on<TType extends keyof SocketTransportEventMap>(type: TType, listener: (event: SocketTransportEventMap[TType]) => void): () => void;
41
41
  }
42
+ export interface KeepAliveLoopOptionsPartial {
43
+ intervalMs?: number;
44
+ timeoutMs?: number;
45
+ maxMissedPongs?: number;
46
+ buildPayload?: () => unknown;
47
+ mode?: 'send' | 'request';
48
+ timerKey?: string;
49
+ onPongTimeout?: () => void | Promise<void>;
50
+ }
51
+ export interface AutoReconnectOptionsPartial {
52
+ minDelayMs?: number;
53
+ maxDelayMs?: number;
54
+ factor?: number;
55
+ jitterRatio?: number;
56
+ jitterMode?: 'equal' | 'full';
57
+ maxAttempts?: number;
58
+ minStableMs?: number;
59
+ timerKey?: string;
60
+ now?: () => number;
61
+ }
42
62
  export interface ClientSocketOptions {
43
63
  url: string;
44
64
  protocols?: string | string[];
@@ -47,6 +67,11 @@ export interface ClientSocketOptions {
47
67
  aliases?: Partial<Record<SocketPacketInputType | string, SocketPacketType>>;
48
68
  timerScheduler?: SharedTimerScheduler;
49
69
  requestTimeoutMs?: number;
70
+ connectTimeoutMs?: number;
71
+ maxInflightRequests?: number;
72
+ maxPendingRequests?: number;
73
+ keepAlive?: false | KeepAliveLoopOptionsPartial;
74
+ reconnect?: false | AutoReconnectOptionsPartial;
50
75
  createMid?: () => string;
51
76
  now?: () => number;
52
77
  }
@@ -67,6 +92,8 @@ export interface ClientSocketV2 {
67
92
  readonly state: ClientSocketState;
68
93
  readonly deviceDefaults?: DeviceBootstrapInput;
69
94
  readonly timerScheduler?: SharedTimerScheduler;
95
+ readonly keepAlive?: KeepAliveLoopControl;
96
+ readonly reconnect?: ReconnectController;
70
97
  connect(): Promise<void>;
71
98
  disconnect(code?: number, reason?: string): Promise<void>;
72
99
  send<TType extends SocketPacketInputType>(type: TType, data?: SocketPacketRequestData<ResolveSocketPacketType<TType>>): void;
@@ -78,6 +105,7 @@ export interface ClientSocketV2 {
78
105
  onError(listener: (event: ClientSocketErrorEvent) => void): () => void;
79
106
  onMessage(listener: (event: ClientSocketMessageEvent) => void): () => void;
80
107
  onType<T = any>(type: string, listener: (message: SocketMessage<T>) => void): () => void;
108
+ destroy(): void;
81
109
  }
82
110
  export declare type SyncTargetType = string;
83
111
  export interface SyncTargetDescriptor {
@@ -116,7 +144,7 @@ export interface KeepAliveLoopControl {
116
144
  }
117
145
  export interface ReconnectController {
118
146
  start(): Promise<void>;
119
- stop(): Promise<void>;
147
+ stop(code?: number, reason?: string): Promise<void>;
120
148
  restart(): Promise<void>;
121
149
  }
122
150
  export interface SharedTimerScheduler {
@@ -0,0 +1,36 @@
1
+ import type { InferSocketError, InferSocketRequest, InferSocketResponse, SocketErrorMessage, SocketRequestMessage, SocketResponseMessage } from '../types';
2
+ export declare type AuthUpdateState = '' | 'pending' | 'validating' | 'authenticated' | 'failed' | 'disconnected';
3
+ export interface AuthUpdateRequestData {
4
+ token?: string;
5
+ dryRun?: boolean;
6
+ }
7
+ export interface AuthUpdateMemberHead {
8
+ id?: string;
9
+ name?: string;
10
+ }
11
+ export interface AuthUpdateResponseData {
12
+ connId?: string;
13
+ deviceId?: string;
14
+ authId?: string;
15
+ memberId?: string;
16
+ state?: AuthUpdateState;
17
+ stateAt?: number;
18
+ error?: string;
19
+ member$?: AuthUpdateMemberHead;
20
+ }
21
+ declare module '../types' {
22
+ interface SocketPacketRegistry {
23
+ 'auth.update': {
24
+ request: AuthUpdateRequestData;
25
+ response: AuthUpdateResponseData;
26
+ error: null;
27
+ };
28
+ }
29
+ }
30
+ export declare type AuthUpdateType = 'auth.update';
31
+ export declare type AuthUpdateInput = InferSocketRequest<AuthUpdateType>;
32
+ export declare type AuthUpdateResponse = InferSocketResponse<AuthUpdateType>;
33
+ export declare type AuthUpdateErrorData = InferSocketError<AuthUpdateType>;
34
+ export declare type AuthUpdateRequestMessage = SocketRequestMessage<AuthUpdateType>;
35
+ export declare type AuthUpdateResponseMessage = SocketResponseMessage<AuthUpdateType>;
36
+ export declare type AuthUpdateErrorMessage = SocketErrorMessage<AuthUpdateType>;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemoncloud/chatic-sockets-lib",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Client websocket transport and sync runtime for chatic sockets v2",
5
5
  "main": "dist/client-socket-v2/index.js",
6
6
  "types": "dist/client-socket-v2/index.d.ts",