dsh-mobile 0.3.14 → 0.3.15

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
@@ -645,7 +645,20 @@ declare class MobileAccessGateway {
645
645
  private proxyMobileIndex;
646
646
  private rememberMobileBootBatch;
647
647
  private serveMobileBootBatch;
648
+ private startMobileBootBatchAssembly;
648
649
  private assembleMobileBootBatch;
650
+ /**
651
+ * Read one upstream bundle, retrying transient connection failures.
652
+ *
653
+ * Assembling a batch fans out over every client entry, and the upstream
654
+ * resets a fraction of those connections before sending a byte
655
+ * (`read ECONNRESET`, recv=0) — randomly, on any entry, at any concurrency.
656
+ * A single such reset used to fail the whole batch with 502
657
+ * `upstream_unavailable`, surfacing in the browser as "bundle script
658
+ * /mobile-access/mobile-boot/<hash>.js failed to load". Retrying recovers
659
+ * every observed reset; a genuine upstream error still fails.
660
+ */
661
+ private readUpstreamClientBundleWithRetry;
649
662
  private readUpstreamClientBundle;
650
663
  private allocateRequest;
651
664
  private proxyHttp;
package/lib/index.mjs CHANGED
@@ -1061,7 +1061,8 @@ const AUTH_PREFIX = "/mobile-access";
1061
1061
  const WS_PATHS = /* @__PURE__ */ new Set([
1062
1062
  "/api/events.mux",
1063
1063
  "/api/events.host",
1064
- "/api/remote.mux"
1064
+ "/api/remote.mux",
1065
+ "/sidebar/ws/terminal"
1065
1066
  ]);
1066
1067
  /** Terse request failure safe to expose without internal diagnostics. */
1067
1068
  var HttpError = class extends Error {
@@ -2108,6 +2109,21 @@ const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
2108
2109
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
2109
2110
  const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
2110
2111
  const MAX_MOBILE_BOOT_BATCHES = 8;
2112
+ const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
2113
+ const MOBILE_BOOT_RETRY_DELAY_MS = 150;
2114
+ const TRANSIENT_UPSTREAM_ERROR_CODES = /* @__PURE__ */ new Set([
2115
+ "EAI_AGAIN",
2116
+ "ECONNABORTED",
2117
+ "ECONNREFUSED",
2118
+ "ECONNRESET",
2119
+ "EPIPE",
2120
+ "EHOSTUNREACH",
2121
+ "ENETDOWN",
2122
+ "ENETRESET",
2123
+ "ENETUNREACH",
2124
+ "ETIMEDOUT",
2125
+ "ERR_STREAM_PREMATURE_CLOSE"
2126
+ ]);
2111
2127
  const UPSTREAM_AUTH_REFRESH_MARGIN_MS = 6e4;
2112
2128
  const UPSTREAM_COOKIE_PAIR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+=[\x21-\x3A\x3C-\x7E]*$/u;
2113
2129
  const CUSTOM_STYLE_FALLBACK = "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n";
@@ -2575,6 +2591,51 @@ function mapError(error) {
2575
2591
  if (error instanceof MobileExtensionError) return new HttpError(error.status, error.code);
2576
2592
  return new HttpError(500, "internal_error");
2577
2593
  }
2594
+ function requestAbortedError() {
2595
+ const error = /* @__PURE__ */ new Error("request aborted");
2596
+ error.name = "AbortError";
2597
+ return error;
2598
+ }
2599
+ function isTransientUpstreamError(error) {
2600
+ if (!(error instanceof Error)) return false;
2601
+ const code = error.code;
2602
+ return typeof code === "string" && TRANSIENT_UPSTREAM_ERROR_CODES.has(code);
2603
+ }
2604
+ function upstreamTimeoutError() {
2605
+ const error = /* @__PURE__ */ new Error("upstream timeout");
2606
+ error.code = "ETIMEDOUT";
2607
+ return error;
2608
+ }
2609
+ function waitForAbortableDelay(delayMs, signal) {
2610
+ if (signal.aborted) return Promise.reject(requestAbortedError());
2611
+ return new Promise((resolve, reject) => {
2612
+ const aborted = () => {
2613
+ clearTimeout(timer);
2614
+ reject(requestAbortedError());
2615
+ };
2616
+ const timer = setTimeout(() => {
2617
+ signal.removeEventListener("abort", aborted);
2618
+ resolve();
2619
+ }, delayMs);
2620
+ signal.addEventListener("abort", aborted, { once: true });
2621
+ });
2622
+ }
2623
+ function waitForRequestTask(task, signal) {
2624
+ if (signal.aborted) return Promise.reject(requestAbortedError());
2625
+ return new Promise((resolve, reject) => {
2626
+ const aborted = () => {
2627
+ reject(requestAbortedError());
2628
+ };
2629
+ signal.addEventListener("abort", aborted, { once: true });
2630
+ task.then((value) => {
2631
+ signal.removeEventListener("abort", aborted);
2632
+ resolve(value);
2633
+ }, (error) => {
2634
+ signal.removeEventListener("abort", aborted);
2635
+ reject(error);
2636
+ });
2637
+ });
2638
+ }
2578
2639
  function discoveryDeviceName() {
2579
2640
  const value = hostname().trim().replaceAll(/[\u0000-\u001f\u007f]/gu, "");
2580
2641
  return (value === "" ? "DeepSeek Harness" : value).slice(0, 63);
@@ -3574,6 +3635,7 @@ var MobileAccessGateway = class {
3574
3635
  while (this.mobileBootBatches.size > MAX_MOBILE_BOOT_BATCHES) {
3575
3636
  const oldest = this.mobileBootBatches.keys().next().value;
3576
3637
  if (oldest === void 0) break;
3638
+ this.mobileBootBatches.get(oldest)?.assembly?.controller.abort();
3577
3639
  this.mobileBootBatches.delete(oldest);
3578
3640
  }
3579
3641
  }
@@ -3582,17 +3644,17 @@ var MobileAccessGateway = class {
3582
3644
  const stored = this.mobileBootBatches.get(key);
3583
3645
  if (stored === void 0) throw new HttpError(404, "not_found");
3584
3646
  const operation = this.allocateRequest(authorization, response, {});
3647
+ response.once("close", operation.abort);
3585
3648
  try {
3586
3649
  const layoutStat = await stat(this.config.mobileLayoutFile);
3587
3650
  if (stored.body === void 0 || stored.etag === void 0 || stored.layoutMtimeMs !== layoutStat.mtimeMs) {
3588
- const body = await this.assembleMobileBootBatch(stored.plan, operation.signal);
3589
- stored.body = body;
3590
- delete stored.gzipBody;
3591
- stored.etag = createHash("sha256").update(body).digest("hex");
3592
- stored.layoutMtimeMs = layoutStat.mtimeMs;
3651
+ operation.signal.throwIfAborted();
3652
+ await waitForRequestTask((stored.assembly ?? this.startMobileBootBatchAssembly(stored, layoutStat.mtimeMs)).task, operation.signal);
3593
3653
  }
3594
3654
  const compressed = acceptsGzip(request.headers["accept-encoding"]);
3595
- const body = compressed ? stored.gzipBody ??= await gzipBuffer(stored.body) : stored.body;
3655
+ const assembled = stored.body;
3656
+ if (assembled === void 0) throw new HttpError(502, "upstream_unavailable");
3657
+ const body = compressed ? stored.gzipBody ??= await gzipBuffer(assembled) : assembled;
3596
3658
  const etag = compressed ? `${stored.etag}-gzip` : stored.etag;
3597
3659
  const headers = {
3598
3660
  "Content-Type": "text/javascript; charset=utf-8",
@@ -3620,9 +3682,32 @@ var MobileAccessGateway = class {
3620
3682
  if (error.code === "ENOENT") throw new HttpError(503, "mobile_frontend_unavailable");
3621
3683
  throw error;
3622
3684
  } finally {
3685
+ response.removeListener("close", operation.abort);
3623
3686
  operation.release();
3624
3687
  }
3625
3688
  }
3689
+ startMobileBootBatchAssembly(stored, layoutMtimeMs) {
3690
+ const controller = new AbortController();
3691
+ const task = (async () => {
3692
+ const body = await this.assembleMobileBootBatch(stored.plan, controller.signal);
3693
+ stored.body = body;
3694
+ delete stored.gzipBody;
3695
+ stored.etag = createHash("sha256").update(body).digest("hex");
3696
+ stored.layoutMtimeMs = layoutMtimeMs;
3697
+ return body;
3698
+ })();
3699
+ const assembly = Object.freeze({
3700
+ controller,
3701
+ task
3702
+ });
3703
+ stored.assembly = assembly;
3704
+ task.then(() => {
3705
+ if (stored.assembly === assembly) delete stored.assembly;
3706
+ }, () => {
3707
+ if (stored.assembly === assembly) delete stored.assembly;
3708
+ });
3709
+ return assembly;
3710
+ }
3626
3711
  async assembleMobileBootBatch(plan, signal) {
3627
3712
  const bodies = new Array(plan.entries.length);
3628
3713
  let cursor = 0;
@@ -3630,15 +3715,41 @@ var MobileAccessGateway = class {
3630
3715
  while (cursor < plan.entries.length) {
3631
3716
  const index = cursor++;
3632
3717
  const entry = plan.entries[index];
3633
- bodies[index] = entry.id === MOBILE_LAYOUT_MODULE ? await readFile(this.config.mobileLayoutFile, { signal }) : await this.readUpstreamClientBundle(entry.url, signal);
3718
+ bodies[index] = entry.id === MOBILE_LAYOUT_MODULE ? await readFile(this.config.mobileLayoutFile, { signal }) : await this.readUpstreamClientBundleWithRetry(entry.url, signal);
3634
3719
  if (bodies[index].byteLength > MAX_MOBILE_BOOT_ENTRY_BYTES) throw new HttpError(502, "upstream_unavailable");
3635
3720
  }
3636
3721
  };
3637
- await Promise.all(Array.from({ length: Math.min(8, plan.entries.length) }, worker));
3722
+ await Promise.all(Array.from({ length: Math.min(3, plan.entries.length) }, worker));
3638
3723
  if (bodies.reduce((bytes, body) => bytes + body.byteLength + 2, 0) > MAX_MOBILE_BOOT_BATCH_BYTES) throw new HttpError(502, "upstream_unavailable");
3639
3724
  return Buffer.concat(bodies.flatMap((body) => [body, Buffer.from("\n;\n")]));
3640
3725
  }
3726
+ /**
3727
+ * Read one upstream bundle, retrying transient connection failures.
3728
+ *
3729
+ * Assembling a batch fans out over every client entry, and the upstream
3730
+ * resets a fraction of those connections before sending a byte
3731
+ * (`read ECONNRESET`, recv=0) — randomly, on any entry, at any concurrency.
3732
+ * A single such reset used to fail the whole batch with 502
3733
+ * `upstream_unavailable`, surfacing in the browser as "bundle script
3734
+ * /mobile-access/mobile-boot/<hash>.js failed to load". Retrying recovers
3735
+ * every observed reset; a genuine upstream error still fails.
3736
+ */
3737
+ async readUpstreamClientBundleWithRetry(source, signal) {
3738
+ for (let attempt = 1; attempt <= MOBILE_BOOT_UPSTREAM_ATTEMPTS; attempt++) {
3739
+ signal.throwIfAborted();
3740
+ try {
3741
+ return await this.readUpstreamClientBundle(source, signal);
3742
+ } catch (error) {
3743
+ if (signal.aborted) throw error;
3744
+ if (!isTransientUpstreamError(error)) throw error;
3745
+ if (attempt === MOBILE_BOOT_UPSTREAM_ATTEMPTS) throw new HttpError(502, "upstream_unavailable");
3746
+ await waitForAbortableDelay(MOBILE_BOOT_RETRY_DELAY_MS * attempt, signal);
3747
+ }
3748
+ }
3749
+ throw new HttpError(502, "upstream_unavailable");
3750
+ }
3641
3751
  async readUpstreamClientBundle(source, signal) {
3752
+ signal.throwIfAborted();
3642
3753
  if (!source.startsWith("/plugins/") || source.includes("#")) throw new HttpError(502, "upstream_unavailable");
3643
3754
  const target = new URL(source, this.config.upstreamOrigin);
3644
3755
  if (target.origin !== this.config.upstreamOrigin.origin) throw new HttpError(502, "upstream_unavailable");
@@ -3649,6 +3760,7 @@ var MobileAccessGateway = class {
3649
3760
  signal.addEventListener("abort", aborted, { once: true });
3650
3761
  try {
3651
3762
  const upstreamCookie = await this.upstreamCookieHeader();
3763
+ signal.throwIfAborted();
3652
3764
  const proxied = await new Promise((resolve, reject) => {
3653
3765
  upstreamRequest = request({
3654
3766
  protocol: "http:",
@@ -3665,7 +3777,7 @@ var MobileAccessGateway = class {
3665
3777
  agent: false
3666
3778
  });
3667
3779
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
3668
- upstreamRequest?.destroy(/* @__PURE__ */ new Error("upstream timeout"));
3780
+ upstreamRequest?.destroy(upstreamTimeoutError());
3669
3781
  });
3670
3782
  upstreamRequest.once("response", resolve);
3671
3783
  upstreamRequest.once("error", reject);
@@ -3683,6 +3795,7 @@ var MobileAccessGateway = class {
3683
3795
  return Buffer.concat(chunks);
3684
3796
  } catch (error) {
3685
3797
  if (error instanceof HttpError) throw error;
3798
+ if (signal.aborted || isTransientUpstreamError(error)) throw error;
3686
3799
  throw new HttpError(502, "upstream_unavailable");
3687
3800
  } finally {
3688
3801
  signal.removeEventListener("abort", aborted);
@@ -3708,6 +3821,7 @@ var MobileAccessGateway = class {
3708
3821
  return {
3709
3822
  id,
3710
3823
  signal: controller.signal,
3824
+ abort,
3711
3825
  release: () => {
3712
3826
  const entry = this.activeRequests.get(id);
3713
3827
  if (entry !== void 0) clearTimeout(entry.timer);
@@ -4105,6 +4219,7 @@ var MobileAccessGateway = class {
4105
4219
  this.upstreamAuthRequest = void 0;
4106
4220
  this.removeSessionListener();
4107
4221
  const accessClose = this.access.close();
4222
+ for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
4108
4223
  for (const request of this.activeRequests.values()) request.abort();
4109
4224
  for (const websocket of this.activeWebSockets.values()) {
4110
4225
  websocket.client.destroy();
@@ -7340,6 +7455,18 @@ async function reserveLoopbackPort() {
7340
7455
  }
7341
7456
  };
7342
7457
  }
7458
+ function spawnCpolarProcess(executable, args, environment) {
7459
+ return spawn(executable, [...args], {
7460
+ env: environment,
7461
+ shell: false,
7462
+ stdio: [
7463
+ "pipe",
7464
+ "pipe",
7465
+ "pipe"
7466
+ ],
7467
+ windowsHide: true
7468
+ });
7469
+ }
7343
7470
  function withoutProxyEnvironment(environment) {
7344
7471
  const blocked = /* @__PURE__ */ new Set([
7345
7472
  "HTTP_PROXY",
@@ -7526,16 +7653,7 @@ var CpolarController = class {
7526
7653
  "-log-level=INFO",
7527
7654
  String(reservation.port)
7528
7655
  ];
7529
- const child = spawn(this.options.executable, args, {
7530
- env: withoutProxyEnvironment(process.env),
7531
- shell: false,
7532
- stdio: [
7533
- "pipe",
7534
- "pipe",
7535
- "pipe"
7536
- ],
7537
- windowsHide: true
7538
- });
7656
+ const child = (this.options.spawnProcess ?? spawnCpolarProcess)(this.options.executable, args, withoutProxyEnvironment(process.env));
7539
7657
  this.child = child;
7540
7658
  child.stdout.setEncoding("utf8");
7541
7659
  child.stderr.setEncoding("utf8");
@@ -7581,7 +7699,13 @@ var CpolarController = class {
7581
7699
  }
7582
7700
  }
7583
7701
  async attachGateway(generation, origin) {
7584
- if (generation !== this.generation || !this.enabled || this.gatewayValue !== void 0) return;
7702
+ if (generation !== this.generation || !this.enabled || this.disposed) return;
7703
+ const current = this.gatewayValue;
7704
+ if (current !== void 0) {
7705
+ if (current.address().origin === origin) return;
7706
+ await this.rotateGateway(generation, origin, current);
7707
+ return;
7708
+ }
7585
7709
  const reservation = this.reservation;
7586
7710
  if (reservation === void 0) return;
7587
7711
  this.publish({
@@ -7598,7 +7722,7 @@ var CpolarController = class {
7598
7722
  await this.failGeneration(generation, "gateway_start_failed");
7599
7723
  return;
7600
7724
  }
7601
- if (generation !== this.generation || !this.enabled) {
7725
+ if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) {
7602
7726
  await gateway.close();
7603
7727
  return;
7604
7728
  }
@@ -7611,6 +7735,40 @@ var CpolarController = class {
7611
7735
  origin
7612
7736
  });
7613
7737
  }
7738
+ /** Replace the gateway authority when cpolar rotates a temporary public origin. */
7739
+ async rotateGateway(generation, origin, current) {
7740
+ const listenPort = current.address().port;
7741
+ if (this.gatewayValue === current) this.gatewayValue = void 0;
7742
+ this.publish({
7743
+ enabled: true,
7744
+ state: "connecting",
7745
+ origin
7746
+ });
7747
+ try {
7748
+ await current.close();
7749
+ } catch {
7750
+ await this.failGeneration(generation, "gateway_start_failed");
7751
+ return;
7752
+ }
7753
+ if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) return;
7754
+ let replacement;
7755
+ try {
7756
+ replacement = await this.options.createGateway(origin, listenPort);
7757
+ } catch {
7758
+ await this.failGeneration(generation, "gateway_start_failed");
7759
+ return;
7760
+ }
7761
+ if (generation !== this.generation || !this.enabled || this.disposed || this.child === void 0) {
7762
+ await replacement.close();
7763
+ return;
7764
+ }
7765
+ this.gatewayValue = replacement;
7766
+ this.publish({
7767
+ enabled: true,
7768
+ state: "ready",
7769
+ origin
7770
+ });
7771
+ }
7614
7772
  async failGeneration(generation, code) {
7615
7773
  if (generation !== this.generation) return;
7616
7774
  await this.stopProcessAndGateway();
@@ -8582,9 +8740,28 @@ async function settleCleanupSteps(steps) {
8582
8740
  if (errors.length === 1 && errors[0] instanceof Error) throw errors[0];
8583
8741
  if (errors.length > 0) throw new AggregateError(errors, "DSH Mobile cleanup failed");
8584
8742
  }
8743
+ /**
8744
+ * Resolve the DSH launch-token URL the gateway exchanges for its upstream cookie.
8745
+ *
8746
+ * A layer that disables DSH browser authentication — dsh-lan-access with
8747
+ * `noAuth: true` replaces `authenticatedUrl` with one returning the bare origin
8748
+ * — produces a URL with no query string, so it cannot carry a launch token. That
8749
+ * is a legitimate "this upstream needs no browser auth" signal: report no URL and
8750
+ * the gateway proxies without a cookie instead of failing every route with
8751
+ * `upstream_unavailable`. A token-bearing URL keeps the existing exchange, and a
8752
+ * connection service that is absent or returns a malformed URL keeps failing
8753
+ * closed. Only parsing is guarded: a connection service that throws still fails
8754
+ * plugin activation loudly rather than silently proxying without authentication.
8755
+ */
8585
8756
  function upstreamAuthenticatedUrl(ctx, upstreamOrigin) {
8586
8757
  const connection = ctx.connection;
8587
- return typeof connection?.authenticatedUrl === "function" ? connection.authenticatedUrl(upstreamOrigin.origin) : void 0;
8758
+ if (typeof connection?.authenticatedUrl !== "function") return void 0;
8759
+ const authenticatedUrl = connection.authenticatedUrl(upstreamOrigin.origin);
8760
+ try {
8761
+ return new URL(authenticatedUrl).search === "" ? void 0 : authenticatedUrl;
8762
+ } catch {
8763
+ return;
8764
+ }
8588
8765
  }
8589
8766
  function installedDshVersion() {
8590
8767
  try {