@yoooclaw/phone-notifications 1.7.1 → 1.7.3

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.js CHANGED
@@ -5495,6 +5495,16 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
5495
5495
  var wrapper_default = import_websocket.default;
5496
5496
 
5497
5497
  // src/tunnel/relay-client.ts
5498
+ function previewText(text, max = 500) {
5499
+ return text.length <= max ? text : `${text.substring(0, max)}\u2026`;
5500
+ }
5501
+ function maskSecret(value) {
5502
+ if (!value) return "empty";
5503
+ if (value.length <= 8) {
5504
+ return `${value.slice(0, 2)}\u2026${value.slice(-2)}`;
5505
+ }
5506
+ return `${value.slice(0, 4)}\u2026${value.slice(-4)}`;
5507
+ }
5498
5508
  var RelayClient = class {
5499
5509
  constructor(opts) {
5500
5510
  this.opts = opts;
@@ -5575,8 +5585,9 @@ var RelayClient = class {
5575
5585
  async connect() {
5576
5586
  if (this.aborted) return;
5577
5587
  this.cleanup(true);
5588
+ const rawApiKey = this.opts.apiKey.startsWith("Bearer ") ? this.opts.apiKey.slice("Bearer ".length) : this.opts.apiKey;
5578
5589
  this.opts.logger.info(
5579
- `Relay tunnel: connecting to ${this.opts.tunnelUrl} (attempt=${this.reconnectAttempt}, heartbeat=${this.opts.heartbeatSec}s)`
5590
+ `Relay tunnel: connecting to ${this.opts.tunnelUrl} (attempt=${this.reconnectAttempt}, heartbeat=${this.opts.heartbeatSec}s, apiKey=${maskSecret(rawApiKey)})`
5580
5591
  );
5581
5592
  this.writeStatus("connecting");
5582
5593
  return new Promise((resolve) => {
@@ -5587,7 +5598,6 @@ var RelayClient = class {
5587
5598
  resolve();
5588
5599
  }
5589
5600
  };
5590
- const rawApiKey = this.opts.apiKey.startsWith("Bearer ") ? this.opts.apiKey.slice("Bearer ".length) : this.opts.apiKey;
5591
5601
  const wsUrl = new URL(this.opts.tunnelUrl);
5592
5602
  if (!wsUrl.searchParams.get("apiKey")) {
5593
5603
  wsUrl.searchParams.set("apiKey", rawApiKey);
@@ -5631,8 +5641,9 @@ var RelayClient = class {
5631
5641
  });
5632
5642
  ws.on("close", (code, reason) => {
5633
5643
  const reasonStr = reason.toString();
5644
+ const lastInboundAgoMs = this.lastInboundAt ? Date.now() - this.lastInboundAt : null;
5634
5645
  this.opts.logger.warn(
5635
- `Relay tunnel: disconnected (code=${code}, reason=${reasonStr})`
5646
+ `Relay tunnel: disconnected (code=${code}, reason=${previewText(reasonStr, 200)}, lastInboundAgoMs=${lastInboundAgoMs ?? "N/A"}, reconnectAttempt=${this.reconnectAttempt})`
5636
5647
  );
5637
5648
  if (this.ws === ws) {
5638
5649
  this.stopHeartbeat();
@@ -5643,7 +5654,9 @@ var RelayClient = class {
5643
5654
  settle();
5644
5655
  });
5645
5656
  ws.on("error", (err) => {
5646
- this.opts.logger.error(`Relay tunnel: WebSocket error: ${err.message}`);
5657
+ this.opts.logger.error(
5658
+ `Relay tunnel: WebSocket error: ${err.message} (readyState=${ws.readyState}, reconnectAttempt=${this.reconnectAttempt}, url=${wsUrl.toString()})`
5659
+ );
5647
5660
  settle();
5648
5661
  });
5649
5662
  });
@@ -5654,13 +5667,16 @@ var RelayClient = class {
5654
5667
  if (text === "pong") {
5655
5668
  return;
5656
5669
  }
5657
- const preview = text.length <= 500 ? text : text.substring(0, 500) + "\u2026";
5658
- this.opts.logger.info(`Relay tunnel: \u2605 received message (${text.length} chars): ${preview}`);
5670
+ this.opts.logger.info(
5671
+ `Relay tunnel: \u2605 received message (${text.length} chars): ${previewText(text)}`
5672
+ );
5659
5673
  let frame;
5660
5674
  try {
5661
5675
  frame = JSON.parse(text);
5662
5676
  } catch {
5663
- this.opts.logger.warn("Relay tunnel: received invalid frame, ignoring");
5677
+ this.opts.logger.warn(
5678
+ `Relay tunnel: received invalid frame, ignoring (preview=${previewText(text, 200)})`
5679
+ );
5664
5680
  return;
5665
5681
  }
5666
5682
  this.opts.logger.info(`Relay tunnel: parsed frame type=${frame.type}, id=${"id" in frame ? frame.id : "N/A"}`);
@@ -5775,6 +5791,32 @@ import { randomUUID as randomUUID2 } from "crypto";
5775
5791
  import crypto from "crypto";
5776
5792
  import fs from "fs";
5777
5793
  import path from "path";
5794
+ function previewText2(text, max = 200) {
5795
+ if (!text) return "";
5796
+ return text.length <= max ? text : `${text.substring(0, max)}\u2026`;
5797
+ }
5798
+ function findHeaderValue(headers, key) {
5799
+ if (!headers) return void 0;
5800
+ const lowerKey = key.toLowerCase();
5801
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
5802
+ if (headerKey.toLowerCase() === lowerKey) {
5803
+ return headerValue;
5804
+ }
5805
+ }
5806
+ return void 0;
5807
+ }
5808
+ function summarizeRequestHeaders(headers) {
5809
+ const contentType = findHeaderValue(headers, "content-type");
5810
+ const requestId = findHeaderValue(headers, "x-request-id");
5811
+ const parts = [];
5812
+ if (contentType) {
5813
+ parts.push(`contentType=${contentType}`);
5814
+ }
5815
+ if (requestId) {
5816
+ parts.push(`xRequestId=${previewText2(requestId, 120)}`);
5817
+ }
5818
+ return parts.length ? `, ${parts.join(", ")}` : "";
5819
+ }
5778
5820
  var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
5779
5821
  function base64UrlEncode(buf) {
5780
5822
  return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
@@ -5814,11 +5856,96 @@ function buildDeviceAuthPayload(params) {
5814
5856
  params.nonce
5815
5857
  ].join("|");
5816
5858
  }
5817
- function resolveStateDir2() {
5818
- return resolveStateDir();
5859
+ function resolveClientStateDir(stateDir) {
5860
+ return stateDir ?? resolveStateDir();
5861
+ }
5862
+ function ensureDir(filePath) {
5863
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
5864
+ }
5865
+ function resolveIdentityPath(stateDir) {
5866
+ return path.join(stateDir, "identity", "device.json");
5867
+ }
5868
+ function normalizeDeviceAuthRole(role) {
5869
+ return role.trim();
5870
+ }
5871
+ function normalizeDeviceAuthScopes(scopes) {
5872
+ const out = /* @__PURE__ */ new Set();
5873
+ for (const scope of scopes) {
5874
+ const trimmed = scope.trim();
5875
+ if (trimmed) {
5876
+ out.add(trimmed);
5877
+ }
5878
+ }
5879
+ return [...out].sort();
5880
+ }
5881
+ function resolveDeviceAuthPath(stateDir) {
5882
+ return path.join(stateDir, "identity", "device-auth.json");
5883
+ }
5884
+ function readDeviceAuthStore(filePath) {
5885
+ try {
5886
+ if (!fs.existsSync(filePath)) return null;
5887
+ const raw = fs.readFileSync(filePath, "utf8");
5888
+ const parsed = JSON.parse(raw);
5889
+ if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") return null;
5890
+ if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
5891
+ return parsed;
5892
+ } catch {
5893
+ return null;
5894
+ }
5895
+ }
5896
+ function writeDeviceAuthStore(filePath, store) {
5897
+ ensureDir(filePath);
5898
+ fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}
5899
+ `, {
5900
+ mode: 384
5901
+ });
5902
+ try {
5903
+ fs.chmodSync(filePath, 384);
5904
+ } catch {
5905
+ }
5906
+ }
5907
+ function loadDeviceAuthToken(params) {
5908
+ const store = readDeviceAuthStore(resolveDeviceAuthPath(params.stateDir));
5909
+ if (!store || store.deviceId !== params.deviceId) return null;
5910
+ const entry = store.tokens[normalizeDeviceAuthRole(params.role)];
5911
+ if (!entry || typeof entry.token !== "string") return null;
5912
+ return entry;
5913
+ }
5914
+ function storeDeviceAuthToken(params) {
5915
+ const filePath = resolveDeviceAuthPath(params.stateDir);
5916
+ const existing = readDeviceAuthStore(filePath);
5917
+ const role = normalizeDeviceAuthRole(params.role);
5918
+ const next = {
5919
+ version: 1,
5920
+ deviceId: params.deviceId,
5921
+ tokens: existing && existing.deviceId === params.deviceId && existing.tokens ? { ...existing.tokens } : {}
5922
+ };
5923
+ const entry = {
5924
+ token: params.token,
5925
+ role,
5926
+ scopes: normalizeDeviceAuthScopes(params.scopes),
5927
+ updatedAtMs: Date.now()
5928
+ };
5929
+ next.tokens[role] = entry;
5930
+ writeDeviceAuthStore(filePath, next);
5931
+ return entry;
5932
+ }
5933
+ function clearDeviceAuthToken(params) {
5934
+ const filePath = resolveDeviceAuthPath(params.stateDir);
5935
+ const store = readDeviceAuthStore(filePath);
5936
+ if (!store || store.deviceId !== params.deviceId) return;
5937
+ const role = normalizeDeviceAuthRole(params.role);
5938
+ if (!store.tokens[role]) return;
5939
+ const next = {
5940
+ version: 1,
5941
+ deviceId: store.deviceId,
5942
+ tokens: { ...store.tokens }
5943
+ };
5944
+ delete next.tokens[role];
5945
+ writeDeviceAuthStore(filePath, next);
5819
5946
  }
5820
- function loadOrCreateDeviceIdentity() {
5821
- const filePath = path.join(resolveStateDir2(), "identity", "device.json");
5947
+ function loadOrCreateDeviceIdentity(stateDir) {
5948
+ const filePath = resolveIdentityPath(stateDir);
5822
5949
  try {
5823
5950
  if (fs.existsSync(filePath)) {
5824
5951
  const raw = fs.readFileSync(filePath, "utf8");
@@ -5848,6 +5975,7 @@ function loadOrCreateDeviceIdentity() {
5848
5975
  ...identity,
5849
5976
  createdAtMs: Date.now()
5850
5977
  };
5978
+ ensureDir(filePath);
5851
5979
  fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}
5852
5980
  `, {
5853
5981
  mode: 384
@@ -5857,7 +5985,8 @@ function loadOrCreateDeviceIdentity() {
5857
5985
  var TunnelProxy = class _TunnelProxy {
5858
5986
  constructor(opts) {
5859
5987
  this.opts = opts;
5860
- this.deviceIdentity = loadOrCreateDeviceIdentity();
5988
+ this.stateDir = resolveClientStateDir(opts.stateDir);
5989
+ this.deviceIdentity = loadOrCreateDeviceIdentity(this.stateDir);
5861
5990
  opts.logger.info(
5862
5991
  `TunnelProxy: loaded device identity (deviceId=${this.deviceIdentity.deviceId})`
5863
5992
  );
@@ -5873,6 +6002,13 @@ var TunnelProxy = class _TunnelProxy {
5873
6002
  gatewayWsPending = [];
5874
6003
  /** 设备身份,用于 Gateway connect 握手 */
5875
6004
  deviceIdentity;
6005
+ stateDir;
6006
+ pushGatewayPending(payload, reason) {
6007
+ this.gatewayWsPending.push(payload);
6008
+ this.opts.logger.info(
6009
+ `TunnelProxy: gateway WS pending queue size=${this.gatewayWsPending.length} (${reason})`
6010
+ );
6011
+ }
5876
6012
  resolveGatewayConnectAuth() {
5877
6013
  const token = this.opts.gatewayToken?.trim() || void 0;
5878
6014
  const password = this.opts.gatewayPassword?.trim() || void 0;
@@ -5884,6 +6020,65 @@ var TunnelProxy = class _TunnelProxy {
5884
6020
  password
5885
6021
  };
5886
6022
  }
6023
+ loadStoredDeviceToken(role) {
6024
+ return loadDeviceAuthToken({
6025
+ stateDir: this.stateDir,
6026
+ deviceId: this.deviceIdentity.deviceId,
6027
+ role
6028
+ })?.token ?? void 0;
6029
+ }
6030
+ buildGatewayConnectAuth(role) {
6031
+ const explicitAuth = this.resolveGatewayConnectAuth();
6032
+ const authPassword = explicitAuth?.password?.trim() || void 0;
6033
+ const explicitGatewayToken = explicitAuth?.token?.trim() || void 0;
6034
+ const deviceToken = explicitGatewayToken ? void 0 : this.loadStoredDeviceToken(role);
6035
+ const authToken = explicitGatewayToken ?? deviceToken;
6036
+ const auth = authToken || authPassword || deviceToken ? {
6037
+ token: authToken,
6038
+ deviceToken,
6039
+ password: authPassword
6040
+ } : void 0;
6041
+ return {
6042
+ auth,
6043
+ authToken,
6044
+ authPassword,
6045
+ deviceToken
6046
+ };
6047
+ }
6048
+ storeIssuedDeviceToken(params) {
6049
+ const token = params.authInfo?.deviceToken;
6050
+ if (typeof token !== "string" || !token.trim()) {
6051
+ return;
6052
+ }
6053
+ const role = typeof params.authInfo?.role === "string" && params.authInfo.role.trim() ? params.authInfo.role.trim() : params.fallbackRole;
6054
+ const scopes = Array.isArray(params.authInfo?.scopes) ? params.authInfo.scopes.filter(
6055
+ (scope) => typeof scope === "string" && !!scope.trim()
6056
+ ) : params.fallbackScopes;
6057
+ storeDeviceAuthToken({
6058
+ stateDir: this.stateDir,
6059
+ deviceId: this.deviceIdentity.deviceId,
6060
+ role,
6061
+ token: token.trim(),
6062
+ scopes
6063
+ });
6064
+ }
6065
+ maybeClearStoredDeviceTokenOnMismatch(code, reason) {
6066
+ const explicitAuth = this.resolveGatewayConnectAuth();
6067
+ if (explicitAuth?.token || explicitAuth?.password) {
6068
+ return;
6069
+ }
6070
+ if (code !== 1008 || !reason.toLowerCase().includes("device token mismatch")) {
6071
+ return;
6072
+ }
6073
+ clearDeviceAuthToken({
6074
+ stateDir: this.stateDir,
6075
+ deviceId: this.deviceIdentity.deviceId,
6076
+ role: "operator"
6077
+ });
6078
+ this.opts.logger.warn(
6079
+ `TunnelProxy: cleared stale stored device token after gateway mismatch (deviceId=${this.deviceIdentity.deviceId})`
6080
+ );
6081
+ }
5887
6082
  buildLocalGatewayAuthAttempts(baseHeaders) {
5888
6083
  const auth = this.resolveGatewayConnectAuth();
5889
6084
  const attempts = [];
@@ -5921,19 +6116,21 @@ var TunnelProxy = class _TunnelProxy {
5921
6116
  }
5922
6117
  return attempts;
5923
6118
  }
5924
- async sendHttpResponse(frameId, res) {
6119
+ async sendHttpResponse(params) {
6120
+ const { frameId, method, path: path2, authLabel, startedAtMs, res } = params;
5925
6121
  const contentType = res.headers.get("content-type") ?? "";
5926
6122
  const isStreaming = contentType.includes("text/event-stream");
6123
+ const elapsedMs = Date.now() - startedAtMs;
5927
6124
  this.opts.logger.info(
5928
- `TunnelProxy: response status=${res.status}, content-type=${contentType}, streaming=${isStreaming}`
6125
+ `TunnelProxy: HTTP id=${frameId} ${method} ${path2} <= ${res.status} (${elapsedMs}ms, auth=${authLabel}, content-type=${contentType}, streaming=${isStreaming})`
5929
6126
  );
5930
6127
  if (isStreaming && res.body) {
5931
- await this.streamResponse(frameId, res);
6128
+ await this.streamResponse(frameId, res, startedAtMs);
5932
6129
  return;
5933
6130
  }
5934
6131
  const body = await res.text();
5935
6132
  this.opts.logger.info(
5936
- `TunnelProxy: response status=${res.status}, body=${body.substring(0, 200)}`
6133
+ `TunnelProxy: HTTP id=${frameId} response body=${previewText2(body)}`
5937
6134
  );
5938
6135
  const headers = {};
5939
6136
  res.headers.forEach((value, key) => {
@@ -5982,7 +6179,7 @@ var TunnelProxy = class _TunnelProxy {
5982
6179
  if (this.gatewayWsReady && this.gatewayWs?.readyState === wrapper_default.OPEN) {
5983
6180
  this.gatewayWs.send(payload);
5984
6181
  } else {
5985
- this.gatewayWsPending.push(payload);
6182
+ this.pushGatewayPending(payload, "raw frame queued before gateway WS ready");
5986
6183
  }
5987
6184
  }
5988
6185
  /** 清理所有代理的 WebSocket 连接 */
@@ -6016,12 +6213,14 @@ var TunnelProxy = class _TunnelProxy {
6016
6213
  this.gatewayWsConnecting = true;
6017
6214
  this.gatewayWsReady = false;
6018
6215
  const wsUrl = this.opts.gatewayBaseUrl.replace(/^http/, "ws");
6019
- this.opts.logger.info(`TunnelProxy: RPC WS connecting to gateway ${wsUrl}`);
6216
+ this.opts.logger.info(
6217
+ `TunnelProxy: RPC WS connecting to gateway ${wsUrl} (pending=${this.gatewayWsPending.length})`
6218
+ );
6020
6219
  const ws = new wrapper_default(wsUrl);
6021
6220
  ws.on("open", () => {
6022
6221
  this.gatewayWs = ws;
6023
6222
  this.opts.logger.info(
6024
- "TunnelProxy: RPC WS tcp connected, waiting for connect.challenge"
6223
+ `TunnelProxy: RPC WS tcp connected, waiting for connect.challenge (pending=${this.gatewayWsPending.length})`
6025
6224
  );
6026
6225
  });
6027
6226
  ws.on("message", (data) => {
@@ -6048,15 +6247,16 @@ var TunnelProxy = class _TunnelProxy {
6048
6247
  }
6049
6248
  if (frame.type === "event" && frame.event === "connect.challenge") {
6050
6249
  const challengeNonce = frame.payload?.nonce ?? "";
6051
- this.opts.logger.info(
6052
- `TunnelProxy: received connect.challenge (nonce=${challengeNonce}), sending connect request with device identity`
6053
- );
6250
+ const connectRequestId = `tunnel-connect-${randomUUID2()}`;
6054
6251
  const role = "operator";
6055
6252
  const scopes = ["operator.admin"];
6253
+ const gatewayConnectAuth = this.buildGatewayConnectAuth(role);
6254
+ this.opts.logger.info(
6255
+ `TunnelProxy: received connect.challenge (nonce=${challengeNonce}, connectReqId=${connectRequestId}, hasToken=${!!gatewayConnectAuth.authToken}, hasPassword=${!!gatewayConnectAuth.authPassword}, hasDeviceToken=${!!gatewayConnectAuth.deviceToken}), sending connect request with device identity`
6256
+ );
6056
6257
  const signedAtMs = Date.now();
6057
6258
  const clientId = "gateway-client";
6058
6259
  const clientMode = "backend";
6059
- const gatewayAuth = this.resolveGatewayConnectAuth();
6060
6260
  const authPayload = buildDeviceAuthPayload({
6061
6261
  deviceId: this.deviceIdentity.deviceId,
6062
6262
  clientId,
@@ -6064,7 +6264,7 @@ var TunnelProxy = class _TunnelProxy {
6064
6264
  role,
6065
6265
  scopes,
6066
6266
  signedAtMs,
6067
- token: gatewayAuth?.token ?? null,
6267
+ token: gatewayConnectAuth.authToken ?? null,
6068
6268
  nonce: challengeNonce
6069
6269
  });
6070
6270
  const signature = signDevicePayload(
@@ -6073,7 +6273,7 @@ var TunnelProxy = class _TunnelProxy {
6073
6273
  );
6074
6274
  const connectReq = {
6075
6275
  type: "req",
6076
- id: `tunnel-connect-${randomUUID2()}`,
6276
+ id: connectRequestId,
6077
6277
  method: "connect",
6078
6278
  params: {
6079
6279
  minProtocol: 3,
@@ -6086,7 +6286,7 @@ var TunnelProxy = class _TunnelProxy {
6086
6286
  },
6087
6287
  role,
6088
6288
  scopes,
6089
- ...gatewayAuth ? { auth: gatewayAuth } : {},
6289
+ ...gatewayConnectAuth.auth ? { auth: gatewayConnectAuth.auth } : {},
6090
6290
  device: {
6091
6291
  id: this.deviceIdentity.deviceId,
6092
6292
  publicKey: publicKeyRawBase64UrlFromPem(
@@ -6102,6 +6302,11 @@ var TunnelProxy = class _TunnelProxy {
6102
6302
  return;
6103
6303
  }
6104
6304
  if (frame.type === "res" && frame.ok === true && frame.payload?.type === "hello-ok") {
6305
+ this.storeIssuedDeviceToken({
6306
+ fallbackRole: "operator",
6307
+ fallbackScopes: ["operator.admin"],
6308
+ authInfo: frame.payload?.auth
6309
+ });
6105
6310
  this.gatewayWsReady = true;
6106
6311
  this.gatewayWsConnecting = false;
6107
6312
  this.opts.logger.info(
@@ -6115,7 +6320,7 @@ var TunnelProxy = class _TunnelProxy {
6115
6320
  }
6116
6321
  if (frame.type === "res" && frame.ok === false && !this.gatewayWsReady) {
6117
6322
  this.opts.logger.error(
6118
- `TunnelProxy: RPC WS handshake failed: ${JSON.stringify(frame.error)}`
6323
+ `TunnelProxy: RPC WS handshake failed (pending=${this.gatewayWsPending.length}): ${previewText2(JSON.stringify(frame.error), 500)}`
6119
6324
  );
6120
6325
  ws.close();
6121
6326
  return;
@@ -6123,17 +6328,23 @@ var TunnelProxy = class _TunnelProxy {
6123
6328
  this.opts.client.sendRaw(text);
6124
6329
  });
6125
6330
  ws.on("close", (code, reason) => {
6331
+ const wasReady = this.gatewayWsReady;
6332
+ const pendingCount = this.gatewayWsPending.length;
6333
+ const reasonText = reason.toString();
6126
6334
  this.opts.logger.info(
6127
- `TunnelProxy: RPC WS closed by gateway (code=${code}, reason=${reason.toString()})`
6335
+ `TunnelProxy: RPC WS closed by gateway (code=${code}, reason=${reasonText}, ready=${wasReady}, pending=${pendingCount}, activeWs=${this.wsConnections.size})`
6128
6336
  );
6129
6337
  if (this.gatewayWs === ws) {
6130
6338
  this.gatewayWs = null;
6131
6339
  this.gatewayWsReady = false;
6132
6340
  }
6133
6341
  this.gatewayWsConnecting = false;
6342
+ this.maybeClearStoredDeviceTokenOnMismatch(code, reasonText);
6134
6343
  });
6135
6344
  ws.on("error", (err) => {
6136
- this.opts.logger.warn(`TunnelProxy: RPC WS error: ${err.message}`);
6345
+ this.opts.logger.warn(
6346
+ `TunnelProxy: RPC WS error: ${err.message} (ready=${this.gatewayWsReady}, pending=${this.gatewayWsPending.length}, activeWs=${this.wsConnections.size})`
6347
+ );
6137
6348
  this.gatewayWsConnecting = false;
6138
6349
  if (this.gatewayWs === ws) {
6139
6350
  this.gatewayWs = null;
@@ -6154,7 +6365,10 @@ var TunnelProxy = class _TunnelProxy {
6154
6365
  this.opts.logger.info(
6155
6366
  `TunnelProxy: req id=${frame.id} queued, gateway WS not ready yet`
6156
6367
  );
6157
- this.gatewayWsPending.push(payload);
6368
+ this.pushGatewayPending(
6369
+ payload,
6370
+ `req id=${frame.id} queued before gateway WS handshake`
6371
+ );
6158
6372
  }
6159
6373
  }
6160
6374
  // ─── 路径映射 ───
@@ -6166,7 +6380,9 @@ var TunnelProxy = class _TunnelProxy {
6166
6380
  }
6167
6381
  // ─── HTTP 请求代理 ───
6168
6382
  async handleHttpRequest(frame) {
6169
- const url = new URL(this.mapPath(frame.path), this.opts.gatewayBaseUrl);
6383
+ const mappedPath = this.mapPath(frame.path);
6384
+ const url = new URL(mappedPath, this.opts.gatewayBaseUrl);
6385
+ const startedAtMs = Date.now();
6170
6386
  const localHeaders = {};
6171
6387
  for (const [k, v] of Object.entries(frame.headers ?? {})) {
6172
6388
  const lower = k.toLowerCase();
@@ -6176,11 +6392,14 @@ var TunnelProxy = class _TunnelProxy {
6176
6392
  }
6177
6393
  const authAttempts = this.buildLocalGatewayAuthAttempts(localHeaders);
6178
6394
  this.opts.logger.info(
6179
- `TunnelProxy: HTTP ${frame.method} ${frame.path} \u2192 ${url.toString()}, body=${frame.body?.substring(0, 200)}`
6395
+ `TunnelProxy: HTTP id=${frame.id} ${frame.method} ${frame.path} \u2192 ${url.toString()}${summarizeRequestHeaders(frame.headers)}, authAttempts=${authAttempts.map((attempt) => attempt.label).join(" -> ")}, body=${previewText2(frame.body)}`
6180
6396
  );
6181
6397
  try {
6182
6398
  for (let attemptIndex = 0; attemptIndex < authAttempts.length; attemptIndex++) {
6183
6399
  const attempt = authAttempts[attemptIndex];
6400
+ this.opts.logger.info(
6401
+ `TunnelProxy: HTTP id=${frame.id} attempt ${attemptIndex + 1}/${authAttempts.length} auth=${attempt.label}`
6402
+ );
6184
6403
  const res = await fetch(url.toString(), {
6185
6404
  method: frame.method,
6186
6405
  headers: attempt.headers,
@@ -6190,23 +6409,34 @@ var TunnelProxy = class _TunnelProxy {
6190
6409
  if (res.status === 401 && hasFallback) {
6191
6410
  const body = await res.text();
6192
6411
  this.opts.logger.warn(
6193
- `TunnelProxy: local gateway auth via ${attempt.label} returned 401, retrying next credential${body ? `, body=${body.substring(0, 200)}` : ""}`
6412
+ `TunnelProxy: HTTP id=${frame.id} local gateway auth via ${attempt.label} returned 401 after ${Date.now() - startedAtMs}ms, retrying next credential${body ? `, body=${previewText2(body)}` : ""}`
6194
6413
  );
6195
6414
  continue;
6196
6415
  }
6197
- await this.sendHttpResponse(frame.id, res);
6416
+ await this.sendHttpResponse({
6417
+ frameId: frame.id,
6418
+ method: frame.method,
6419
+ path: mappedPath,
6420
+ authLabel: attempt.label,
6421
+ startedAtMs,
6422
+ res
6423
+ });
6198
6424
  return;
6199
6425
  }
6200
6426
  } catch (err) {
6427
+ const message = err instanceof Error ? err.message : String(err);
6428
+ this.opts.logger.error(
6429
+ `TunnelProxy: HTTP id=${frame.id} ${frame.method} ${mappedPath} failed after ${Date.now() - startedAtMs}ms: ${message}`
6430
+ );
6201
6431
  this.opts.client.send({
6202
6432
  type: "proxy_error",
6203
6433
  id: frame.id,
6204
6434
  status: 502,
6205
- message: `gateway unreachable: ${err instanceof Error ? err.message : String(err)}`
6435
+ message: `gateway unreachable: ${message}`
6206
6436
  });
6207
6437
  }
6208
6438
  }
6209
- async streamResponse(requestId, res) {
6439
+ async streamResponse(requestId, res, startedAtMs) {
6210
6440
  const reader = res.body.getReader();
6211
6441
  const decoder = new TextDecoder();
6212
6442
  let chunkCount = 0;
@@ -6228,7 +6458,7 @@ var TunnelProxy = class _TunnelProxy {
6228
6458
  });
6229
6459
  }
6230
6460
  this.opts.logger.info(
6231
- `TunnelProxy: stream end id=${requestId}, total chunks=${chunkCount}`
6461
+ `TunnelProxy: stream end id=${requestId}, total chunks=${chunkCount}, totalElapsedMs=${Date.now() - startedAtMs}`
6232
6462
  );
6233
6463
  this.opts.client.send({
6234
6464
  type: "stream",
@@ -6238,7 +6468,7 @@ var TunnelProxy = class _TunnelProxy {
6238
6468
  });
6239
6469
  } catch (err) {
6240
6470
  this.opts.logger.error(
6241
- `TunnelProxy: stream error id=${requestId} after ${chunkCount} chunks: ${err instanceof Error ? err.message : String(err)}`
6471
+ `TunnelProxy: stream error id=${requestId} after ${chunkCount} chunks and ${Date.now() - startedAtMs}ms: ${err instanceof Error ? err.message : String(err)}`
6242
6472
  );
6243
6473
  this.opts.client.send({
6244
6474
  type: "proxy_error",
@@ -6473,6 +6703,7 @@ function createTunnelService(opts) {
6473
6703
  logger
6474
6704
  });
6475
6705
  proxy = new TunnelProxy({
6706
+ stateDir: baseStateDir,
6476
6707
  gatewayBaseUrl: opts.gatewayBaseUrl,
6477
6708
  gatewayAuthMode: opts.gatewayAuthMode,
6478
6709
  gatewayToken: opts.gatewayToken,