dsh-mobile 0.4.3 → 0.4.5

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/lib/index.d.mts CHANGED
@@ -597,6 +597,7 @@ declare class MobileAccessGateway {
597
597
  private readonly upstreamAuthenticatedUrl?;
598
598
  private readonly extraWebSocketPaths?;
599
599
  private readonly blockedUpgradeLog?;
600
+ private readonly onDiscoveryDegraded?;
600
601
  readonly access: AccessController;
601
602
  private readonly listenerTlsEnabled;
602
603
  private readonly tlsEnabled;
@@ -611,6 +612,7 @@ declare class MobileAccessGateway {
611
612
  * it, "the phone cannot find this computer" has no diagnosable cause on the host.
612
613
  */
613
614
  private discoveryError;
615
+ private mdnsError;
614
616
  /** Names of the bundled mobile assets that could not be read, if any. */
615
617
  private mobileAssetError;
616
618
  private bonjour;
@@ -644,10 +646,13 @@ declare class MobileAccessGateway {
644
646
  private readonly probeLimiter;
645
647
  constructor(config: ResolvedGatewayConfig, store: DeviceStore, extensions?: MobileAccessService | undefined, upstreamAuthenticatedUrl?: string | undefined, extraWebSocketPaths?: {
646
648
  has(pathname: string): boolean;
647
- } | undefined, blockedUpgradeLog?: BlockedUpgradePathLog | undefined);
649
+ } | undefined, blockedUpgradeLog?: BlockedUpgradePathLog | undefined, onDiscoveryDegraded?: ((source: 'broadcast' | 'mdns', code: string) => void) | undefined);
648
650
  /** Initialize durable state, validate TLS, and bind the externally reachable listener. */
649
651
  start(): Promise<void>;
650
652
  private startDiscovery;
653
+ private reportDiscoveryDegraded;
654
+ private recordBroadcastFailure;
655
+ private recordMdnsFailure;
651
656
  private discoveryAnnouncement;
652
657
  private closeFailedStart;
653
658
  private closeBonjour;
@@ -668,6 +673,7 @@ declare class MobileAccessGateway {
668
673
  readonly broadcast: boolean;
669
674
  readonly mdns: boolean;
670
675
  readonly errorCode?: string;
676
+ readonly mdnsErrorCode?: string;
671
677
  readonly mobileAssetsErrorCode?: string;
672
678
  };
673
679
  private requirePolicy;
@@ -1416,6 +1422,15 @@ declare class TaskEventHub {
1416
1422
  }
1417
1423
  //#endregion
1418
1424
  //#region src/plugin.d.ts
1425
+ declare module '@deepseek-ai/dsh-llm' {
1426
+ interface MessageSourceMap {
1427
+ 'plugin:dsh-mobile': {
1428
+ kind: 'plugin:dsh-mobile';
1429
+ form: 'notice';
1430
+ summary: string;
1431
+ };
1432
+ }
1433
+ }
1419
1434
  /** Stable Cordis plugin name. */
1420
1435
  declare const name = "dsh-mobile";
1421
1436
  /** The stock WebServer serves the control card; Connection authenticates the loopback DSH origin. */
package/lib/index.mjs CHANGED
@@ -2451,6 +2451,16 @@ const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
2451
2451
  const MAX_MOBILE_BOOT_BATCHES = 8;
2452
2452
  const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
2453
2453
  const MOBILE_BOOT_RETRY_DELAY_MS = 150;
2454
+ function upstreamPluginBundleUrl(source, upstreamOrigin) {
2455
+ if (!source.startsWith("/plugins/") && !source.startsWith("plugins/") || source.includes("#")) return void 0;
2456
+ try {
2457
+ const target = new URL(source, new URL("/", upstreamOrigin));
2458
+ if (target.origin !== upstreamOrigin.origin || !target.pathname.startsWith("/plugins/")) return void 0;
2459
+ return target;
2460
+ } catch {
2461
+ return;
2462
+ }
2463
+ }
2454
2464
  const TRANSIENT_UPSTREAM_ERROR_CODES = /* @__PURE__ */ new Set([
2455
2465
  "EAI_AGAIN",
2456
2466
  "ECONNABORTED",
@@ -2870,8 +2880,9 @@ function shouldCompressResponse(request, response) {
2870
2880
  const pathname = request.url?.split("?", 1)[0] ?? "";
2871
2881
  return (request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) || request.method === "POST" && pathname === SESSION_HISTORY_PATH) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
2872
2882
  }
2873
- function revisionedStaticCacheControl(request) {
2883
+ function revisionedStaticCacheControl(request, statusCode) {
2874
2884
  if (request.method !== "GET" && request.method !== "HEAD") return void 0;
2885
+ if (statusCode !== 200) return void 0;
2875
2886
  let target;
2876
2887
  try {
2877
2888
  target = new URL(request.url ?? "/", "https://dsh-mobile.invalid");
@@ -2991,6 +3002,19 @@ function discoveryBroadcastTargets(cidrs) {
2991
3002
  }
2992
3003
  return [...targets];
2993
3004
  }
3005
+ /** Keep Node's upgrade-owned socket safe after the HTTP parser removes its listener. */
3006
+ function guardUpgradeSocket(socket) {
3007
+ socket.on("error", () => {
3008
+ if (!socket.destroyed) socket.destroy();
3009
+ });
3010
+ }
3011
+ function discoveryFailure(error) {
3012
+ const code = error instanceof Error && typeof error.code === "string" ? error.code : "unknown";
3013
+ return Object.freeze({
3014
+ code,
3015
+ message: error instanceof Error ? error.message : String(error)
3016
+ });
3017
+ }
2994
3018
  function extensionTarget(pathname) {
2995
3019
  const prefix = `${AUTH_PREFIX}/extensions`;
2996
3020
  if (pathname === prefix || pathname === `${prefix}/` || pathname === `${prefix}/manifest`) return { kind: "manifest" };
@@ -3088,6 +3112,7 @@ var MobileAccessGateway = class {
3088
3112
  upstreamAuthenticatedUrl;
3089
3113
  extraWebSocketPaths;
3090
3114
  blockedUpgradeLog;
3115
+ onDiscoveryDegraded;
3091
3116
  access;
3092
3117
  listenerTlsEnabled;
3093
3118
  tlsEnabled;
@@ -3102,6 +3127,7 @@ var MobileAccessGateway = class {
3102
3127
  * it, "the phone cannot find this computer" has no diagnosable cause on the host.
3103
3128
  */
3104
3129
  discoveryError;
3130
+ mdnsError;
3105
3131
  /** Names of the bundled mobile assets that could not be read, if any. */
3106
3132
  mobileAssetError;
3107
3133
  bonjour;
@@ -3133,12 +3159,13 @@ var MobileAccessGateway = class {
3133
3159
  removeExtensionContentListener;
3134
3160
  renewLimiter;
3135
3161
  probeLimiter;
3136
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog) {
3162
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded) {
3137
3163
  this.config = config;
3138
3164
  this.extensions = extensions;
3139
3165
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
3140
3166
  this.extraWebSocketPaths = extraWebSocketPaths;
3141
3167
  this.blockedUpgradeLog = blockedUpgradeLog;
3168
+ this.onDiscoveryDegraded = onDiscoveryDegraded;
3142
3169
  this.listenerTlsEnabled = config.tls.mode === "provided";
3143
3170
  this.tlsEnabled = config.publicTls;
3144
3171
  this.access = new AccessController(store, {
@@ -3215,9 +3242,11 @@ var MobileAccessGateway = class {
3215
3242
  socket.destroy();
3216
3243
  });
3217
3244
  server.on("upgrade", (request, socket, head) => {
3218
- this.handleUpgrade(request, socket, head).catch((error) => {
3245
+ const client = socket;
3246
+ guardUpgradeSocket(client);
3247
+ this.handleUpgrade(request, client, head).catch((error) => {
3219
3248
  const mapped = mapError(error);
3220
- rejectUpgrade(socket, mapped.status, mapped.code);
3249
+ rejectUpgrade(client, mapped.status, mapped.code);
3221
3250
  });
3222
3251
  });
3223
3252
  server.on("clientError", (_error, socket) => {
@@ -3252,33 +3281,56 @@ var MobileAccessGateway = class {
3252
3281
  const socket = createSocket("udp4");
3253
3282
  this.discoverySocket = socket;
3254
3283
  const announcement = this.discoveryAnnouncement(port);
3284
+ let binding = true;
3285
+ socket.on("error", (error) => {
3286
+ if (!binding) this.recordBroadcastFailure(error, true);
3287
+ });
3288
+ const sendAnnouncement = (targetPort, address) => {
3289
+ if (this.closing || this.discoveryError !== void 0) return;
3290
+ try {
3291
+ socket.send(announcement, targetPort, address, (error) => {
3292
+ if (error !== null) this.recordBroadcastFailure(error, true);
3293
+ });
3294
+ } catch (error) {
3295
+ this.recordBroadcastFailure(error, true);
3296
+ }
3297
+ };
3255
3298
  socket.on("message", (message, remote) => {
3256
- if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3257
- socket.send(announcement, remote.port, remote.address, () => void 0);
3299
+ if (this.closing || this.discoveryError !== void 0 || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3300
+ sendAnnouncement(remote.port, remote.address);
3258
3301
  });
3259
3302
  const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
3260
3303
  if (await new Promise((resolve) => {
3261
3304
  const onBindError = (error) => {
3262
3305
  socket.off("error", onBindError);
3263
- this.discoveryError = Object.freeze({
3264
- code: typeof error.code === "string" ? error.code : "unknown",
3265
- message: typeof error.message === "string" ? error.message : String(error)
3266
- });
3306
+ this.recordBroadcastFailure(error, false);
3267
3307
  resolve(false);
3268
3308
  };
3269
3309
  socket.once("error", onBindError);
3270
- socket.bind(port, bindHost, () => {
3271
- socket.off("error", onBindError);
3272
- socket.setBroadcast(true);
3273
- resolve(true);
3274
- });
3275
- })) {
3310
+ try {
3311
+ socket.bind(port, bindHost, () => {
3312
+ socket.off("error", onBindError);
3313
+ binding = false;
3314
+ try {
3315
+ socket.setBroadcast(true);
3316
+ resolve(true);
3317
+ } catch (error) {
3318
+ this.recordBroadcastFailure(error, true);
3319
+ resolve(false);
3320
+ }
3321
+ });
3322
+ } catch (error) {
3323
+ onBindError(error);
3324
+ }
3325
+ }) && this.discoveryError === void 0) {
3276
3326
  const announce = () => {
3277
- for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
3327
+ for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) sendAnnouncement(port, target);
3278
3328
  };
3279
3329
  announce();
3280
- this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3281
- this.discoveryTimer.unref();
3330
+ if (this.discoveryError === void 0) {
3331
+ this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3332
+ this.discoveryTimer.unref();
3333
+ }
3282
3334
  } else {
3283
3335
  this.discoverySocket = void 0;
3284
3336
  try {
@@ -3286,8 +3338,12 @@ var MobileAccessGateway = class {
3286
3338
  } catch {}
3287
3339
  }
3288
3340
  const deviceName = discoveryDeviceName();
3289
- const bonjour = new Bonjour({ disableIPv6: true });
3341
+ const onMdnsError = (error) => {
3342
+ this.recordMdnsFailure(error);
3343
+ };
3344
+ const bonjour = new Bonjour({ disableIPv6: true }, onMdnsError);
3290
3345
  this.bonjour = bonjour;
3346
+ bonjour.server.mdns.on("error", onMdnsError);
3291
3347
  bonjour.publish({
3292
3348
  name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
3293
3349
  type: MDNS_SERVICE_TYPE,
@@ -3303,6 +3359,25 @@ var MobileAccessGateway = class {
3303
3359
  }
3304
3360
  });
3305
3361
  }
3362
+ reportDiscoveryDegraded(source, code) {
3363
+ try {
3364
+ this.onDiscoveryDegraded?.(source, code);
3365
+ } catch (error) {
3366
+ process.emitWarning(`DSH Mobile could not log ${source} discovery error: ${String(error)}`, { code: "DSH_MOBILE_DISCOVERY_LOG_FAILED" });
3367
+ }
3368
+ }
3369
+ recordBroadcastFailure(error, notify) {
3370
+ if (this.closing || this.discoveryError !== void 0) return;
3371
+ this.discoveryError = discoveryFailure(error);
3372
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
3373
+ this.discoveryTimer = void 0;
3374
+ if (notify) this.reportDiscoveryDegraded("broadcast", this.discoveryError.code);
3375
+ }
3376
+ recordMdnsFailure(error) {
3377
+ if (this.closing || this.mdnsError !== void 0) return;
3378
+ this.mdnsError = discoveryFailure(error);
3379
+ this.reportDiscoveryDegraded("mdns", this.mdnsError.code);
3380
+ }
3306
3381
  discoveryAnnouncement(port) {
3307
3382
  return Buffer.from(JSON.stringify({
3308
3383
  deviceName: discoveryDeviceName(),
@@ -3360,8 +3435,9 @@ var MobileAccessGateway = class {
3360
3435
  discoveryStatus() {
3361
3436
  return Object.freeze({
3362
3437
  broadcast: this.discoverySocket !== void 0 && this.discoveryTimer !== void 0,
3363
- mdns: this.bonjour !== void 0,
3438
+ mdns: this.bonjour !== void 0 && this.mdnsError === void 0,
3364
3439
  ...this.discoveryError === void 0 ? {} : { errorCode: `discovery_broadcast_${this.discoveryError.code}` },
3440
+ ...this.mdnsError === void 0 ? {} : { mdnsErrorCode: `discovery_mdns_${this.mdnsError.code}` },
3365
3441
  ...this.mobileAssetError === void 0 ? {} : { mobileAssetsErrorCode: this.mobileAssetError }
3366
3442
  });
3367
3443
  }
@@ -4061,7 +4137,7 @@ var MobileAccessGateway = class {
4061
4137
  async resolveMobileBootSizes(planEntries) {
4062
4138
  const sizes = /* @__PURE__ */ new Map();
4063
4139
  const passThrough = /* @__PURE__ */ new Set();
4064
- const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && entry.url.startsWith("/plugins/"));
4140
+ const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && upstreamPluginBundleUrl(entry.url, this.config.upstreamOrigin) !== void 0);
4065
4141
  let cursor = 0;
4066
4142
  const worker = async () => {
4067
4143
  while (cursor < candidates.length) {
@@ -4085,11 +4161,10 @@ var MobileAccessGateway = class {
4085
4161
  * cached for a short window.
4086
4162
  */
4087
4163
  async upstreamBundleSize(source) {
4088
- if (!source.startsWith("/plugins/") || source.includes("#")) return void 0;
4164
+ const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
4165
+ if (target === void 0) return void 0;
4089
4166
  const cached = this.bootEntrySizeCache.get(source);
4090
4167
  if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
4091
- const target = new URL(source, this.config.upstreamOrigin);
4092
- if (target.origin !== this.config.upstreamOrigin.origin) return void 0;
4093
4168
  const upstreamCookie = await this.upstreamCookieHeader();
4094
4169
  let upstreamRequest;
4095
4170
  try {
@@ -4255,9 +4330,8 @@ var MobileAccessGateway = class {
4255
4330
  }
4256
4331
  async readUpstreamClientBundle(source, signal) {
4257
4332
  signal.throwIfAborted();
4258
- if (!source.startsWith("/plugins/") || source.includes("#")) throw new HttpError(502, "upstream_unavailable");
4259
- const target = new URL(source, this.config.upstreamOrigin);
4260
- if (target.origin !== this.config.upstreamOrigin.origin) throw new HttpError(502, "upstream_unavailable");
4333
+ const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
4334
+ if (target === void 0) throw new HttpError(502, "upstream_unavailable");
4261
4335
  let upstreamRequest;
4262
4336
  const aborted = () => {
4263
4337
  upstreamRequest?.destroy(/* @__PURE__ */ new Error("request aborted"));
@@ -4403,7 +4477,8 @@ var MobileAccessGateway = class {
4403
4477
  });
4404
4478
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
4405
4479
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
4406
- const cacheControl = revisionedStaticCacheControl(request$2);
4480
+ const statusCode = proxied.statusCode ?? 502;
4481
+ const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
4407
4482
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
4408
4483
  const compressed = shouldCompressResponse(request$2, proxied);
4409
4484
  if (compressed) {
@@ -4413,7 +4488,7 @@ var MobileAccessGateway = class {
4413
4488
  headers["content-encoding"] = "gzip";
4414
4489
  addVaryAcceptEncoding(headers);
4415
4490
  }
4416
- response.writeHead(proxied.statusCode ?? 502, headers);
4491
+ response.writeHead(statusCode, headers);
4417
4492
  await Promise.all([bodyDone, compressed ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
4418
4493
  } catch (error) {
4419
4494
  holder.request?.destroy();
@@ -4623,6 +4698,7 @@ var MobileAccessGateway = class {
4623
4698
  const authorization = this.authorize(request);
4624
4699
  if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
4625
4700
  const upstreamCookie = await this.upstreamCookieHeader();
4701
+ if (client.destroyed) return;
4626
4702
  const upstream = connect({
4627
4703
  host: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4628
4704
  port: Number(this.config.upstreamOrigin.port)
@@ -6721,10 +6797,23 @@ async function attemptDownload(request) {
6721
6797
  });
6722
6798
  const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
6723
6799
  if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
6724
- const declared = Number(response.headers.get("content-length"));
6725
- if (Number.isFinite(declared) && declared !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6726
- const bytes = new Uint8Array(await response.arrayBuffer());
6727
- if (bytes.byteLength !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6800
+ const contentLength = response.headers.get("content-length");
6801
+ if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6802
+ if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6803
+ const bytes = new Uint8Array(request.expectedBytes);
6804
+ const reader = response.body.getReader();
6805
+ let received = 0;
6806
+ while (true) {
6807
+ const chunk = await reader.read();
6808
+ if (chunk.done) break;
6809
+ if (chunk.value.byteLength > bytes.byteLength - received) {
6810
+ await reader.cancel();
6811
+ throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6812
+ }
6813
+ bytes.set(chunk.value, received);
6814
+ received += chunk.value.byteLength;
6815
+ }
6816
+ if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6728
6817
  return bytes;
6729
6818
  }
6730
6819
  /**
@@ -11312,7 +11401,9 @@ async function apply(ctx, config) {
11312
11401
  ...candidateConfig,
11313
11402
  upstreamOrigin: template.upstreamOrigin.origin
11314
11403
  });
11315
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
11404
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
11405
+ logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
11406
+ });
11316
11407
  await candidate.start();
11317
11408
  lanGateway = candidate;
11318
11409
  const removeTaskSink = taskEventHub.add(candidate);
@@ -11854,8 +11945,7 @@ async function apply(ctx, config) {
11854
11945
  text: `${guide}\n\n用户需求:${task}`
11855
11946
  }],
11856
11947
  source: {
11857
- kind: "plugin",
11858
- plugin: "dsh-mobile",
11948
+ kind: "plugin:dsh-mobile",
11859
11949
  form: "notice",
11860
11950
  summary: boundContextSummary(`/mobile ${task}`)
11861
11951
  }