@zhin.js/adapter 1.1.0 → 1.1.2

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,422 @@
1
+ /**
2
+ * EndpointLifecycle — WS/SSE 长连接端点生命周期基座。
3
+ *
4
+ * 把 napcat / milky / onebot11·12 各自重复实现(且重复犯错)的状态机收敛为一处:
5
+ *
6
+ * 状态机:idle → connecting → open → reconnecting → open … → stopped / closed
7
+ *
8
+ * - start(connectFn):连接失败自动复位回 idle 且不武装重连(start 的 catch 语义);
9
+ * stop-during-connect 竞态时 start() 静默 settle(视为主动停止,不抛错)。
10
+ * - stop():主动断开,清全部定时器、调用已注册的强关函数、唤醒所有竞态等待,绝不重连。
11
+ * - handle.notifyClosed():对端断开(ws close / SSE 流结束)时由适配器调用;
12
+ * 仅在连接曾 open 时才按指数退避 + jitter 武装重连,初始连接失败不武装。
13
+ * - startHeartbeat(fn, interval):心跳 + 看门狗——连续 N 轮无回包(notifyHeartbeatAck
14
+ * 未复位计数)时主动调用 onForceClose 注册的强关函数,由底层 close 事件驱动重连。
15
+ * - 定时器集中管理:重连 timer 与心跳 timer 均在 close / stop / 看门狗触发时清理。
16
+ *
17
+ * 防叠套:重连循环单例(#reconnectRunning),且每次 connect 尝试递增 generation,
18
+ * 陈旧连接句柄的 notifyClosed / onForceClose 一律忽略。
19
+ *
20
+ * 迁移指引(以 napcat/milky/onebot WS endpoint 为例):
21
+ * 1. 删除 #started / #stopping / #reconnectTimer / #heartbeatTimer / opened 旗标,
22
+ * 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.name, reconnect, heartbeat })`。
23
+ * 2. `start()` 改为:
24
+ * ```ts
25
+ * this.#unregisterAgent = registerXxxAgentEndpoint(name, this); // agent 注册仍在适配器侧
26
+ * try {
27
+ * await this.#lifecycle.start(async (handle) => {
28
+ * this.#handle = handle; // 供 ws 'close' 回调引用
29
+ * await new Promise<void>((resolve, reject) => {
30
+ * const ws = createWebSocket(...); this.#ws = ws;
31
+ * ws.on('open', () => { this.#lifecycle.startHeartbeat(() => beat(), interval); resolve(); });
32
+ * ws.on('close', (code, reason) => { handle.notifyClosed(...); rejectIfNotSettled(...); });
33
+ * ws.on('error', (err) => rejectIfNotSettled(err));
34
+ * });
35
+ * });
36
+ * } catch (err) {
37
+ * this.#unregisterAgent?.(); this.#unregisterAgent = undefined; // 反注册对称
38
+ * throw err;
39
+ * }
40
+ * ```
41
+ * start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,刻意不收入基座。
42
+ * 3. `stop()` 改为:先 `await this.#lifecycle.stop()`(清定时器 + 强关 ws + 竞态 settle),
43
+ * 再做适配器专有清理(rejectAllPending、deduper.clear、agent 反注册)。
44
+ * 4. `handle.onForceClose(() => this.#ws?.close())` 在每次拿到新 socket 后注册,
45
+ * 供心跳看门狗主动断开;ws 'message'/'pong' 回调里调 `notifyHeartbeatAck()` 喂狗。
46
+ * 5. 退避参数由配置映射:reconnect_interval → initialIntervalMs,可按需覆盖
47
+ * multiplier / maxIntervalMs / jitterMs / maxAttempts;重连成功后退避自动复位。
48
+ */
49
+ // 使用全局定时器(而非 node:timers 导入):vitest fake timers 只接管全局绑定,
50
+ // 这样单测可用 vi.useFakeTimers 驱动退避/心跳。
51
+ import { formatCompact, getLogger } from '@zhin.js/logger';
52
+
53
+ const logger = getLogger('adapter');
54
+
55
+ export type EndpointLifecycleState =
56
+ | 'idle'
57
+ | 'connecting'
58
+ | 'open'
59
+ | 'reconnecting'
60
+ | 'closed'
61
+ | 'stopped';
62
+
63
+ export interface EndpointLifecycleReconnectOptions {
64
+ /** 首次重连间隔(ms),默认 5000。 */
65
+ readonly initialIntervalMs?: number;
66
+ /** 退避倍数,默认 2(1 = 固定间隔,兼容旧 reconnect_interval 语义)。 */
67
+ readonly multiplier?: number;
68
+ /** 退避封顶(ms),默认 60000。 */
69
+ readonly maxIntervalMs?: number;
70
+ /** 每次重连附加的随机抖动上限(ms),默认 250;测试可配 random 使其确定。 */
71
+ readonly jitterMs?: number;
72
+ /** 最大连续重连失败次数,默认 Infinity;耗尽后进入 closed 终态。 */
73
+ readonly maxAttempts?: number;
74
+ }
75
+
76
+ export interface EndpointLifecycleHeartbeatOptions {
77
+ /** startHeartbeat 缺省间隔(ms),默认 30000;<=0 表示不开心跳。 */
78
+ readonly intervalMs?: number;
79
+ /**
80
+ * 看门狗轮数:连续 N 次心跳未收到回包(notifyHeartbeatAck)后,
81
+ * 下一心跳周期主动调用强关函数。默认 0 = 关闭看门狗。
82
+ */
83
+ readonly watchdogMisses?: number;
84
+ }
85
+
86
+ export interface EndpointLifecycleOptions {
87
+ /** 端点名,仅用于日志字段。 */
88
+ readonly name: string;
89
+ /** 重连配置;传 false 禁用自动重连(对端断开后进入 closed)。 */
90
+ readonly reconnect?: EndpointLifecycleReconnectOptions | false;
91
+ /** 心跳配置。 */
92
+ readonly heartbeat?: EndpointLifecycleHeartbeatOptions;
93
+ /** 随机源(jitter 用),默认 Math.random;测试注入 () => 0 获得确定退避序列。 */
94
+ readonly random?: () => number;
95
+ }
96
+
97
+ /**
98
+ * 每次 connect 尝试获得一个句柄;generation 过期后其方法自动失效,
99
+ * 因此适配器无需担心旧 socket 的迟到事件污染新连接。
100
+ */
101
+ export interface EndpointConnectHandle {
102
+ /**
103
+ * 底层连接关闭(对端断开 / 看门狗强关 / 任意 close 事件)时调用。
104
+ * 仅当本次连接曾 open(即 connectFn 已 resolve)才武装退避重连;
105
+ * 初始连接失败由 start() 的拒绝路径复位,不武装重连。
106
+ */
107
+ notifyClosed(reason?: unknown): void;
108
+ /** 注册当前连接的强制关闭函数(心跳看门狗与 stop 使用);每次 connect 覆盖。 */
109
+ onForceClose(close: () => void): void;
110
+ }
111
+
112
+ export type EndpointConnectFn = (handle: EndpointConnectHandle) => Promise<void>;
113
+
114
+ export interface EndpointLifecycle {
115
+ readonly state: EndpointLifecycleState;
116
+ /** start 已成功且未 stop(含 connecting / open / reconnecting)。 */
117
+ readonly started: boolean;
118
+ /**
119
+ * 启动并建立首连。重复调用幂等(进行中/已连接时直接返回)。
120
+ * connectFn 须在连接 open 时 resolve、失败或 open 前 close 时 reject;
121
+ * stop-during-connect 时本方法静默 resolve(主动停止不算失败)。
122
+ */
123
+ start(connect: EndpointConnectFn): Promise<void>;
124
+ /** 主动停止:清全部定时器、强关连接、唤醒竞态等待;幂等,绝不触发重连。 */
125
+ stop(): Promise<void>;
126
+ /** 启动心跳;重复调用先清旧 timer。intervalMs 缺省取配置,<=0 不开。 */
127
+ startHeartbeat(beat: () => void, intervalMs?: number): void;
128
+ /** 清理心跳 timer(close / stop / 看门狗触发时基座会自动调用)。 */
129
+ stopHeartbeat(): void;
130
+ /** 喂狗:收到任何回包(message / pong / 心跳响应)时调用,复位看门狗计数。 */
131
+ notifyHeartbeatAck(): void;
132
+ }
133
+
134
+ interface ResolvedReconnectOptions {
135
+ readonly initialIntervalMs: number;
136
+ readonly multiplier: number;
137
+ readonly maxIntervalMs: number;
138
+ readonly jitterMs: number;
139
+ readonly maxAttempts: number;
140
+ }
141
+
142
+ const DEFAULT_RECONNECT: ResolvedReconnectOptions = {
143
+ initialIntervalMs: 5_000,
144
+ multiplier: 2,
145
+ maxIntervalMs: 60_000,
146
+ jitterMs: 250,
147
+ maxAttempts: Number.POSITIVE_INFINITY,
148
+ };
149
+
150
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
151
+
152
+ class EndpointLifecycleImpl implements EndpointLifecycle {
153
+ readonly #name: string;
154
+ readonly #reconnect: ResolvedReconnectOptions | false;
155
+ readonly #heartbeat: Required<EndpointLifecycleHeartbeatOptions>;
156
+ readonly #random: () => number;
157
+ #state: EndpointLifecycleState = 'idle';
158
+ #connect?: EndpointConnectFn;
159
+ /** 每次 connect 尝试 +1,识别陈旧句柄。 */
160
+ #generation = 0;
161
+ /** 连续重连失败计数,open 成功后复位。 */
162
+ #attempt = 0;
163
+ /** 重连循环单例旗标(防叠套)。 */
164
+ #reconnectRunning = false;
165
+ #reconnectTimer?: NodeJS.Timeout;
166
+ #reconnectWake?: (elapsed: boolean) => void;
167
+ #heartbeatTimer?: NodeJS.Timeout;
168
+ #heartbeatMisses = 0;
169
+ #forceClose?: () => void;
170
+ /** stop() 时唤醒的竞态等待(start / 重连中的 connect 尝试)。 */
171
+ #stopWaiters: Array<() => void> = [];
172
+
173
+ constructor(options: EndpointLifecycleOptions) {
174
+ this.#name = options.name;
175
+ this.#reconnect = options.reconnect === false
176
+ ? false
177
+ : { ...DEFAULT_RECONNECT, ...options.reconnect };
178
+ this.#heartbeat = {
179
+ intervalMs: options.heartbeat?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
180
+ watchdogMisses: options.heartbeat?.watchdogMisses ?? 0,
181
+ };
182
+ this.#random = options.random ?? Math.random;
183
+ }
184
+
185
+ get state(): EndpointLifecycleState {
186
+ return this.#state;
187
+ }
188
+
189
+ /**
190
+ * 并发安全的状态读取:stop() 可能在任意 await 点并发改写 #state,
191
+ * 经方法调用读取可避免 TS 对字段/getter 的控制流窄化误判(TS2367)。
192
+ */
193
+ #currentState(): EndpointLifecycleState {
194
+ return this.#state;
195
+ }
196
+
197
+ get started(): boolean {
198
+ return this.#state === 'connecting' || this.#state === 'open' || this.#state === 'reconnecting';
199
+ }
200
+
201
+ async start(connect: EndpointConnectFn): Promise<void> {
202
+ if (this.started) return;
203
+ this.#connect = connect;
204
+ this.#state = 'connecting';
205
+ this.#attempt = 0;
206
+ try {
207
+ await this.#runConnect(connect);
208
+ } catch (err) {
209
+ // 注意:stop() 可能在 await 期间并发改写 #state,必须经 getter 读取避免 TS 窄化误判
210
+ if (this.#currentState() === 'stopped') return; // stop-during-connect 竞态:静默 settle
211
+ // start 失败复位:回 idle、不武装重连,允许调用方重试
212
+ this.#state = 'idle';
213
+ throw err;
214
+ }
215
+ if (this.#currentState() === 'stopped') return; // stop 竞态先于 open
216
+ this.#state = 'open';
217
+ }
218
+
219
+ async stop(): Promise<void> {
220
+ const wasActive = this.#state !== 'stopped';
221
+ this.#state = 'stopped';
222
+ this.#attempt = 0;
223
+ this.stopHeartbeat();
224
+ if (this.#reconnectTimer) {
225
+ clearTimeout(this.#reconnectTimer);
226
+ this.#reconnectTimer = undefined;
227
+ }
228
+ this.#reconnectWake?.(false);
229
+ this.#reconnectWake = undefined;
230
+ for (const wake of this.#stopWaiters.splice(0)) wake();
231
+ const close = this.#forceClose;
232
+ this.#forceClose = undefined;
233
+ if (close) {
234
+ try {
235
+ close();
236
+ } catch {
237
+ /* ignore */
238
+ }
239
+ }
240
+ if (wasActive) {
241
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#name }));
242
+ }
243
+ }
244
+
245
+ startHeartbeat(beat: () => void, intervalMs = this.#heartbeat.intervalMs): void {
246
+ this.stopHeartbeat();
247
+ if (intervalMs <= 0) return;
248
+ const watchdogMisses = this.#heartbeat.watchdogMisses;
249
+ this.#heartbeatMisses = 0;
250
+ this.#heartbeatTimer = setInterval(() => {
251
+ if (watchdogMisses > 0) {
252
+ this.#heartbeatMisses += 1;
253
+ if (this.#heartbeatMisses > watchdogMisses) {
254
+ this.stopHeartbeat();
255
+ logger.warn(formatCompact({
256
+ op: 'heartbeat_watchdog',
257
+ endpoint: this.#name,
258
+ ok: false,
259
+ misses: this.#heartbeatMisses,
260
+ }));
261
+ const close = this.#forceClose;
262
+ if (close) {
263
+ try {
264
+ close();
265
+ } catch {
266
+ /* ignore */
267
+ }
268
+ }
269
+ return;
270
+ }
271
+ }
272
+ try {
273
+ beat();
274
+ } catch (err) {
275
+ logger.warn(formatCompact({
276
+ op: 'heartbeat',
277
+ endpoint: this.#name,
278
+ ok: false,
279
+ error: err instanceof Error ? err.message : String(err),
280
+ }));
281
+ }
282
+ }, intervalMs);
283
+ }
284
+
285
+ stopHeartbeat(): void {
286
+ if (this.#heartbeatTimer) {
287
+ clearInterval(this.#heartbeatTimer);
288
+ this.#heartbeatTimer = undefined;
289
+ }
290
+ this.#heartbeatMisses = 0;
291
+ }
292
+
293
+ notifyHeartbeatAck(): void {
294
+ this.#heartbeatMisses = 0;
295
+ }
296
+
297
+ #createHandle(generation: number): EndpointConnectHandle {
298
+ return {
299
+ notifyClosed: (reason) => {
300
+ if (generation !== this.#generation) return; // 陈旧连接的迟到事件
301
+ this.#forceClose = undefined;
302
+ this.stopHeartbeat(); // close 清心跳
303
+ // 仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
304
+ if (this.#state !== 'open') return;
305
+ logger.warn(formatCompact({
306
+ op: 'disconnect',
307
+ endpoint: this.#name,
308
+ ok: false,
309
+ error: reason instanceof Error ? reason.message : reason != null ? String(reason) : 'closed',
310
+ }));
311
+ if (!this.#reconnect) {
312
+ this.#state = 'closed';
313
+ return;
314
+ }
315
+ this.#state = 'reconnecting';
316
+ this.#scheduleReconnect();
317
+ },
318
+ onForceClose: (close) => {
319
+ if (generation === this.#generation) this.#forceClose = close;
320
+ },
321
+ };
322
+ }
323
+
324
+ /** 跑一次 connect 尝试;与 stop 信号竞态,stop 先到则静默返回。 */
325
+ async #runConnect(connect: EndpointConnectFn): Promise<void> {
326
+ const generation = ++this.#generation;
327
+ this.#forceClose = undefined;
328
+ const handle = this.#createHandle(generation);
329
+ // Promise.resolve().then 兜底同步抛错;额外 catch 防止 stop 竞态后迟到拒绝变 unhandled
330
+ const connecting = Promise.resolve().then(() => connect(handle));
331
+ connecting.catch(() => { /* settled via race; late rejection ignored */ });
332
+ let wake!: () => void;
333
+ const stopped = new Promise<void>((resolve) => {
334
+ wake = resolve;
335
+ });
336
+ this.#stopWaiters.push(wake);
337
+ try {
338
+ await Promise.race([connecting, stopped]);
339
+ } finally {
340
+ const index = this.#stopWaiters.indexOf(wake);
341
+ if (index >= 0) this.#stopWaiters.splice(index, 1);
342
+ }
343
+ }
344
+
345
+ /** 武装重连循环(单例,防叠套)。 */
346
+ #scheduleReconnect(): void {
347
+ if (this.#reconnectRunning) return;
348
+ this.#reconnectRunning = true;
349
+ void this.#reconnectLoop().finally(() => {
350
+ this.#reconnectRunning = false;
351
+ });
352
+ }
353
+
354
+ async #reconnectLoop(): Promise<void> {
355
+ const config = this.#reconnect;
356
+ const connect = this.#connect;
357
+ if (!config || !connect) return;
358
+ while (this.#state === 'reconnecting') {
359
+ if (this.#attempt >= config.maxAttempts) {
360
+ this.#state = 'closed';
361
+ logger.warn(formatCompact({
362
+ op: 'reconnect',
363
+ endpoint: this.#name,
364
+ ok: false,
365
+ error: `gave up after ${this.#attempt} attempts`,
366
+ }));
367
+ return;
368
+ }
369
+ const base = Math.min(
370
+ config.initialIntervalMs * config.multiplier ** this.#attempt,
371
+ config.maxIntervalMs,
372
+ );
373
+ const delay = base + Math.floor(this.#random() * config.jitterMs);
374
+ // 首次断开 WARN,后续重试静默为 DEBUG,避免刷屏(对齐 icqq)
375
+ const log = this.#attempt === 0 ? logger.warn.bind(logger) : logger.debug.bind(logger);
376
+ log(formatCompact({
377
+ op: 'reconnect',
378
+ endpoint: this.#name,
379
+ delay_ms: delay,
380
+ attempt: this.#attempt + 1,
381
+ }));
382
+ const elapsed = await this.#sleep(delay);
383
+ if (!elapsed || this.#currentState() !== 'reconnecting') return;
384
+ try {
385
+ await this.#runConnect(connect);
386
+ } catch (err) {
387
+ if (this.#currentState() === 'stopped') return;
388
+ this.#attempt += 1;
389
+ logger.debug(formatCompact({
390
+ op: 'reconnect',
391
+ endpoint: this.#name,
392
+ ok: false,
393
+ attempt: this.#attempt,
394
+ error: err instanceof Error ? err.message : String(err),
395
+ }));
396
+ continue;
397
+ }
398
+ if (this.#currentState() === 'stopped') return;
399
+ this.#state = 'open';
400
+ this.#attempt = 0;
401
+ logger.info(formatCompact({ op: 'reconnect', endpoint: this.#name, ok: true }));
402
+ return;
403
+ }
404
+ }
405
+
406
+ /** 可中断 sleep:stop() 唤醒并返回 false。 */
407
+ #sleep(ms: number): Promise<boolean> {
408
+ return new Promise((resolve) => {
409
+ this.#reconnectTimer = setTimeout(() => {
410
+ this.#reconnectTimer = undefined;
411
+ this.#reconnectWake = undefined;
412
+ resolve(true);
413
+ }, ms);
414
+ this.#reconnectWake = resolve;
415
+ });
416
+ }
417
+ }
418
+
419
+ /** 创建端点生命周期基座实例(见文件头迁移指引)。 */
420
+ export function createEndpointLifecycle(options: EndpointLifecycleOptions): EndpointLifecycle {
421
+ return new EndpointLifecycleImpl(options);
422
+ }
@@ -1,11 +1,13 @@
1
1
  export interface EndpointFriend {
2
- readonly user_id: number;
2
+ /** 数字平台(QQ 系)用 number;Slack/LINE/微信系用 string。 */
3
+ readonly user_id: number | string;
3
4
  readonly nickname: string;
4
5
  readonly remark: string;
5
6
  }
6
7
 
7
8
  export interface EndpointGroup {
8
- readonly group_id: number;
9
+ /** 数字平台(QQ 系)用 number;Slack/LINE/微信系用 string。 */
10
+ readonly group_id: number | string;
9
11
  readonly name: string;
10
12
  }
11
13
 
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
+ /** @internal 适配器 projection(AdapterIndex),框架内部机制,不承诺不 break。 */
1
2
  export * from './adapter-index.js';
2
3
  export * from './credentials.js';
4
+ /** @public 用户侧创作面:`defineAdapter`(`adapters/` 约定目录默认导出,承诺 semver)。 */
3
5
  export * from './definition.js';
6
+ export * from './endpoint-commands.js';
7
+ export * from './endpoint-lifecycle.js';
4
8
  export * from './endpoint-management.js';
5
9
  export * from './provider.js';
6
10
  export { default } from './provider.js';
package/src/provider.ts CHANGED
@@ -9,6 +9,7 @@ const adapterFeature = defineFeatureProvider({
9
9
  protocol: 1,
10
10
  id: adapterFeatureId,
11
11
  authoring: {
12
+ setupMethod: 'addAdapter',
12
13
  conventions: [typeScriptModules({
13
14
  id: 'adapters-ts',
14
15
  directory: 'adapters',