@yoooclaw/phone-notifications 1.11.13 → 1.11.14

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/dist/index.cjs CHANGED
@@ -5574,7 +5574,7 @@ function readBuildInjectedVersion() {
5574
5574
  if (false) {
5575
5575
  return void 0;
5576
5576
  }
5577
- const version = "1.11.13".trim();
5577
+ const version = "1.11.14".trim();
5578
5578
  return version || void 0;
5579
5579
  }
5580
5580
  function readPluginVersionFromPackageJson() {
@@ -12379,6 +12379,7 @@ var RelayClient = class {
12379
12379
  reconnectTimer = null;
12380
12380
  handlers = [];
12381
12381
  connectedHandlers = [];
12382
+ disconnectedHandlers = [];
12382
12383
  aborted = false;
12383
12384
  lastInboundAt = 0;
12384
12385
  stopPromise = null;
@@ -12407,6 +12408,10 @@ var RelayClient = class {
12407
12408
  onConnected(handler) {
12408
12409
  this.connectedHandlers.push(handler);
12409
12410
  }
12411
+ /** 注册 Relay 连接断开后的回调 */
12412
+ onDisconnected(handler) {
12413
+ this.disconnectedHandlers.push(handler);
12414
+ }
12410
12415
  /** 当前是否已连上 Relay */
12411
12416
  isConnected() {
12412
12417
  return this.ws?.readyState === wrapper_default.OPEN;
@@ -12480,23 +12485,41 @@ var RelayClient = class {
12480
12485
  }
12481
12486
  /** 发送出站帧 */
12482
12487
  send(frame) {
12483
- if (this.ws?.readyState === wrapper_default.OPEN) {
12488
+ const ws = this.ws;
12489
+ if (ws?.readyState === wrapper_default.OPEN) {
12484
12490
  const payload = JSON.stringify(frame);
12485
12491
  this.opts.logger.info(
12486
12492
  `Relay tunnel: \u25B6 send frame type=${frame.type}, id=${"id" in frame ? frame.id : "N/A"} (${payload.length} chars)`
12487
12493
  );
12488
- this.ws.send(payload);
12494
+ try {
12495
+ ws.send(payload);
12496
+ } catch (err2) {
12497
+ const message = err2 instanceof Error ? err2.message : String(err2);
12498
+ this.opts.logger.warn(
12499
+ `Relay tunnel: send failed, forcing reconnect: ${message}`
12500
+ );
12501
+ this.forceReconnectFromSocket(ws, `send-failed: ${message}`);
12502
+ }
12489
12503
  } else {
12490
12504
  this.logSendSkipped("send", `frame type=${frame.type}`);
12491
12505
  }
12492
12506
  }
12493
12507
  /** 原样透传文本到 Relay(用于 Gateway WS 响应直接回传) */
12494
12508
  sendRaw(text) {
12495
- if (this.ws?.readyState === wrapper_default.OPEN) {
12509
+ const ws = this.ws;
12510
+ if (ws?.readyState === wrapper_default.OPEN) {
12496
12511
  this.opts.logger.info(
12497
12512
  `Relay tunnel: \u25B6 sendRaw (${text.length} chars): ${text.length <= 500 ? text : text.substring(0, 500) + "\u2026"}`
12498
12513
  );
12499
- this.ws.send(text);
12514
+ try {
12515
+ ws.send(text);
12516
+ } catch (err2) {
12517
+ const message = err2 instanceof Error ? err2.message : String(err2);
12518
+ this.opts.logger.warn(
12519
+ `Relay tunnel: sendRaw failed, forcing reconnect: ${message}`
12520
+ );
12521
+ this.forceReconnectFromSocket(ws, `sendRaw-failed: ${message}`);
12522
+ }
12500
12523
  } else {
12501
12524
  this.logSendSkipped("sendRaw");
12502
12525
  }
@@ -12547,33 +12570,20 @@ var RelayClient = class {
12547
12570
  handshakeTimeout: HANDSHAKE_TIMEOUT_MS
12548
12571
  });
12549
12572
  this.ws = ws;
12550
- let connectWatchdog = setTimeout(() => {
12551
- connectWatchdog = null;
12552
- if (this.ws !== ws || ws.readyState === wrapper_default.OPEN) {
12553
- return;
12554
- }
12555
- this.opts.logger.warn(
12556
- `Relay tunnel: connect watchdog fired (readyState=${ws.readyState}, attempt=${this.reconnectAttempt}), forcing terminate`
12557
- );
12558
- try {
12559
- ws.terminate();
12560
- } catch {
12561
- }
12562
- if (this.ws === ws) {
12563
- setTimeout(() => {
12564
- if (this.ws === ws) {
12565
- this.opts.logger.warn(
12566
- "Relay tunnel: terminate did not emit close, forcing reconnect"
12567
- );
12568
- this.stopHeartbeat();
12569
- this.ws = null;
12570
- this.writeStatus("disconnected", "watchdog-force");
12571
- this.scheduleReconnect();
12572
- settle();
12573
- }
12574
- }, 1e3);
12575
- }
12576
- }, CONNECT_WATCHDOG_MS);
12573
+ let connectWatchdog = setTimeout(
12574
+ () => {
12575
+ connectWatchdog = null;
12576
+ if (this.ws !== ws || ws.readyState === wrapper_default.OPEN) {
12577
+ return;
12578
+ }
12579
+ this.opts.logger.warn(
12580
+ `Relay tunnel: connect watchdog fired (readyState=${ws.readyState}, attempt=${this.reconnectAttempt}), forcing reconnect`
12581
+ );
12582
+ this.forceReconnectFromSocket(ws, "connect-watchdog-timeout");
12583
+ settle();
12584
+ },
12585
+ CONNECT_WATCHDOG_MS
12586
+ );
12577
12587
  const clearConnectWatchdog = () => {
12578
12588
  if (connectWatchdog) {
12579
12589
  clearTimeout(connectWatchdog);
@@ -12627,7 +12637,9 @@ var RelayClient = class {
12627
12637
  if (isCurrentSocket) {
12628
12638
  this.stopHeartbeat();
12629
12639
  this.ws = null;
12630
- this.writeStatus("disconnected", `code=${code}, reason=${reasonStr}`);
12640
+ const disconnectReason = `code=${code}, reason=${reasonStr}`;
12641
+ this.writeStatus("disconnected", disconnectReason);
12642
+ this.emitDisconnected(disconnectReason);
12631
12643
  this.scheduleReconnect();
12632
12644
  }
12633
12645
  settle();
@@ -12653,6 +12665,15 @@ var RelayClient = class {
12653
12665
  });
12654
12666
  }
12655
12667
  }
12668
+ emitDisconnected(reason) {
12669
+ for (const handler of this.disconnectedHandlers) {
12670
+ Promise.resolve(handler(reason)).catch((err2) => {
12671
+ this.opts.logger.warn(
12672
+ `Relay tunnel: onDisconnected handler failed: ${err2 instanceof Error ? err2.message : String(err2)}`
12673
+ );
12674
+ });
12675
+ }
12676
+ }
12656
12677
  handleMessage(data) {
12657
12678
  const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString() : String(data);
12658
12679
  this.markInboundActivity();
@@ -12719,11 +12740,22 @@ var RelayClient = class {
12719
12740
  this.opts.logger.info('Relay tunnel: \u2192 heartbeat "ping"');
12720
12741
  try {
12721
12742
  ws.send("ping");
12722
- } catch {
12743
+ } catch (err2) {
12744
+ const message = err2 instanceof Error ? err2.message : String(err2);
12745
+ this.opts.logger.warn(
12746
+ `Relay tunnel: heartbeat text ping failed, forcing reconnect: ${message}`
12747
+ );
12748
+ this.forceReconnectFromSocket(ws, `heartbeat-send-failed: ${message}`);
12749
+ return;
12723
12750
  }
12724
12751
  try {
12725
12752
  ws.ping();
12726
- } catch {
12753
+ } catch (err2) {
12754
+ const message = err2 instanceof Error ? err2.message : String(err2);
12755
+ this.opts.logger.warn(
12756
+ `Relay tunnel: heartbeat control ping failed, forcing reconnect: ${message}`
12757
+ );
12758
+ this.forceReconnectFromSocket(ws, `heartbeat-ping-failed: ${message}`);
12727
12759
  }
12728
12760
  }
12729
12761
  markInboundActivity() {
@@ -12755,6 +12787,7 @@ var RelayClient = class {
12755
12787
  this.stopHeartbeat();
12756
12788
  this.ws = null;
12757
12789
  this.writeStatus("disconnected", reason);
12790
+ this.emitDisconnected(reason);
12758
12791
  this.scheduleReconnect();
12759
12792
  try {
12760
12793
  if (ws.readyState !== wrapper_default.CLOSED) {
@@ -13054,6 +13087,7 @@ async function handleHttpRequest(opts, frame) {
13054
13087
  const res = await fetch(url.toString(), {
13055
13088
  method: frame.method,
13056
13089
  headers: attempt.headers,
13090
+ signal: opts.abortSignal,
13057
13091
  body: frame.method !== "GET" && frame.method !== "HEAD" ? frame.body : void 0
13058
13092
  });
13059
13093
  const hasFallback = attemptIndex < authAttempts.length - 1;
@@ -13122,6 +13156,9 @@ async function streamResponse(opts, requestId, res, startedAtMs) {
13122
13156
  opts.logger.info(`TunnelProxy: stream start id=${requestId}`);
13123
13157
  try {
13124
13158
  while (true) {
13159
+ if (opts.abortSignal?.aborted) {
13160
+ throw new DOMException("relay tunnel disconnected", "AbortError");
13161
+ }
13125
13162
  const { done, value } = await reader.read();
13126
13163
  if (done) break;
13127
13164
  chunkCount++;
@@ -13159,6 +13196,7 @@ async function streamResponse(opts, requestId, res, startedAtMs) {
13159
13196
  }
13160
13197
 
13161
13198
  // src/tunnel/ws-proxy.ts
13199
+ var GATEWAY_WS_HANDSHAKE_TIMEOUT_MS = 1e4;
13162
13200
  var WsProxy = class {
13163
13201
  constructor(opts) {
13164
13202
  this.opts = opts;
@@ -13169,14 +13207,15 @@ var WsProxy = class {
13169
13207
  return this.connections.size;
13170
13208
  }
13171
13209
  cleanup() {
13172
- for (const [id, ws] of this.connections) {
13210
+ const connections = [...this.connections.entries()];
13211
+ this.connections.clear();
13212
+ for (const [id, ws] of connections) {
13173
13213
  this.opts.logger.info(`WsProxy: closing WS id=${id}`);
13174
13214
  try {
13175
13215
  ws.close();
13176
13216
  } catch {
13177
13217
  }
13178
13218
  }
13179
- this.connections.clear();
13180
13219
  }
13181
13220
  handleWsOpen(frame) {
13182
13221
  const wsUrl = this.opts.gatewayBaseUrl.replace(/^http/, "ws") + mapPath(frame.path);
@@ -13184,11 +13223,72 @@ var WsProxy = class {
13184
13223
  `TunnelProxy: WS open id=${frame.id}, path=${frame.path} \u2192 ${wsUrl}`
13185
13224
  );
13186
13225
  try {
13226
+ const existing = this.connections.get(frame.id);
13227
+ if (existing) {
13228
+ this.opts.logger.warn(
13229
+ `TunnelProxy: WS id=${frame.id} already exists, closing previous gateway connection`
13230
+ );
13231
+ try {
13232
+ existing.close(1e3, "replaced by new relay ws_open");
13233
+ } catch {
13234
+ }
13235
+ }
13187
13236
  const ws = new wrapper_default(wsUrl, {
13188
- headers: frame.headers
13237
+ headers: frame.headers,
13238
+ handshakeTimeout: GATEWAY_WS_HANDSHAKE_TIMEOUT_MS
13189
13239
  });
13240
+ this.connections.set(frame.id, ws);
13241
+ let notifiedClosed = false;
13242
+ let connectTimer = setTimeout(
13243
+ () => {
13244
+ connectTimer = null;
13245
+ if (this.connections.get(frame.id) !== ws || ws.readyState === wrapper_default.OPEN) {
13246
+ return;
13247
+ }
13248
+ this.opts.logger.warn(
13249
+ `TunnelProxy: WS id=${frame.id} connect timed out after ${GATEWAY_WS_HANDSHAKE_TIMEOUT_MS}ms`
13250
+ );
13251
+ notifyClosed(1011, "gateway websocket connect timeout");
13252
+ try {
13253
+ if (typeof ws.terminate === "function") {
13254
+ ws.terminate();
13255
+ } else {
13256
+ ws.close();
13257
+ }
13258
+ } catch {
13259
+ }
13260
+ },
13261
+ GATEWAY_WS_HANDSHAKE_TIMEOUT_MS
13262
+ );
13263
+ const clearConnectTimer = () => {
13264
+ if (connectTimer) {
13265
+ clearTimeout(connectTimer);
13266
+ connectTimer = null;
13267
+ }
13268
+ };
13269
+ const notifyClosed = (code, reason) => {
13270
+ if (notifiedClosed) return;
13271
+ notifiedClosed = true;
13272
+ clearConnectTimer();
13273
+ if (this.connections.get(frame.id) === ws) {
13274
+ this.connections.delete(frame.id);
13275
+ }
13276
+ this.opts.client.send({
13277
+ type: "ws_close",
13278
+ id: frame.id,
13279
+ code,
13280
+ reason
13281
+ });
13282
+ };
13190
13283
  ws.on("open", () => {
13191
- this.connections.set(frame.id, ws);
13284
+ clearConnectTimer();
13285
+ if (this.connections.get(frame.id) !== ws) {
13286
+ try {
13287
+ ws.close(1e3, "stale relay ws_open");
13288
+ } catch {
13289
+ }
13290
+ return;
13291
+ }
13192
13292
  this.opts.logger.info(
13193
13293
  `TunnelProxy: WS id=${frame.id} connected to gateway, active=${this.connections.size}`
13194
13294
  );
@@ -13198,6 +13298,9 @@ var WsProxy = class {
13198
13298
  });
13199
13299
  });
13200
13300
  ws.on("message", (data) => {
13301
+ if (this.connections.get(frame.id) !== ws) {
13302
+ return;
13303
+ }
13201
13304
  const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString() : String(data);
13202
13305
  this.opts.logger.info(
13203
13306
  `TunnelProxy: WS id=${frame.id} \u2190 gateway data (${text.length} chars): ${text.substring(0, 200)}`
@@ -13209,28 +13312,33 @@ var WsProxy = class {
13209
13312
  });
13210
13313
  });
13211
13314
  ws.on("close", (code, reason) => {
13212
- this.connections.delete(frame.id);
13315
+ clearConnectTimer();
13316
+ const reasonText = reason.toString();
13317
+ const isCurrent = this.connections.get(frame.id) === ws;
13318
+ if (isCurrent) {
13319
+ this.connections.delete(frame.id);
13320
+ }
13213
13321
  this.opts.logger.info(
13214
- `TunnelProxy: WS id=${frame.id} closed by gateway (code=${code}, reason=${reason.toString()}), active=${this.connections.size}`
13322
+ `TunnelProxy: WS id=${frame.id} closed by gateway (code=${code}, reason=${reasonText}), active=${this.connections.size}`
13215
13323
  );
13216
- this.opts.client.send({
13217
- type: "ws_close",
13218
- id: frame.id,
13219
- code,
13220
- reason: reason.toString()
13221
- });
13324
+ if (isCurrent) {
13325
+ notifyClosed(code, reasonText);
13326
+ }
13222
13327
  });
13223
13328
  ws.on("error", (err2) => {
13329
+ clearConnectTimer();
13224
13330
  this.opts.logger.warn(
13225
13331
  `TunnelProxy: WS id=${frame.id} error: ${err2.message}, active=${this.connections.size}`
13226
13332
  );
13227
- this.connections.delete(frame.id);
13228
- this.opts.client.send({
13229
- type: "ws_close",
13230
- id: frame.id,
13231
- code: 1011,
13232
- reason: err2.message
13233
- });
13333
+ if (this.connections.get(frame.id) === ws) {
13334
+ notifyClosed(1011, err2.message);
13335
+ }
13336
+ try {
13337
+ if (typeof ws.terminate === "function") {
13338
+ ws.terminate();
13339
+ }
13340
+ } catch {
13341
+ }
13234
13342
  });
13235
13343
  } catch (err2) {
13236
13344
  this.opts.logger.error(
@@ -13280,6 +13388,8 @@ var WsProxy = class {
13280
13388
  var RELAY_TUNNEL_GATEWAY_CLIENT_INSTANCE_ID = "phone-notifications-relay-tunnel";
13281
13389
  var MAX_AUTO_PAIRING_APPROVALS = 3;
13282
13390
  var RECENT_ABORTED_CHAT_RUN_TTL_MS = 6e4;
13391
+ var GATEWAY_RPC_HANDSHAKE_TIMEOUT_MS = 1e4;
13392
+ var MAX_GATEWAY_WS_PENDING = 200;
13283
13393
  var approveDevicePairingPromise = null;
13284
13394
  var approveDevicePairingWarned = false;
13285
13395
  function formatErrorMessage(err2) {
@@ -13331,6 +13441,8 @@ var TunnelProxy = class {
13331
13441
  gatewayWsReady = false;
13332
13442
  /** 收到本地自动配对成功后,在 close 回调里触发重连 */
13333
13443
  gatewayWsReconnectRequested = false;
13444
+ /** 本地自动配对审批进行中,期间 Gateway 关闭连接时先保留 pending 请求。 */
13445
+ gatewayWsPairingApprovalPending = false;
13334
13446
  /** 防止配对失败时无限重连 */
13335
13447
  gatewayWsAutoPairingApprovals = 0;
13336
13448
  /** 等待 Gateway WS 握手完成后发送的帧队列 */
@@ -13339,6 +13451,8 @@ var TunnelProxy = class {
13339
13451
  gatewayReqMetaById = /* @__PURE__ */ new Map();
13340
13452
  /** 最近由用户手动中断的 chat.run,用于过滤 runtime 误补发的 synthetic failure。 */
13341
13453
  recentAbortedChatRuns = /* @__PURE__ */ new Map();
13454
+ /** Relay 断开或服务停止时要中断的 HTTP 代理请求。 */
13455
+ httpAbortControllers = /* @__PURE__ */ new Map();
13342
13456
  /** 设备身份,用于 Gateway connect 握手 */
13343
13457
  deviceIdentity;
13344
13458
  stateDir;
@@ -13352,7 +13466,7 @@ var TunnelProxy = class {
13352
13466
  );
13353
13467
  switch (frame.type) {
13354
13468
  case "request":
13355
- await handleHttpRequest(this.opts, frame);
13469
+ await this.handleRequestFrame(frame);
13356
13470
  break;
13357
13471
  case "req":
13358
13472
  this.handleReqFrame(frame);
@@ -13380,35 +13494,73 @@ var TunnelProxy = class {
13380
13494
  `TunnelProxy: cleanup, closing ${this.wsProxy.activeCount} active WS connections, gatewayWs=${!!this.gatewayWs}`
13381
13495
  );
13382
13496
  this.wsProxy.cleanup();
13383
- if (this.gatewayWs) {
13384
- try {
13385
- this.gatewayWs.close();
13386
- } catch {
13387
- }
13388
- this.gatewayWs = null;
13497
+ for (const [id, controller] of this.httpAbortControllers) {
13498
+ this.opts.logger.info(`TunnelProxy: aborting HTTP proxy id=${id}`);
13499
+ controller.abort();
13389
13500
  }
13501
+ this.httpAbortControllers.clear();
13502
+ const gatewayWs = this.gatewayWs;
13503
+ this.gatewayWs = null;
13390
13504
  this.gatewayWsReady = false;
13391
13505
  this.gatewayWsConnecting = false;
13392
13506
  this.gatewayWsReconnectRequested = false;
13507
+ this.gatewayWsPairingApprovalPending = false;
13393
13508
  this.gatewayWsAutoPairingApprovals = 0;
13394
13509
  this.gatewayWsPending = [];
13395
13510
  this.gatewayReqMetaById.clear();
13396
13511
  this.recentAbortedChatRuns.clear();
13512
+ if (gatewayWs) {
13513
+ try {
13514
+ gatewayWs.close();
13515
+ } catch {
13516
+ }
13517
+ }
13397
13518
  }
13398
13519
  // ─── Gateway RPC WebSocket ───
13399
- pushGatewayPending(payload, reason) {
13400
- this.gatewayWsPending.push(payload);
13520
+ async handleRequestFrame(frame) {
13521
+ const controller = new AbortController();
13522
+ this.httpAbortControllers.set(frame.id, controller);
13523
+ try {
13524
+ await handleHttpRequest(
13525
+ {
13526
+ ...this.opts,
13527
+ abortSignal: controller.signal
13528
+ },
13529
+ frame
13530
+ );
13531
+ } finally {
13532
+ this.httpAbortControllers.delete(frame.id);
13533
+ }
13534
+ }
13535
+ pushGatewayPending(entry, reason) {
13536
+ if (this.gatewayWsPending.length >= MAX_GATEWAY_WS_PENDING) {
13537
+ this.opts.logger.error(
13538
+ `TunnelProxy: gateway WS pending queue overflow (${this.gatewayWsPending.length}/${MAX_GATEWAY_WS_PENDING}); failing ${entry.reqId ? `req id=${entry.reqId}` : "raw frame"}`
13539
+ );
13540
+ this.failGatewayPendingEntry(
13541
+ entry,
13542
+ "GATEWAY_RPC_QUEUE_FULL",
13543
+ "local gateway RPC queue is full"
13544
+ );
13545
+ return false;
13546
+ }
13547
+ this.gatewayWsPending.push(entry);
13401
13548
  this.opts.logger.info(
13402
13549
  `TunnelProxy: gateway WS pending queue size=${this.gatewayWsPending.length} (${reason})`
13403
13550
  );
13551
+ return true;
13404
13552
  }
13405
13553
  /** 将未知帧原样转发到 Gateway WS */
13406
13554
  forwardRawToGateway(payload) {
13407
- this.ensureGatewayWs();
13408
13555
  if (this.gatewayWsReady && this.gatewayWs?.readyState === wrapper_default.OPEN) {
13409
- this.gatewayWs.send(payload);
13556
+ this.sendGatewayPayload(this.gatewayWs, { payload }, "raw frame");
13410
13557
  } else {
13411
- this.pushGatewayPending(payload, "raw frame queued before gateway WS ready");
13558
+ if (this.pushGatewayPending(
13559
+ { payload },
13560
+ "raw frame queued before gateway WS ready"
13561
+ )) {
13562
+ this.ensureGatewayWs();
13563
+ }
13412
13564
  }
13413
13565
  }
13414
13566
  /** 处理 Relay 转发的 Gateway RPC 请求帧,原样通过 WebSocket 发给本地 Gateway */
@@ -13421,17 +13573,99 @@ var TunnelProxy = class {
13421
13573
  this.opts.logger.info(
13422
13574
  `TunnelProxy: req id=${frame.id} method=${frame.method} \u2192 gateway WS (${payload.length} chars)`
13423
13575
  );
13424
- this.ensureGatewayWs();
13425
13576
  if (this.gatewayWsReady && this.gatewayWs?.readyState === wrapper_default.OPEN) {
13426
- this.gatewayWs.send(payload);
13577
+ this.sendGatewayPayload(
13578
+ this.gatewayWs,
13579
+ { payload, reqId: frame.id },
13580
+ `req id=${frame.id}`
13581
+ );
13427
13582
  } else {
13428
13583
  this.opts.logger.info(
13429
13584
  `TunnelProxy: req id=${frame.id} queued, gateway WS not ready yet`
13430
13585
  );
13431
- this.pushGatewayPending(
13432
- payload,
13586
+ if (this.pushGatewayPending(
13587
+ { payload, reqId: frame.id },
13433
13588
  `req id=${frame.id} queued before gateway WS handshake`
13589
+ )) {
13590
+ this.ensureGatewayWs();
13591
+ }
13592
+ }
13593
+ }
13594
+ sendGatewayRpcError(id, code, message) {
13595
+ this.gatewayReqMetaById.delete(id);
13596
+ this.opts.client.sendRaw(
13597
+ JSON.stringify({
13598
+ type: "res",
13599
+ id,
13600
+ ok: false,
13601
+ error: {
13602
+ code,
13603
+ message
13604
+ }
13605
+ })
13606
+ );
13607
+ }
13608
+ failGatewayPendingEntry(entry, code, message) {
13609
+ if (!entry.reqId) return;
13610
+ this.sendGatewayRpcError(entry.reqId, code, message);
13611
+ }
13612
+ failGatewayPending(code, message) {
13613
+ if (this.gatewayWsPending.length === 0) return;
13614
+ const pending = this.gatewayWsPending;
13615
+ this.gatewayWsPending = [];
13616
+ this.opts.logger.warn(
13617
+ `TunnelProxy: failing ${pending.length} pending gateway RPC frame(s): ${message}`
13618
+ );
13619
+ for (const entry of pending) {
13620
+ this.failGatewayPendingEntry(entry, code, message);
13621
+ }
13622
+ }
13623
+ failGatewayInflight(code, message) {
13624
+ if (this.gatewayReqMetaById.size === 0) return;
13625
+ const ids = [...this.gatewayReqMetaById.keys()];
13626
+ this.opts.logger.warn(
13627
+ `TunnelProxy: failing ${ids.length} in-flight gateway RPC request(s): ${message}`
13628
+ );
13629
+ for (const id of ids) {
13630
+ this.sendGatewayRpcError(id, code, message);
13631
+ }
13632
+ }
13633
+ failAllGatewayRequests(code, message) {
13634
+ this.failGatewayPending(code, message);
13635
+ this.failGatewayInflight(code, message);
13636
+ }
13637
+ sendGatewayPayload(ws, entry, context) {
13638
+ try {
13639
+ ws.send(entry.payload);
13640
+ return true;
13641
+ } catch (err2) {
13642
+ const message = formatErrorMessage(err2);
13643
+ this.opts.logger.warn(
13644
+ `TunnelProxy: RPC WS send failed for ${context}: ${message}`
13645
+ );
13646
+ this.failGatewayPendingEntry(
13647
+ entry,
13648
+ "GATEWAY_RPC_SEND_FAILED",
13649
+ `local gateway RPC send failed: ${message}`
13650
+ );
13651
+ this.failGatewayInflight(
13652
+ "GATEWAY_RPC_SEND_FAILED",
13653
+ "local gateway RPC connection closed after send failure"
13434
13654
  );
13655
+ if (this.gatewayWs === ws) {
13656
+ this.gatewayWs = null;
13657
+ this.gatewayWsReady = false;
13658
+ this.gatewayWsConnecting = false;
13659
+ }
13660
+ try {
13661
+ if (typeof ws.terminate === "function") {
13662
+ ws.terminate();
13663
+ } else {
13664
+ ws.close();
13665
+ }
13666
+ } catch {
13667
+ }
13668
+ return false;
13435
13669
  }
13436
13670
  }
13437
13671
  // ─── Gateway connect auth helpers ───
@@ -13511,13 +13745,16 @@ var TunnelProxy = class {
13511
13745
  );
13512
13746
  return false;
13513
13747
  }
13748
+ this.gatewayWsPairingApprovalPending = true;
13514
13749
  try {
13515
13750
  const approveDevicePairing = await loadApproveDevicePairing(this.opts.logger);
13516
13751
  if (!approveDevicePairing) {
13752
+ this.gatewayWsPairingApprovalPending = false;
13517
13753
  return false;
13518
13754
  }
13519
13755
  const approved = await approveDevicePairing(requestId, this.hostStateDir);
13520
13756
  if (!approved) {
13757
+ this.gatewayWsPairingApprovalPending = false;
13521
13758
  this.opts.logger.warn(
13522
13759
  `TunnelProxy: gateway pairing request ${requestId} not found in host state ${this.hostStateDir}`
13523
13760
  );
@@ -13525,11 +13762,13 @@ var TunnelProxy = class {
13525
13762
  }
13526
13763
  this.gatewayWsAutoPairingApprovals += 1;
13527
13764
  this.gatewayWsReconnectRequested = true;
13765
+ this.gatewayWsPairingApprovalPending = false;
13528
13766
  this.opts.logger.info(
13529
13767
  `TunnelProxy: auto-approved local gateway pairing request ${requestId} (reason=${reason || "not-paired"}, hostStateDir=${this.hostStateDir}, approval=${this.gatewayWsAutoPairingApprovals}/${MAX_AUTO_PAIRING_APPROVALS})`
13530
13768
  );
13531
13769
  return true;
13532
13770
  } catch (err2) {
13771
+ this.gatewayWsPairingApprovalPending = false;
13533
13772
  this.opts.logger.warn(
13534
13773
  `TunnelProxy: failed to auto-approve gateway pairing request ${requestId}: ${err2?.message ?? String(err2)}`
13535
13774
  );
@@ -13658,9 +13897,56 @@ var TunnelProxy = class {
13658
13897
  this.opts.logger.info(
13659
13898
  `TunnelProxy: RPC WS connecting to gateway ${wsUrl} (pending=${this.gatewayWsPending.length})`
13660
13899
  );
13661
- const ws = new wrapper_default(wsUrl);
13900
+ let ws;
13901
+ try {
13902
+ ws = new wrapper_default(wsUrl, {
13903
+ handshakeTimeout: GATEWAY_RPC_HANDSHAKE_TIMEOUT_MS
13904
+ });
13905
+ } catch (err2) {
13906
+ const message = formatErrorMessage(err2);
13907
+ this.gatewayWsConnecting = false;
13908
+ this.opts.logger.error(
13909
+ `TunnelProxy: RPC WS failed to create gateway connection: ${message}`
13910
+ );
13911
+ this.failAllGatewayRequests(
13912
+ "GATEWAY_RPC_CONNECT_FAILED",
13913
+ `local gateway RPC connect failed: ${message}`
13914
+ );
13915
+ return;
13916
+ }
13917
+ this.gatewayWs = ws;
13918
+ let handshakeTimer = setTimeout(
13919
+ () => {
13920
+ handshakeTimer = null;
13921
+ if (this.gatewayWs !== ws || this.gatewayWsReady) return;
13922
+ this.opts.logger.warn(
13923
+ `TunnelProxy: RPC WS handshake timed out after ${GATEWAY_RPC_HANDSHAKE_TIMEOUT_MS}ms (pending=${this.gatewayWsPending.length})`
13924
+ );
13925
+ this.gatewayWs = null;
13926
+ this.gatewayWsReady = false;
13927
+ this.gatewayWsConnecting = false;
13928
+ this.failAllGatewayRequests(
13929
+ "GATEWAY_RPC_HANDSHAKE_TIMEOUT",
13930
+ "local gateway RPC handshake timed out"
13931
+ );
13932
+ try {
13933
+ if (typeof ws.terminate === "function") {
13934
+ ws.terminate();
13935
+ } else {
13936
+ ws.close();
13937
+ }
13938
+ } catch {
13939
+ }
13940
+ },
13941
+ GATEWAY_RPC_HANDSHAKE_TIMEOUT_MS
13942
+ );
13943
+ const clearHandshakeTimer = () => {
13944
+ if (handshakeTimer) {
13945
+ clearTimeout(handshakeTimer);
13946
+ handshakeTimer = null;
13947
+ }
13948
+ };
13662
13949
  ws.on("open", () => {
13663
- this.gatewayWs = ws;
13664
13950
  this.opts.logger.info(
13665
13951
  `TunnelProxy: RPC WS tcp connected, waiting for connect.challenge (pending=${this.gatewayWsPending.length})`
13666
13952
  );
@@ -13692,6 +13978,7 @@ var TunnelProxy = class {
13692
13978
  return;
13693
13979
  }
13694
13980
  if (frame.type === "res" && frame.ok === true && frame.payload?.type === "hello-ok") {
13981
+ clearHandshakeTimer();
13695
13982
  this.storeIssuedDeviceToken({
13696
13983
  fallbackRole: "operator",
13697
13984
  fallbackScopes: ["operator.admin"],
@@ -13704,10 +13991,14 @@ var TunnelProxy = class {
13704
13991
  this.opts.logger.info(
13705
13992
  `TunnelProxy: RPC WS handshake done (hello-ok), flushing ${this.gatewayWsPending.length} pending frames`
13706
13993
  );
13707
- for (const pending of this.gatewayWsPending) {
13708
- ws.send(pending);
13709
- }
13994
+ const pending = this.gatewayWsPending;
13710
13995
  this.gatewayWsPending = [];
13996
+ for (let i = 0; i < pending.length; i++) {
13997
+ const entry = pending[i];
13998
+ if (!this.sendGatewayPayload(ws, entry, entry.reqId ?? "pending frame")) {
13999
+ return;
14000
+ }
14001
+ }
13711
14002
  return;
13712
14003
  }
13713
14004
  if (frame.type === "res" && frame.ok === false && !this.gatewayWsReady) {
@@ -13728,6 +14019,11 @@ var TunnelProxy = class {
13728
14019
  this.opts.logger.error(
13729
14020
  `TunnelProxy: RPC WS handshake failed (pending=${this.gatewayWsPending.length}): ${previewText(JSON.stringify(frame.error), 500)}`
13730
14021
  );
14022
+ clearHandshakeTimer();
14023
+ this.failAllGatewayRequests(
14024
+ "GATEWAY_RPC_HANDSHAKE_FAILED",
14025
+ `local gateway RPC handshake failed: ${previewText(JSON.stringify(frame.error), 500)}`
14026
+ );
13731
14027
  ws.close();
13732
14028
  return;
13733
14029
  }
@@ -13737,10 +14033,12 @@ var TunnelProxy = class {
13737
14033
  }
13738
14034
  });
13739
14035
  ws.on("close", (code, reason) => {
14036
+ clearHandshakeTimer();
13740
14037
  const wasReady = this.gatewayWsReady;
13741
14038
  const pendingCount = this.gatewayWsPending.length;
13742
14039
  const reasonText = reason.toString();
13743
14040
  const shouldReconnect = this.gatewayWsReconnectRequested && pendingCount > 0;
14041
+ const shouldHoldForPairing = this.gatewayWsPairingApprovalPending && pendingCount > 0;
13744
14042
  this.opts.logger.info(
13745
14043
  `TunnelProxy: RPC WS closed by gateway (code=${code}, reason=${reasonText}, ready=${wasReady}, pending=${pendingCount}, activeWs=${this.wsProxy.activeCount})`
13746
14044
  );
@@ -13756,17 +14054,36 @@ var TunnelProxy = class {
13756
14054
  `TunnelProxy: retrying RPC WS after local pairing approval (pending=${this.gatewayWsPending.length})`
13757
14055
  );
13758
14056
  queueMicrotask(() => this.ensureGatewayWs());
14057
+ } else if (!shouldHoldForPairing && (pendingCount > 0 || this.gatewayReqMetaById.size > 0)) {
14058
+ this.failAllGatewayRequests(
14059
+ "GATEWAY_RPC_DISCONNECTED",
14060
+ `local gateway RPC disconnected: code=${code}, reason=${reasonText}`
14061
+ );
13759
14062
  }
13760
14063
  });
13761
14064
  ws.on("error", (err2) => {
14065
+ clearHandshakeTimer();
13762
14066
  this.opts.logger.warn(
13763
14067
  `TunnelProxy: RPC WS error: ${err2.message} (ready=${this.gatewayWsReady}, pending=${this.gatewayWsPending.length}, activeWs=${this.wsProxy.activeCount})`
13764
14068
  );
14069
+ const shouldFailRequests = this.gatewayWs === ws && (this.gatewayWsPending.length > 0 || this.gatewayReqMetaById.size > 0);
13765
14070
  this.gatewayWsConnecting = false;
13766
14071
  if (this.gatewayWs === ws) {
13767
14072
  this.gatewayWs = null;
13768
14073
  this.gatewayWsReady = false;
13769
14074
  }
14075
+ if (shouldFailRequests) {
14076
+ this.failAllGatewayRequests(
14077
+ "GATEWAY_RPC_ERROR",
14078
+ `local gateway RPC error: ${err2.message}`
14079
+ );
14080
+ }
14081
+ try {
14082
+ if (typeof ws.terminate === "function") {
14083
+ ws.terminate();
14084
+ }
14085
+ } catch {
14086
+ }
13770
14087
  });
13771
14088
  }
13772
14089
  handleConnectChallenge(ws, frame) {
@@ -13998,6 +14315,12 @@ function createTunnelService(opts) {
13998
14315
  client.onConnected(() => {
13999
14316
  emitPendingPluginUpdate("relay connected");
14000
14317
  });
14318
+ client.onDisconnected((reason) => {
14319
+ logger.warn(
14320
+ `Relay tunnel: relay disconnected, cleaning local proxy state (${reason})`
14321
+ );
14322
+ proxy?.cleanup();
14323
+ });
14001
14324
  abortController = new AbortController();
14002
14325
  client.connectWithAutoReconnect(abortController.signal).catch((err2) => {
14003
14326
  releaseLock();
@@ -14120,10 +14443,18 @@ function resolveLocalGatewayAuth(params) {
14120
14443
  gatewayPassword: envGatewayPassword ?? configGatewayPassword
14121
14444
  };
14122
14445
  }
14446
+ var DISABLED_TAILSCALE_MODES = /* @__PURE__ */ new Set([
14447
+ "off",
14448
+ "disabled",
14449
+ "disable",
14450
+ "none",
14451
+ "false",
14452
+ "no"
14453
+ ]);
14123
14454
  function resolveExclusiveTunnelHint(params) {
14124
14455
  const configData = readHostGatewayConfig(params);
14125
14456
  const tailscaleMode = trimToUndefined2(configData?.gateway?.tailscale?.mode);
14126
- if (tailscaleMode) {
14457
+ if (tailscaleMode && !DISABLED_TAILSCALE_MODES.has(tailscaleMode.toLowerCase())) {
14127
14458
  return `gateway.tailscale.mode=${tailscaleMode}`;
14128
14459
  }
14129
14460
  const remoteUrl = trimToUndefined2(configData?.gateway?.remote?.url);