@jhh96/light-app-bridge 1.0.6 → 1.1.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.
package/README.md CHANGED
@@ -54,6 +54,27 @@ bridge.onMessage((data, id) => {
54
54
  })
55
55
  ```
56
56
 
57
+ ## 动态绑定与 ping(1.1.0+,仅 plugin 角色生效)
58
+
59
+ 默认 `bindMode: 'fixed'`(首绑后固定对端,失联自动向原对端重连)。
60
+ 多轻应用窗口场景用 `dynamic`:插件轮询 `getContentContext()` 感知用户切换窗口,向新窗口探测握手——成功即切换绑定(`onRebind`),失败(新窗口未集成 SDK)保持旧绑定。
61
+
62
+ ```js
63
+ const bridge = new LightAppBridge('plugin', {
64
+ bindMode: 'dynamic', // 'fixed'(默认)| 'dynamic'
65
+ dynamicPollInterval: 2000, // 轮询当前轻应用间隔(ms)
66
+ migrateProbeTimeout: 3000, // 新窗口 hello 探测窗口(ms)
67
+ pingInterval: 10000, // 存活探测周期(ms),0=关闭
68
+ pingTimeout: 5000, // 单次 pong 等待(ms)
69
+ pingFailLimit: 3 // 连续失败阈值,达阈值后 hello/ack 兜底探测
70
+ })
71
+
72
+ bridge.onRebind((newPeerId, oldPeerId) => { /* 动态绑定已切换 */ })
73
+ bridge.onDisconnect((peerId) => { /* 对端失联(ping 与兜底探测均无响应) */ })
74
+ ```
75
+
76
+ ping 兼容性:对端 ≤1.0.6 不会回 pong,但握手逻辑仍在——连续 ping 失败后自动发 hello 兜底探测,收到 ack 即判定存活,不会误断;新旧版本可混布。
77
+
57
78
  ## 消息信封
58
79
 
59
80
  ```js
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { default } from './lightAppBridge';
2
2
  export { createBridge } from './lightAppBridge';
3
- export type { BridgeRole, EnvelopeType, Envelope, BridgeOptions, SendOptions, MessageHandler } from './lightAppBridge';
3
+ export type { BridgeRole, BindMode, EnvelopeType, Envelope, BridgeOptions, SendOptions, MessageHandler } from './lightAppBridge';
@@ -23,8 +23,15 @@
23
23
  */
24
24
  /** 角色:plugin=插件窗口(发起方),lightapp=轻应用窗口(接收方) */
25
25
  export type BridgeRole = 'plugin' | 'lightapp';
26
+ /**
27
+ * 绑定模式(仅 plugin 角色生效):
28
+ * - fixed 默认。首次握手绑定后固定对端,失联后向原对端重连。
29
+ * - dynamic 跟随当前激活的轻应用窗口:轮询 getContentContext 感知切换,
30
+ * 向新窗口探测握手(成功即切换绑定;失败——如未集成 SDK——保持旧绑定)。
31
+ */
32
+ export type BindMode = 'fixed' | 'dynamic';
26
33
  /** 信封类型 */
27
- export type EnvelopeType = 'hello' | 'ack' | 'req' | 'res';
34
+ export type EnvelopeType = 'hello' | 'ack' | 'req' | 'res' | 'ping' | 'pong';
28
35
  /** 消息信封:所有跨窗口通信的统一包装 */
29
36
  export interface Envelope {
30
37
  __sdk: string;
@@ -39,6 +46,36 @@ export interface BridgeOptions {
39
46
  timeout?: number;
40
47
  /** 握手超时(ms),默认 30000。仅 plugin 角色生效;lightapp 端持续等待,不超时 */
41
48
  handshakeTimeout?: number;
49
+ /**
50
+ * 绑定模式(仅 plugin 生效),默认 'fixed'。
51
+ * dynamic:轮询 getContentContext 跟随当前激活的轻应用窗口切换绑定。
52
+ */
53
+ bindMode?: BindMode;
54
+ /** dynamic 模式轮询 getContentContext 的间隔(ms),默认 2000 */
55
+ dynamicPollInterval?: number;
56
+ /** dynamic 模式迁移探测窗口:向新窗口发 hello 等 ack 的时长(ms),默认 3000 */
57
+ migrateProbeTimeout?: number;
58
+ /**
59
+ * ping 周期(ms),默认 10000;设 0 关闭 ping。
60
+ * plugin 角色在绑定成功后周期 ping 对端(lightapp 自动回 pong),
61
+ * 连续 pingTimeout 无 pong 且 hello/ack 兜底探测也失败时判定失联并触发
62
+ * onDisconnect。
63
+ */
64
+ pingInterval?: number;
65
+ /** 单次 pong 等待(ms),默认 5000 */
66
+ pingTimeout?: number;
67
+ /** 连续 ping 失败阈值(次),默认 3。达到后进入 hello/ack 兜底探测 */
68
+ pingFailLimit?: number;
69
+ /**
70
+ * 调试模式:开启后在控制台打印完整通信轨迹,便于排查 plugin ↔ lightapp
71
+ * 联调问题。覆盖:
72
+ * - 构造(角色/配置)、握手(hello/ack/连接建立/超时/重连)
73
+ * - 发送请求(send data + id)、底层投递(目标 webContentsId + 信封)
74
+ * - 收到消息(type/id/from/data)、响应返回(res 数据/超时)
75
+ * - ping/pong、动态绑定迁移、handler 异常、销毁
76
+ * 大 payload 截断到 500 字符防刷屏。
77
+ */
78
+ debug?: boolean;
42
79
  }
43
80
  /** send 单次调用选项 */
44
81
  export interface SendOptions {
@@ -56,6 +93,12 @@ export default class LightAppBridge {
56
93
  role: BridgeRole;
57
94
  timeout: number;
58
95
  handshakeTimeout: number;
96
+ bindMode: BindMode;
97
+ dynamicPollInterval: number;
98
+ migrateProbeTimeout: number;
99
+ pingInterval: number;
100
+ pingTimeout: number;
101
+ pingFailLimit: number;
59
102
  private _pending;
60
103
  private _messageHandlers;
61
104
  private _target;
@@ -72,12 +115,30 @@ export default class LightAppBridge {
72
115
  private _listenerReady;
73
116
  private _connectFailed;
74
117
  private _destroyed;
118
+ private _debug;
119
+ private _helloCount;
120
+ private _dynamicTimer;
121
+ private _migrating;
122
+ private _migrateTargetId;
123
+ private _migrateTimer;
124
+ private _migrateRetryTimer;
125
+ private _rebindCbs;
126
+ private _pingTimer;
127
+ private _pingWaiting;
128
+ private _pingFails;
129
+ private _pingResolve;
130
+ private _probeActive;
131
+ private _probeOk;
132
+ private _probeTimers;
133
+ private _disconnectCbs;
75
134
  /**
76
135
  * @param role 角色,省略时按 window.worklink.plugin 是否存在自动探测
77
136
  * @param options 超时配置
78
137
  * @throws 依赖未就绪时抛出(window.worklink;监听后端可稍后出现,不算构造失败)
79
138
  */
80
139
  constructor(role?: BridgeRole, options?: BridgeOptions);
140
+ /** debug 日志(options.debug 开启时打印,前缀带角色便于双端对照) */
141
+ private _log;
81
142
  /** 校验依赖是否就绪,缺失抛出明确异常 */
82
143
  private _checkDeps;
83
144
  /** 绑定消息监听:优先 waltz;无 waltz 时定义/包装 window.handleMessageFromNative */
@@ -113,6 +174,42 @@ export default class LightAppBridge {
113
174
  private _clearHelloTimer;
114
175
  /** 监听连接建立(握手完成)。若已连接则立即触发 */
115
176
  onConnect(cb: (peerId: number | null) => void): this;
177
+ /** 连接建立后启动目标轮询(幂等;非 dynamic / 非 plugin 不启动) */
178
+ private _startDynamicPoll;
179
+ private _stopDynamicPoll;
180
+ /**
181
+ * 解析当前激活的轻应用 webContentsId;变化则发起迁移探测。
182
+ * 失联恢复(onDisconnect 后)也复用此入口强制解析。
183
+ */
184
+ private _pollTarget;
185
+ /**
186
+ * 向新目标发 hello 探测(HELLO_RETRY_INTERVAL 重试)。
187
+ * - 探测窗口内收到 ack(_onAck → _rebindTo):切换绑定
188
+ * - 超时(新窗口未集成 SDK):放弃,保持旧绑定,等下一轮轮询再探
189
+ */
190
+ private _startMigrate;
191
+ private _endMigrate;
192
+ /** 迁移成功:切换绑定目标并通知(连接语义连续,ready() 不受影响) */
193
+ private _rebindTo;
194
+ /** 监听动态绑定切换(bindMode='dynamic' 时目标窗口变化并成功迁移后触发一次) */
195
+ onRebind(cb: (newPeerId: number | null, oldPeerId: number | null) => void): this;
196
+ /** 绑定成功后启动周期 ping(幂等;pingInterval=0 或非 plugin 不启动) */
197
+ private _startPing;
198
+ private _stopPing;
199
+ /** 发一次 ping:等 pong → 失败计数 → 达阈值后 hello/ack 兜底探测 → 仍失败判定失联 */
200
+ private _pingOnce;
201
+ /** 收到 pong:唤醒在途 ping */
202
+ private _onPong;
203
+ /**
204
+ * ping 连续失败后的兜底探测:向对端发 hello 等 ack。
205
+ * 旧版对端(无 pong 能力)握手逻辑仍在会回 ack —— 收到即判定存活,避免误杀。
206
+ * ack 也无响应才判定真失联。
207
+ */
208
+ private _probeAlive;
209
+ /** 判定失联:触发 onDisconnect,并按绑定模式恢复 */
210
+ private _handleDisconnect;
211
+ /** 监听对端失联(ping 与 hello 兜底探测均无响应时触发一次) */
212
+ onDisconnect(cb: (peerId: number | null) => void): this;
116
213
  /**
117
214
  * 等待连接建立(握手完成)。构造时已自动发起握手,直接 await 即可。
118
215
  * @returns 对端 webContentsId
@@ -142,7 +239,7 @@ export default class LightAppBridge {
142
239
  * @returns 调用以取消监听
143
240
  */
144
241
  onMessage(handler: MessageHandler): () => void;
145
- /** 销毁实例:解绑消息监听、清空待处理请求、停止握手重试。销毁后实例不可再用 */
242
+ /** 销毁实例:解绑消息监听、清空待处理请求、停止握手/轮询/迁移/ping。销毁后实例不可再用 */
146
243
  destroy(): void;
147
244
  }
148
245
  /**
@@ -26,7 +26,27 @@ const PROTOCOL_VERSION = 1; // 协议版本号
26
26
  const DEFAULT_TIMEOUT = 3000; // send 默认超时(ms)
27
27
  const DEFAULT_HANDSHAKE_TIMEOUT = 30000; // 握手超时(ms)
28
28
  const HELLO_RETRY_INTERVAL = 1000; // 插件 hello 重试间隔(ms)
29
+ const DEBUG_PAYLOAD_LIMIT = 500; // debug 日志 payload 截断长度
30
+ const DEFAULT_BIND_MODE = 'fixed'; // 绑定模式默认值
31
+ const DEFAULT_DYNAMIC_POLL_INTERVAL = 2000; // dynamic 轮询间隔(ms)
32
+ const DEFAULT_MIGRATE_PROBE_TIMEOUT = 3000; // 迁移探测窗口(ms)
33
+ const DEFAULT_PING_INTERVAL = 10000; // ping 周期(ms)
34
+ const DEFAULT_PING_TIMEOUT = 5000; // 单次 pong 等待(ms)
35
+ const DEFAULT_PING_FAIL_LIMIT = 3; // 连续 ping 失败阈值(次)
29
36
  const genId = () => 'r_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
37
+ /** debug 日志用:安全序列化 payload,超长截断 */
38
+ function debugPayload(data) {
39
+ let s;
40
+ try {
41
+ s = typeof data === 'string' ? data : JSON.stringify(data);
42
+ }
43
+ catch {
44
+ s = String(data);
45
+ }
46
+ if (s === undefined)
47
+ s = 'undefined';
48
+ return s.length > DEBUG_PAYLOAD_LIMIT ? s.slice(0, DEBUG_PAYLOAD_LIMIT) + `…(截断,共${s.length}字符)` : s;
49
+ }
30
50
  /** 判断是否为本 SDK 的消息(按 __sdk 标识过滤) */
31
51
  const isBridgeMsg = (msg) => !!(msg && typeof msg === 'object' && msg.__sdk === SDK_ID);
32
52
  /** 创建一个可外部 resolve 的 Promise */
@@ -165,6 +185,23 @@ export default class LightAppBridge {
165
185
  this._listenerReady = null;
166
186
  this._connectFailed = false;
167
187
  this._destroyed = false;
188
+ this._helloCount = 0;
189
+ // —— 动态绑定(plugin + bindMode='dynamic')——
190
+ this._dynamicTimer = null;
191
+ this._migrating = false;
192
+ this._migrateTargetId = null;
193
+ this._migrateTimer = null;
194
+ this._migrateRetryTimer = null;
195
+ this._rebindCbs = [];
196
+ // —— ping/pong 存活探测(plugin 主动)——
197
+ this._pingTimer = null;
198
+ this._pingWaiting = false; // 一次 ping 在途(等 pong)
199
+ this._pingFails = 0; // 连续 pong 超时计数
200
+ this._pingResolve = null; // 当前在途 ping 的完成回调
201
+ this._probeActive = false; // hello/ack 兜底探测进行中
202
+ this._probeOk = false; // 兜底探测是否收到 ack
203
+ this._probeTimers = []; // 防 destroy 后悬挂
204
+ this._disconnectCbs = [];
168
205
  let r = role === 'plugin' || role === 'lightapp' ? role : 'lightapp';
169
206
  if (role !== 'plugin' && role !== 'lightapp') {
170
207
  const wl = window.worklink;
@@ -173,6 +210,20 @@ export default class LightAppBridge {
173
210
  this.role = r;
174
211
  this.timeout = options.timeout != null ? options.timeout : DEFAULT_TIMEOUT;
175
212
  this.handshakeTimeout = options.handshakeTimeout != null ? options.handshakeTimeout : DEFAULT_HANDSHAKE_TIMEOUT;
213
+ this.bindMode = options.bindMode === 'dynamic' ? 'dynamic' : DEFAULT_BIND_MODE;
214
+ this.dynamicPollInterval = options.dynamicPollInterval != null ? options.dynamicPollInterval : DEFAULT_DYNAMIC_POLL_INTERVAL;
215
+ this.migrateProbeTimeout = options.migrateProbeTimeout != null ? options.migrateProbeTimeout : DEFAULT_MIGRATE_PROBE_TIMEOUT;
216
+ this.pingInterval = options.pingInterval != null ? options.pingInterval : DEFAULT_PING_INTERVAL;
217
+ this.pingTimeout = options.pingTimeout != null ? options.pingTimeout : DEFAULT_PING_TIMEOUT;
218
+ this.pingFailLimit = options.pingFailLimit != null ? options.pingFailLimit : DEFAULT_PING_FAIL_LIMIT;
219
+ this._debug = !!options.debug;
220
+ this._log('构造: role=' + r + ', timeout=' + this.timeout +
221
+ ', handshakeTimeout=' + this.handshakeTimeout +
222
+ ', bindMode=' + this.bindMode +
223
+ (this.bindMode === 'dynamic' ? '(poll=' + this.dynamicPollInterval + 'ms)' : '') +
224
+ ', ping=' + (this.pingInterval > 0 ? this.pingInterval + 'ms' : 'off') +
225
+ ', 监听后端=' + (hasWaltz() ? 'waltz' : 'handleMessageFromNative') +
226
+ ', 自身id=' + this.getId());
176
227
  // 连接 deferred:构造时即建立,保证轻应用收到 hello 也能 resolve(无需显式 connect)
177
228
  this._connectDeferred = deferred();
178
229
  this._connectPromise = this._connectDeferred.promise.then((peerId) => {
@@ -183,6 +234,12 @@ export default class LightAppBridge {
183
234
  this._startListener(); // 绑定消息监听(waltz 或定义 handleMessageFromNative)
184
235
  this.connect(); // 构造即自动发起握手(内部会等监听就绪)
185
236
  }
237
+ /** debug 日志(options.debug 开启时打印,前缀带角色便于双端对照) */
238
+ _log(...args) {
239
+ if (!this._debug)
240
+ return;
241
+ console.log(`[LightAppBridge:${this.role}]`, ...args);
242
+ }
186
243
  /** 校验依赖是否就绪,缺失抛出明确异常 */
187
244
  _checkDeps() {
188
245
  const w = window;
@@ -218,8 +275,12 @@ export default class LightAppBridge {
218
275
  _onMessage(msg) {
219
276
  const envelope = msg.envelope;
220
277
  const fromId = msg.fromWebContentsId;
221
- if (!isBridgeMsg(envelope))
278
+ if (!isBridgeMsg(envelope)) {
279
+ this._log('recv← 忽略非本SDK消息: from=' + fromId);
222
280
  return;
281
+ }
282
+ this._log('recv← type=' + envelope.type + ' id=' + envelope.id +
283
+ ' from=' + fromId + ' data=' + debugPayload(envelope.data));
223
284
  switch (envelope.type) {
224
285
  case 'hello':
225
286
  this._onHello(fromId);
@@ -234,6 +295,13 @@ export default class LightAppBridge {
234
295
  case 'res':
235
296
  this._serveRes(envelope);
236
297
  break;
298
+ case 'ping':
299
+ // 存活探测:立即回 pong(data 原样带回),无论何种角色/模式都应答
300
+ this._send(fromId, this._envelope('pong', envelope.data, envelope.id));
301
+ break;
302
+ case 'pong':
303
+ this._onPong(envelope);
304
+ break;
237
305
  }
238
306
  }
239
307
  // —— 连接 / 握手 —— //
@@ -277,7 +345,19 @@ export default class LightAppBridge {
277
345
  if (this._connected)
278
346
  return;
279
347
  if (this.role === 'plugin') {
280
- // 插件:解析目标后发 hello,并重试直到收到 ack
348
+ // 重连场景(失联后恢复):已有原目标时不再重新解析 getContentContext——
349
+ // 失联期间用户可能已切换轻应用窗口,重新解析会让 fixed 模式"漂移"到新窗口,
350
+ // 违背固定对端语义。固定模式坚持向原对端重连(对端彻底没了则超时失败)。
351
+ // dynamic 模式的窗口跟随走独立的迁移状态机(_pollTarget),不经此路径。
352
+ if (this._target != null) {
353
+ this._sendHello();
354
+ this._helloTimer = setInterval(() => {
355
+ if (!this._connected)
356
+ this._sendHello();
357
+ }, HELLO_RETRY_INTERVAL);
358
+ return;
359
+ }
360
+ // 首次连接:解析目标后发 hello,并重试直到收到 ack
281
361
  return window.worklink.plugin.getContentContext()
282
362
  .then((ctx) => {
283
363
  if (!ctx || ctx.webContentsId == null) {
@@ -316,12 +396,15 @@ export default class LightAppBridge {
316
396
  if (this._destroyed)
317
397
  return;
318
398
  this._connectFailed = true;
399
+ this._log('✗ 握手失败: ' + (e && e.message ? e.message : String(e)));
319
400
  this._connectDeferred.reject(e instanceof Error ? e : new Error(String(e)));
320
401
  }
321
402
  /** 发送 hello(携带自身 webContentsId) */
322
403
  _sendHello() {
323
404
  if (this._target == null)
324
405
  return;
406
+ this._helloCount++;
407
+ this._log('hello→ 目标=' + this._target + (this._helloCount > 1 ? ' (重试#' + this._helloCount + ')' : ''));
325
408
  this._send(this._target, this._envelope('hello', { from: this.getId() }));
326
409
  }
327
410
  /** 收到 hello(轻应用侧):记录对端、回 ack、完成握手 */
@@ -338,6 +421,17 @@ export default class LightAppBridge {
338
421
  _onAck(fromId) {
339
422
  if (fromId == null)
340
423
  return; // 无法定位对端则忽略
424
+ // ping 失败后的 hello/ack 兜底探测:收到 ack 即对端存活(旧版对端不会回 pong
425
+ // 但握手逻辑仍在),标记探测成功
426
+ if (this._probeActive) {
427
+ this._probeOk = true;
428
+ this._log('✓ ping兜底探测: 对端存活(ack) from=' + fromId);
429
+ }
430
+ // 动态绑定迁移中:仅当 ack 来自迁移目标才完成切换
431
+ if (this._migrating && fromId === this._migrateTargetId) {
432
+ this._rebindTo(fromId);
433
+ return;
434
+ }
341
435
  this._recordPeer(fromId);
342
436
  this._connectDeferred.resolve(fromId);
343
437
  }
@@ -347,11 +441,15 @@ export default class LightAppBridge {
347
441
  }
348
442
  _onConnected(peerId) {
349
443
  this._connected = true;
444
+ this._log('✓ 连接建立: 对端=' + peerId + (this._helloCount > 1 ? ' (hello共' + this._helloCount + '次)' : ''));
350
445
  this._clearHelloTimer();
351
446
  if (this._handshakeTimer) {
352
447
  clearTimeout(this._handshakeTimer);
353
448
  this._handshakeTimer = null;
354
449
  }
450
+ // 绑定成功后启动存活探测与(dynamic 模式)目标轮询
451
+ this._startPing();
452
+ this._startDynamicPoll();
355
453
  // 触发 onConnect 回调
356
454
  const cbs = this._connectCbs.splice(0);
357
455
  cbs.forEach((cb) => {
@@ -375,6 +473,230 @@ export default class LightAppBridge {
375
473
  this._connectCbs.push(cb);
376
474
  return this;
377
475
  }
476
+ // —— 动态绑定(bindMode='dynamic',仅 plugin)—— //
477
+ /** 连接建立后启动目标轮询(幂等;非 dynamic / 非 plugin 不启动) */
478
+ _startDynamicPoll() {
479
+ if (this.role !== 'plugin' || this.bindMode !== 'dynamic')
480
+ return;
481
+ if (this._dynamicTimer || this._destroyed)
482
+ return;
483
+ this._log('dynamic: 启动目标轮询 (interval=' + this.dynamicPollInterval + 'ms)');
484
+ this._dynamicTimer = setInterval(() => {
485
+ this._pollTarget().catch(() => { });
486
+ }, this.dynamicPollInterval);
487
+ }
488
+ _stopDynamicPoll() {
489
+ if (this._dynamicTimer) {
490
+ clearInterval(this._dynamicTimer);
491
+ this._dynamicTimer = null;
492
+ }
493
+ this._endMigrate();
494
+ }
495
+ /**
496
+ * 解析当前激活的轻应用 webContentsId;变化则发起迁移探测。
497
+ * 失联恢复(onDisconnect 后)也复用此入口强制解析。
498
+ */
499
+ async _pollTarget() {
500
+ if (this._destroyed || this.role !== 'plugin')
501
+ return;
502
+ const ctx = await window.worklink.plugin.getContentContext();
503
+ const ctxId = ctx && ctx.webContentsId != null ? ctx.webContentsId : null;
504
+ if (ctxId == null || ctxId === this._target)
505
+ return;
506
+ if (this._migrating)
507
+ return; // 上一轮迁移探测进行中,等它结束
508
+ this._startMigrate(ctxId);
509
+ }
510
+ /**
511
+ * 向新目标发 hello 探测(HELLO_RETRY_INTERVAL 重试)。
512
+ * - 探测窗口内收到 ack(_onAck → _rebindTo):切换绑定
513
+ * - 超时(新窗口未集成 SDK):放弃,保持旧绑定,等下一轮轮询再探
514
+ */
515
+ _startMigrate(newTargetId) {
516
+ const oldId = this._peer;
517
+ this._migrating = true;
518
+ this._migrateTargetId = newTargetId;
519
+ this._log('dynamic: 目标变化 ' + oldId + ' → ' + newTargetId + ',迁移探测开始');
520
+ const probe = () => {
521
+ if (!this._migrating)
522
+ return;
523
+ this._send(newTargetId, this._envelope('hello', { from: this.getId() }));
524
+ };
525
+ probe();
526
+ this._migrateRetryTimer = setInterval(probe, HELLO_RETRY_INTERVAL);
527
+ this._migrateTimer = setTimeout(() => {
528
+ if (this._migrating) {
529
+ this._log('dynamic: 迁移探测超时(' + this.migrateProbeTimeout + 'ms),目标 ' +
530
+ newTargetId + ' 未集成 SDK,保持原绑定 ' + oldId);
531
+ this._endMigrate();
532
+ }
533
+ }, this.migrateProbeTimeout);
534
+ }
535
+ _endMigrate() {
536
+ this._migrating = false;
537
+ this._migrateTargetId = null;
538
+ if (this._migrateTimer) {
539
+ clearTimeout(this._migrateTimer);
540
+ this._migrateTimer = null;
541
+ }
542
+ if (this._migrateRetryTimer) {
543
+ clearInterval(this._migrateRetryTimer);
544
+ this._migrateRetryTimer = null;
545
+ }
546
+ }
547
+ /** 迁移成功:切换绑定目标并通知(连接语义连续,ready() 不受影响) */
548
+ _rebindTo(newPeerId) {
549
+ const oldId = this._peer;
550
+ this._endMigrate();
551
+ this._target = newPeerId;
552
+ this._peer = newPeerId;
553
+ // 失联恢复场景(onDisconnect 后迁移成功):恢复连接态并重启 ping;
554
+ // 常规迁移(本来就连接着)时这些操作幂等。
555
+ this._connected = true;
556
+ this._startPing();
557
+ this._startDynamicPoll();
558
+ this._log('✓ dynamic: 重新绑定 ' + oldId + ' → ' + newPeerId);
559
+ // ping 计数清零并立即探测新对端(新窗口刚握手成功,顺带验证 pong 能力)
560
+ this._pingFails = 0;
561
+ this._pingOnce();
562
+ const cbs = this._rebindCbs.splice(0);
563
+ cbs.forEach((cb) => {
564
+ try {
565
+ cb(newPeerId, oldId);
566
+ }
567
+ catch { /* ignore */ }
568
+ });
569
+ }
570
+ /** 监听动态绑定切换(bindMode='dynamic' 时目标窗口变化并成功迁移后触发一次) */
571
+ onRebind(cb) {
572
+ this._rebindCbs.push(cb);
573
+ return this;
574
+ }
575
+ // —— ping/pong 存活探测(plugin 主动发,lightapp 自动回 pong)—— //
576
+ /** 绑定成功后启动周期 ping(幂等;pingInterval=0 或非 plugin 不启动) */
577
+ _startPing() {
578
+ if (this.role !== 'plugin' || this.pingInterval <= 0)
579
+ return;
580
+ if (this._pingTimer || this._destroyed)
581
+ return;
582
+ this._pingTimer = setInterval(() => this._pingOnce(), this.pingInterval);
583
+ }
584
+ _stopPing() {
585
+ if (this._pingTimer) {
586
+ clearInterval(this._pingTimer);
587
+ this._pingTimer = null;
588
+ }
589
+ this._pingWaiting = false;
590
+ this._pingFails = 0;
591
+ }
592
+ /** 发一次 ping:等 pong → 失败计数 → 达阈值后 hello/ack 兜底探测 → 仍失败判定失联 */
593
+ _pingOnce() {
594
+ if (this._destroyed || !this._connected || this._pingWaiting)
595
+ return;
596
+ const id = genId();
597
+ this._pingWaiting = true;
598
+ let settled = false;
599
+ const finish = (ok) => {
600
+ if (settled)
601
+ return;
602
+ settled = true;
603
+ this._pingWaiting = false;
604
+ this._pingResolve = null;
605
+ if (ok) {
606
+ if (this._pingFails > 0)
607
+ this._log('✓ pong 恢复 (此前失败' + this._pingFails + '次)');
608
+ this._pingFails = 0;
609
+ return;
610
+ }
611
+ this._pingFails++;
612
+ this._log('✗ pong 超时 ' + this._pingFails + '/' + this.pingFailLimit);
613
+ if (this._pingFails >= this.pingFailLimit) {
614
+ this._probeAlive();
615
+ }
616
+ };
617
+ // pong 到达回调(_onPong 触发)
618
+ this._pingResolve = () => finish(true);
619
+ setTimeout(() => finish(false), this.pingTimeout);
620
+ try {
621
+ this._send(this._peer, this._envelope('ping', { t: Date.now() }, id));
622
+ }
623
+ catch (e) {
624
+ // 底层投递异常(invoke 瞬时不可用 / peer 为空)按本次失败计,不冒泡进 interval
625
+ this._log('✗ ping 发送异常: ' + (e instanceof Error ? e.message : String(e)));
626
+ finish(false);
627
+ }
628
+ }
629
+ /** 收到 pong:唤醒在途 ping */
630
+ _onPong(envelope) {
631
+ if (this._pingResolve)
632
+ this._pingResolve();
633
+ }
634
+ /**
635
+ * ping 连续失败后的兜底探测:向对端发 hello 等 ack。
636
+ * 旧版对端(无 pong 能力)握手逻辑仍在会回 ack —— 收到即判定存活,避免误杀。
637
+ * ack 也无响应才判定真失联。
638
+ */
639
+ _probeAlive() {
640
+ if (this._destroyed || !this._connected)
641
+ return;
642
+ this._log('ping 兜底探测: 发 hello → ' + this._peer);
643
+ this._probeActive = true;
644
+ this._probeOk = false;
645
+ // 真发一条 hello:旧版对端(无 pong 能力)的握手逻辑仍在,会回 ack 证明存活。
646
+ // 发送异常按探测失败处理(timer 到期自然判定),不冒泡。
647
+ try {
648
+ this._send(this._peer, this._envelope('hello', { from: this.getId() }));
649
+ }
650
+ catch { /* timer 到期后按无响应处理 */ }
651
+ const timer = setTimeout(() => {
652
+ const active = this._probeActive;
653
+ const ok = this._probeOk;
654
+ this._probeActive = false;
655
+ if (!active)
656
+ return;
657
+ if (ok) {
658
+ // 旧版对端存活(无 pong 能力):清计数继续常规 ping(下轮失败仍会兜底,无害)
659
+ this._log('✓ ping 兜底探测: 对端存活(旧版无pong)');
660
+ this._pingFails = 0;
661
+ }
662
+ else {
663
+ this._handleDisconnect();
664
+ }
665
+ }, this.pingTimeout);
666
+ // 防 destroy 后悬挂 timer
667
+ this._probeTimers.push(timer);
668
+ }
669
+ /** 判定失联:触发 onDisconnect,并按绑定模式恢复 */
670
+ _handleDisconnect() {
671
+ const deadId = this._peer;
672
+ this._log('✗ 对端失联: ' + deadId + '(ping + hello 探测均无响应)');
673
+ this._connected = false;
674
+ this._stopPing();
675
+ const cbs = this._disconnectCbs.splice(0);
676
+ cbs.forEach((cb) => {
677
+ try {
678
+ cb(deadId);
679
+ }
680
+ catch { /* ignore */ }
681
+ });
682
+ if (this._destroyed)
683
+ return;
684
+ if (this.bindMode === 'dynamic') {
685
+ // dynamic:立即解析当前激活轻应用并尝试迁移(找不到可绑目标则等下轮轮询)
686
+ this._startDynamicPoll();
687
+ this._pollTarget().catch(() => { });
688
+ }
689
+ else {
690
+ // fixed:自动向原对端重新握手(复用失败重连逻辑)
691
+ this._resetConnectState();
692
+ this.connect().catch(() => { });
693
+ }
694
+ }
695
+ /** 监听对端失联(ping 与 hello 兜底探测均无响应时触发一次) */
696
+ onDisconnect(cb) {
697
+ this._disconnectCbs.push(cb);
698
+ return this;
699
+ }
378
700
  /**
379
701
  * 等待连接建立(握手完成)。构造时已自动发起握手,直接 await 即可。
380
702
  * @returns 对端 webContentsId
@@ -399,19 +721,24 @@ export default class LightAppBridge {
399
721
  Promise.allSettled(results).then((settled) => {
400
722
  for (const s of settled) {
401
723
  if (s.status === 'fulfilled' && s.value !== undefined) {
724
+ this._log('res→ id=' + id + ' data=' + debugPayload(s.value));
402
725
  this._send(this._peer, this._envelope('res', s.value, id));
403
726
  return;
404
727
  }
405
728
  }
729
+ this._log('✗ req 无可回复值(回调全空/异常): id=' + id);
406
730
  });
407
731
  }
408
732
  /** 收到 res:按 id 唤醒等待中的 send */
409
733
  _serveRes(envelope) {
410
734
  const p = this._pending.get(envelope.id);
411
- if (!p)
735
+ if (!p) {
736
+ this._log('recv← res 无等待者(可能已超时): id=' + envelope.id);
412
737
  return;
738
+ }
413
739
  clearTimeout(p.timer);
414
740
  this._pending.delete(envelope.id);
741
+ this._log('✓ 收到响应 id=' + envelope.id + ' data=' + debugPayload(envelope.data));
415
742
  p.resolve(envelope.data);
416
743
  }
417
744
  /** 底层发送:把信封投递到指定 webContentsId */
@@ -442,13 +769,18 @@ export default class LightAppBridge {
442
769
  if (this._destroyed)
443
770
  return Promise.reject(new Error('实例已销毁'));
444
771
  const timeout = options.timeout != null ? options.timeout : this.timeout;
445
- return this.ready().then((targetId) => {
772
+ return this.ready().then(() => {
773
+ // 发送目标取当前 _peer(而非 ready() resolve 的快照):
774
+ // dynamic 模式迁移重绑后,后续 send 立即发往新对端。
775
+ const targetId = this._peer;
446
776
  const id = genId();
447
777
  const envelope = this._envelope('req', data, id);
778
+ this._log('send→ id=' + id + ' 目标=' + targetId + ' data=' + debugPayload(data));
448
779
  return new Promise((resolve, reject) => {
449
780
  const timer = setTimeout(() => {
450
781
  if (this._pending.has(id)) {
451
782
  this._pending.delete(id);
783
+ this._log('✗ 超时 id=' + id + ' (' + timeout + 'ms 无响应)');
452
784
  reject(new Error(`请求超时 (${timeout}ms)`));
453
785
  }
454
786
  }, timeout);
@@ -476,11 +808,12 @@ export default class LightAppBridge {
476
808
  this._messageHandlers.splice(i, 1);
477
809
  };
478
810
  }
479
- /** 销毁实例:解绑消息监听、清空待处理请求、停止握手重试。销毁后实例不可再用 */
811
+ /** 销毁实例:解绑消息监听、清空待处理请求、停止握手/轮询/迁移/ping。销毁后实例不可再用 */
480
812
  destroy() {
481
813
  if (this._destroyed)
482
814
  return;
483
815
  this._destroyed = true;
816
+ this._log('销毁实例: 未完成请求=' + this._pending.size);
484
817
  if (this._unlisten) {
485
818
  try {
486
819
  this._unlisten();
@@ -489,6 +822,11 @@ export default class LightAppBridge {
489
822
  this._unlisten = null;
490
823
  }
491
824
  this._clearHelloTimer();
825
+ this._stopDynamicPoll();
826
+ this._stopPing();
827
+ this._probeTimers.forEach((t) => clearTimeout(t));
828
+ this._probeTimers.length = 0;
829
+ this._probeActive = false;
492
830
  if (this._handshakeTimer) {
493
831
  clearTimeout(this._handshakeTimer);
494
832
  this._handshakeTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhh96/light-app-bridge",
3
- "version": "1.0.6",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "招商随行(WorkLink)插件窗口(plugin)与轻应用窗口(lightapp)之间的双向通信 SDK",
6
6
  "main": "./dist/index.js",