dsh-mobile 0.4.2 → 0.4.4

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.mjs CHANGED
@@ -877,6 +877,17 @@ var FollowingMobileAccessRuntime = class {
877
877
  async initialize(refreshIntervalMs) {
878
878
  await this.refresh();
879
879
  if (refreshIntervalMs === void 0) return;
880
+ this.beginPolling(refreshIntervalMs);
881
+ }
882
+ /**
883
+ * Arm the refresh poller without requiring an initial selection.
884
+ * Boot-degraded path: the first selection already failed (e.g. the saved
885
+ * LAN is down), but later refreshes must still get their chance so a
886
+ * returning network recovers without a restart. Failures keep flowing to
887
+ * onRefreshError; close() stays the single teardown.
888
+ */
889
+ beginPolling(refreshIntervalMs) {
890
+ if (this.timer !== void 0) return;
880
891
  this.timer = setInterval(() => {
881
892
  this.refresh().catch(this.onRefreshError);
882
893
  }, refreshIntervalMs);
@@ -2424,6 +2435,19 @@ const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
2424
2435
  const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
2425
2436
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
2426
2437
  const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
2438
+ /**
2439
+ * Per-merged-batch byte budget. One upstream application batch can carry dozens
2440
+ * of client bundles whose combined size exceeds the hard batch cap; the layout
2441
+ * batch is therefore chunked into multiple merged batches, each kept safely
2442
+ * below {@link MAX_MOBILE_BOOT_BATCH_BYTES} so assembly can never reject it.
2443
+ */
2444
+ const MOBILE_BOOT_CHUNK_BYTES = 16777216;
2445
+ /** Bytes the assembly emits after each entry (`body` plus `\n;\n`). */
2446
+ const MOBILE_BOOT_SEPARATOR_BYTES = 3;
2447
+ /** Freshness window for cached upstream bundle byte sizes. */
2448
+ const MOBILE_BOOT_SIZE_CACHE_TTL_MS = 3e4;
2449
+ /** Marker header distinguishing size probes from ordinary proxied bundle fetches. */
2450
+ const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
2427
2451
  const MAX_MOBILE_BOOT_BATCHES = 8;
2428
2452
  const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
2429
2453
  const MOBILE_BOOT_RETRY_DELAY_MS = 150;
@@ -2508,11 +2532,17 @@ function revisionedMobileBatchPath(entries) {
2508
2532
  path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
2509
2533
  };
2510
2534
  }
2511
- function rewriteMobileIndexWithBatch(html) {
2535
+ /**
2536
+ * Parse one upstream boot manifest and rewire the layout entry onto the
2537
+ * gateway-owned layout module. The layout entry's URL and revision become the
2538
+ * local `mobile-layout.js` path; the dedicated frontend is authenticated in the
2539
+ * gateway, so the requested layout needs no upstream address.
2540
+ */
2541
+ function parseMobileBootPlan(html) {
2512
2542
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
2513
2543
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
2514
- const start = assignment.index;
2515
- const valueStart = start + assignment[0].length;
2544
+ const replaceStart = assignment.index;
2545
+ const valueStart = replaceStart + assignment[0].length;
2516
2546
  const scriptEnd = html.indexOf("<\/script>", valueStart);
2517
2547
  if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
2518
2548
  const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
@@ -2526,45 +2556,122 @@ function rewriteMobileIndexWithBatch(html) {
2526
2556
  if (dependencyProfile === void 0) throw new Error("upstream DSH layout module has unsupported dependencies");
2527
2557
  layout[0].url = MOBILE_LAYOUT_PATH;
2528
2558
  layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
2529
- const remoteSettings = orderAuthenticatedSettings(entries, dependencyProfile.slots);
2530
- let mobileBatch;
2531
- if (parsed.batches !== void 0) {
2532
- if (!Array.isArray(parsed.batches)) throw new Error("upstream DSH boot manifest batches are malformed");
2533
- const batches = parsed.batches;
2534
- const entryById = new Map(entries.map((entry) => [entry.id, entry]));
2535
- if (entryById.size !== entries.length) throw new Error("upstream DSH boot manifest has duplicate entries");
2536
- const layoutBatches = [];
2537
- for (const batch of batches) {
2538
- if (batch === null || typeof batch !== "object" || batch.phase !== "bootstrap" && batch.phase !== "application" || typeof batch.url !== "string" || typeof batch.rev !== "string" || !Array.isArray(batch.entries) || batch.entries.length === 0 || batch.entries.some((id) => typeof id !== "string" || !entryById.has(id))) throw new Error("upstream DSH boot manifest batches are malformed");
2539
- if (batch.entries.includes(MOBILE_LAYOUT_MODULE)) layoutBatches.push(batch);
2540
- }
2541
- if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2542
- const layoutBatch = layoutBatches[0];
2543
- const planEntries = layoutBatch.entries.map((id) => {
2544
- const entry = entryById.get(id);
2545
- if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2546
- return Object.freeze({
2547
- id,
2559
+ const parsedBatches = parsed.batches;
2560
+ if (parsedBatches === void 0) return Object.freeze({
2561
+ parsed,
2562
+ entries,
2563
+ slotProvider: dependencyProfile.slots,
2564
+ assignment: assignment[0],
2565
+ replaceStart,
2566
+ replaceEnd: scriptEnd
2567
+ });
2568
+ if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
2569
+ const batches = parsedBatches;
2570
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
2571
+ if (entryById.size !== entries.length) throw new Error("upstream DSH boot manifest has duplicate entries");
2572
+ const layoutBatches = [];
2573
+ for (const batch of batches) {
2574
+ if (batch === null || typeof batch !== "object" || batch.phase !== "bootstrap" && batch.phase !== "application" || typeof batch.url !== "string" || typeof batch.rev !== "string" || !Array.isArray(batch.entries) || batch.entries.length === 0 || batch.entries.some((id) => typeof id !== "string" || !entryById.has(id))) throw new Error("upstream DSH boot manifest batches are malformed");
2575
+ if (batch.entries.includes(MOBILE_LAYOUT_MODULE)) layoutBatches.push(batch);
2576
+ }
2577
+ if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2578
+ const layoutBatch = layoutBatches[0];
2579
+ const planEntries = layoutBatch.entries.map((id) => {
2580
+ const entry = entryById.get(id);
2581
+ if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2582
+ return Object.freeze({
2583
+ id,
2584
+ url: entry.url,
2585
+ rev: entry.rev
2586
+ });
2587
+ });
2588
+ return Object.freeze({
2589
+ parsed,
2590
+ entries,
2591
+ batches,
2592
+ slotProvider: dependencyProfile.slots,
2593
+ assignment: assignment[0],
2594
+ replaceStart,
2595
+ replaceEnd: scriptEnd,
2596
+ layoutBatch,
2597
+ planEntries
2598
+ });
2599
+ }
2600
+ /**
2601
+ * Partition one upstream application batch into merged batches plus pass-through
2602
+ * rows. Every entry whose bundle is at or above the per-entry cap keeps its own
2603
+ * upstream `/plugins` row (the gateway proxies it verbatim), because a merged
2604
+ * batch cannot carry it. The remaining entries are greedily packed into merged
2605
+ * batches under {@link MOBILE_BOOT_CHUNK_BYTES}; the layout module always merges,
2606
+ * since its body is the gateway-owned layout file, never an upstream fetch.
2607
+ */
2608
+ function splitMobileBootBatch(entries, sizes, passThrough) {
2609
+ const rows = [];
2610
+ const plans = [];
2611
+ let chunk;
2612
+ const flush = () => {
2613
+ if (chunk === void 0 || chunk.entries.length === 0) return;
2614
+ const revision = revisionedMobileBatchPath(chunk.entries);
2615
+ rows.push({
2616
+ phase: "application",
2617
+ url: revision.path,
2618
+ rev: revision.key,
2619
+ entries: chunk.ids
2620
+ });
2621
+ plans.push(Object.freeze({
2622
+ key: revision.key,
2623
+ path: revision.path,
2624
+ entries: Object.freeze(chunk.entries)
2625
+ }));
2626
+ chunk = void 0;
2627
+ };
2628
+ for (const entry of entries) {
2629
+ const size = sizes.get(entry.url);
2630
+ if ((passThrough.has(entry.url) || size !== void 0 && size >= MAX_MOBILE_BOOT_ENTRY_BYTES) && entry.id !== MOBILE_LAYOUT_MODULE) {
2631
+ rows.push({
2632
+ phase: "application",
2548
2633
  url: entry.url,
2549
- rev: entry.rev
2634
+ rev: entry.rev,
2635
+ entries: [entry.id]
2550
2636
  });
2551
- });
2552
- const revision = revisionedMobileBatchPath(planEntries);
2553
- layoutBatch.url = revision.path;
2554
- layoutBatch.rev = revision.key;
2555
- mobileBatch = Object.freeze({
2556
- ...revision,
2557
- entries: Object.freeze(planEntries)
2558
- });
2559
- parsed.rev = createHash("sha256").update(JSON.stringify({
2560
- entries,
2561
- batches
2637
+ continue;
2638
+ }
2639
+ const bytes = (size ?? 0) + MOBILE_BOOT_SEPARATOR_BYTES;
2640
+ if (chunk !== void 0 && chunk.entries.length > 0 && chunk.bytes + bytes > MOBILE_BOOT_CHUNK_BYTES) flush();
2641
+ chunk ??= {
2642
+ ids: [],
2643
+ entries: [],
2644
+ bytes: 0
2645
+ };
2646
+ chunk.ids.push(entry.id);
2647
+ chunk.entries.push(entry);
2648
+ chunk.bytes += bytes;
2649
+ }
2650
+ flush();
2651
+ return {
2652
+ plans,
2653
+ rows
2654
+ };
2655
+ }
2656
+ function rewriteMobileIndexWithBatch(html, options = {}) {
2657
+ const plan = parseMobileBootPlan(html);
2658
+ const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
2659
+ let batches = [];
2660
+ if (plan.batches !== void 0 && plan.layoutBatch !== void 0 && plan.planEntries !== void 0) {
2661
+ const split = splitMobileBootBatch(plan.planEntries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set());
2662
+ const at = plan.batches.indexOf(plan.layoutBatch);
2663
+ if (at < 0) throw new Error("upstream DSH boot manifest has no unique application layout batch");
2664
+ plan.batches.splice(at, 1, ...split.rows);
2665
+ batches = Object.freeze(split.plans);
2666
+ plan.parsed.rev = createHash("sha256").update(JSON.stringify({
2667
+ entries: plan.entries,
2668
+ batches: plan.batches
2562
2669
  })).digest("hex").slice(0, 16);
2563
2670
  }
2564
- const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment[0]}${JSON.stringify(parsed)};`;
2671
+ const replacement = `${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2565
2672
  return Object.freeze({
2566
- html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`)),
2567
- ...mobileBatch === void 0 ? {} : { batch: mobileBatch }
2673
+ html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2674
+ batches
2568
2675
  });
2569
2676
  }
2570
2677
  /** Replace only DSH's layout client module while retaining its complete plugin graph. */
@@ -2884,6 +2991,13 @@ function discoveryBroadcastTargets(cidrs) {
2884
2991
  }
2885
2992
  return [...targets];
2886
2993
  }
2994
+ function discoveryFailure(error) {
2995
+ const code = error instanceof Error && typeof error.code === "string" ? error.code : "unknown";
2996
+ return Object.freeze({
2997
+ code,
2998
+ message: error instanceof Error ? error.message : String(error)
2999
+ });
3000
+ }
2887
3001
  function extensionTarget(pathname) {
2888
3002
  const prefix = `${AUTH_PREFIX}/extensions`;
2889
3003
  if (pathname === prefix || pathname === `${prefix}/` || pathname === `${prefix}/manifest`) return { kind: "manifest" };
@@ -2981,6 +3095,7 @@ var MobileAccessGateway = class {
2981
3095
  upstreamAuthenticatedUrl;
2982
3096
  extraWebSocketPaths;
2983
3097
  blockedUpgradeLog;
3098
+ onDiscoveryDegraded;
2984
3099
  access;
2985
3100
  listenerTlsEnabled;
2986
3101
  tlsEnabled;
@@ -2995,6 +3110,7 @@ var MobileAccessGateway = class {
2995
3110
  * it, "the phone cannot find this computer" has no diagnosable cause on the host.
2996
3111
  */
2997
3112
  discoveryError;
3113
+ mdnsError;
2998
3114
  /** Names of the bundled mobile assets that could not be read, if any. */
2999
3115
  mobileAssetError;
3000
3116
  bonjour;
@@ -3004,6 +3120,8 @@ var MobileAccessGateway = class {
3004
3120
  activeRequests = /* @__PURE__ */ new Map();
3005
3121
  activeWebSockets = /* @__PURE__ */ new Map();
3006
3122
  mobileBootBatches = /* @__PURE__ */ new Map();
3123
+ /** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
3124
+ bootEntrySizeCache = /* @__PURE__ */ new Map();
3007
3125
  extensionEventListeners = /* @__PURE__ */ new Set();
3008
3126
  extensionEventRevision = 0;
3009
3127
  taskEventListeners = /* @__PURE__ */ new Set();
@@ -3024,12 +3142,13 @@ var MobileAccessGateway = class {
3024
3142
  removeExtensionContentListener;
3025
3143
  renewLimiter;
3026
3144
  probeLimiter;
3027
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog) {
3145
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded) {
3028
3146
  this.config = config;
3029
3147
  this.extensions = extensions;
3030
3148
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
3031
3149
  this.extraWebSocketPaths = extraWebSocketPaths;
3032
3150
  this.blockedUpgradeLog = blockedUpgradeLog;
3151
+ this.onDiscoveryDegraded = onDiscoveryDegraded;
3033
3152
  this.listenerTlsEnabled = config.tls.mode === "provided";
3034
3153
  this.tlsEnabled = config.publicTls;
3035
3154
  this.access = new AccessController(store, {
@@ -3143,33 +3262,56 @@ var MobileAccessGateway = class {
3143
3262
  const socket = createSocket("udp4");
3144
3263
  this.discoverySocket = socket;
3145
3264
  const announcement = this.discoveryAnnouncement(port);
3265
+ let binding = true;
3266
+ socket.on("error", (error) => {
3267
+ if (!binding) this.recordBroadcastFailure(error, true);
3268
+ });
3269
+ const sendAnnouncement = (targetPort, address) => {
3270
+ if (this.closing || this.discoveryError !== void 0) return;
3271
+ try {
3272
+ socket.send(announcement, targetPort, address, (error) => {
3273
+ if (error !== null) this.recordBroadcastFailure(error, true);
3274
+ });
3275
+ } catch (error) {
3276
+ this.recordBroadcastFailure(error, true);
3277
+ }
3278
+ };
3146
3279
  socket.on("message", (message, remote) => {
3147
- if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3148
- socket.send(announcement, remote.port, remote.address, () => void 0);
3280
+ if (this.closing || this.discoveryError !== void 0 || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3281
+ sendAnnouncement(remote.port, remote.address);
3149
3282
  });
3150
3283
  const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
3151
3284
  if (await new Promise((resolve) => {
3152
3285
  const onBindError = (error) => {
3153
3286
  socket.off("error", onBindError);
3154
- this.discoveryError = Object.freeze({
3155
- code: typeof error.code === "string" ? error.code : "unknown",
3156
- message: typeof error.message === "string" ? error.message : String(error)
3157
- });
3287
+ this.recordBroadcastFailure(error, false);
3158
3288
  resolve(false);
3159
3289
  };
3160
3290
  socket.once("error", onBindError);
3161
- socket.bind(port, bindHost, () => {
3162
- socket.off("error", onBindError);
3163
- socket.setBroadcast(true);
3164
- resolve(true);
3165
- });
3166
- })) {
3291
+ try {
3292
+ socket.bind(port, bindHost, () => {
3293
+ socket.off("error", onBindError);
3294
+ binding = false;
3295
+ try {
3296
+ socket.setBroadcast(true);
3297
+ resolve(true);
3298
+ } catch (error) {
3299
+ this.recordBroadcastFailure(error, true);
3300
+ resolve(false);
3301
+ }
3302
+ });
3303
+ } catch (error) {
3304
+ onBindError(error);
3305
+ }
3306
+ }) && this.discoveryError === void 0) {
3167
3307
  const announce = () => {
3168
- for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
3308
+ for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) sendAnnouncement(port, target);
3169
3309
  };
3170
3310
  announce();
3171
- this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3172
- this.discoveryTimer.unref();
3311
+ if (this.discoveryError === void 0) {
3312
+ this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3313
+ this.discoveryTimer.unref();
3314
+ }
3173
3315
  } else {
3174
3316
  this.discoverySocket = void 0;
3175
3317
  try {
@@ -3177,8 +3319,12 @@ var MobileAccessGateway = class {
3177
3319
  } catch {}
3178
3320
  }
3179
3321
  const deviceName = discoveryDeviceName();
3180
- const bonjour = new Bonjour({ disableIPv6: true });
3322
+ const onMdnsError = (error) => {
3323
+ this.recordMdnsFailure(error);
3324
+ };
3325
+ const bonjour = new Bonjour({ disableIPv6: true }, onMdnsError);
3181
3326
  this.bonjour = bonjour;
3327
+ bonjour.server.mdns.on("error", onMdnsError);
3182
3328
  bonjour.publish({
3183
3329
  name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
3184
3330
  type: MDNS_SERVICE_TYPE,
@@ -3194,6 +3340,25 @@ var MobileAccessGateway = class {
3194
3340
  }
3195
3341
  });
3196
3342
  }
3343
+ reportDiscoveryDegraded(source, code) {
3344
+ try {
3345
+ this.onDiscoveryDegraded?.(source, code);
3346
+ } catch (error) {
3347
+ process.emitWarning(`DSH Mobile could not log ${source} discovery error: ${String(error)}`, { code: "DSH_MOBILE_DISCOVERY_LOG_FAILED" });
3348
+ }
3349
+ }
3350
+ recordBroadcastFailure(error, notify) {
3351
+ if (this.closing || this.discoveryError !== void 0) return;
3352
+ this.discoveryError = discoveryFailure(error);
3353
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
3354
+ this.discoveryTimer = void 0;
3355
+ if (notify) this.reportDiscoveryDegraded("broadcast", this.discoveryError.code);
3356
+ }
3357
+ recordMdnsFailure(error) {
3358
+ if (this.closing || this.mdnsError !== void 0) return;
3359
+ this.mdnsError = discoveryFailure(error);
3360
+ this.reportDiscoveryDegraded("mdns", this.mdnsError.code);
3361
+ }
3197
3362
  discoveryAnnouncement(port) {
3198
3363
  return Buffer.from(JSON.stringify({
3199
3364
  deviceName: discoveryDeviceName(),
@@ -3251,8 +3416,9 @@ var MobileAccessGateway = class {
3251
3416
  discoveryStatus() {
3252
3417
  return Object.freeze({
3253
3418
  broadcast: this.discoverySocket !== void 0 && this.discoveryTimer !== void 0,
3254
- mdns: this.bonjour !== void 0,
3419
+ mdns: this.bonjour !== void 0 && this.mdnsError === void 0,
3255
3420
  ...this.discoveryError === void 0 ? {} : { errorCode: `discovery_broadcast_${this.discoveryError.code}` },
3421
+ ...this.mdnsError === void 0 ? {} : { mdnsErrorCode: `discovery_mdns_${this.mdnsError.code}` },
3256
3422
  ...this.mobileAssetError === void 0 ? {} : { mobileAssetsErrorCode: this.mobileAssetError }
3257
3423
  });
3258
3424
  }
@@ -3904,8 +4070,10 @@ var MobileAccessGateway = class {
3904
4070
  }
3905
4071
  let body;
3906
4072
  try {
3907
- const rewritten = rewriteMobileIndexWithBatch(Buffer.concat(chunks).toString("utf8"));
3908
- if (rewritten.batch !== void 0) this.rememberMobileBootBatch(rewritten.batch);
4073
+ const html = Buffer.concat(chunks).toString("utf8");
4074
+ const plan = parseMobileBootPlan(html);
4075
+ const rewritten = rewriteMobileIndexWithBatch(html, plan.planEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.planEntries));
4076
+ for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
3909
4077
  body = Buffer.from(rewritten.html);
3910
4078
  } catch {
3911
4079
  throw new HttpError(502, "upstream_unavailable");
@@ -3941,6 +4109,98 @@ var MobileAccessGateway = class {
3941
4109
  this.mobileBootBatches.delete(oldest);
3942
4110
  }
3943
4111
  }
4112
+ /**
4113
+ * Determine which layout-batch entries can share a merged boot batch and which
4114
+ * must pass through. An entry at or above the per-entry cap, or one whose size
4115
+ * could not be probed, keeps its own upstream `/plugins` row: a merged batch
4116
+ * cannot carry it, and an unknown bundle must not risk the whole batch.
4117
+ */
4118
+ async resolveMobileBootSizes(planEntries) {
4119
+ const sizes = /* @__PURE__ */ new Map();
4120
+ const passThrough = /* @__PURE__ */ new Set();
4121
+ const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && entry.url.startsWith("/plugins/"));
4122
+ let cursor = 0;
4123
+ const worker = async () => {
4124
+ while (cursor < candidates.length) {
4125
+ const index = cursor++;
4126
+ const source = candidates[index].url;
4127
+ const size = await this.upstreamBundleSize(source);
4128
+ if (size === void 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
4129
+ else sizes.set(source, size);
4130
+ }
4131
+ };
4132
+ await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker));
4133
+ return {
4134
+ sizes,
4135
+ passThrough
4136
+ };
4137
+ }
4138
+ /**
4139
+ * Measure one upstream bundle by reading its body. The upstream serves
4140
+ * `/plugins` bundles as chunked streams without a Content-Length, so a probe
4141
+ * counts bytes; it aborts instantly past the per-entry cap. Measurements are
4142
+ * cached for a short window.
4143
+ */
4144
+ async upstreamBundleSize(source) {
4145
+ if (!source.startsWith("/plugins/") || source.includes("#")) return void 0;
4146
+ const cached = this.bootEntrySizeCache.get(source);
4147
+ if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
4148
+ const target = new URL(source, this.config.upstreamOrigin);
4149
+ if (target.origin !== this.config.upstreamOrigin.origin) return void 0;
4150
+ const upstreamCookie = await this.upstreamCookieHeader();
4151
+ let upstreamRequest;
4152
+ try {
4153
+ const proxied = await new Promise((resolve, reject) => {
4154
+ upstreamRequest = request({
4155
+ protocol: "http:",
4156
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4157
+ port: Number(this.config.upstreamOrigin.port),
4158
+ method: "GET",
4159
+ path: `${target.pathname}${target.search}`,
4160
+ headers: {
4161
+ host: this.config.upstreamOrigin.host,
4162
+ accept: "text/javascript",
4163
+ "accept-encoding": "identity",
4164
+ [MOBILE_BOOT_SIZE_PROBE_HEADER]: "1",
4165
+ ...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
4166
+ },
4167
+ agent: false
4168
+ });
4169
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
4170
+ upstreamRequest?.destroy(upstreamTimeoutError());
4171
+ });
4172
+ upstreamRequest.once("response", resolve);
4173
+ upstreamRequest.once("error", reject);
4174
+ upstreamRequest.end();
4175
+ });
4176
+ if (proxied.statusCode !== 200) {
4177
+ proxied.resume();
4178
+ return;
4179
+ }
4180
+ const size = await new Promise((resolve, reject) => {
4181
+ let bytes = 0;
4182
+ proxied.on("data", (chunk) => {
4183
+ bytes += chunk.length;
4184
+ if (bytes > MAX_MOBILE_BOOT_ENTRY_BYTES) {
4185
+ proxied.destroy();
4186
+ resolve(void 0);
4187
+ }
4188
+ });
4189
+ proxied.once("end", () => resolve(bytes));
4190
+ proxied.once("error", reject);
4191
+ });
4192
+ if (size === void 0) return void 0;
4193
+ this.bootEntrySizeCache.set(source, {
4194
+ size,
4195
+ at: Date.now()
4196
+ });
4197
+ return size;
4198
+ } catch {
4199
+ return;
4200
+ } finally {
4201
+ upstreamRequest?.destroy();
4202
+ }
4203
+ }
3944
4204
  async serveMobileBootBatch(key, request, response, authorization) {
3945
4205
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
3946
4206
  const stored = this.mobileBootBatches.get(key);
@@ -4022,7 +4282,7 @@ var MobileAccessGateway = class {
4022
4282
  }
4023
4283
  };
4024
4284
  await Promise.all(Array.from({ length: Math.min(3, plan.entries.length) }, worker));
4025
- if (bodies.reduce((bytes, body) => bytes + body.byteLength + 2, 0) > MAX_MOBILE_BOOT_BATCH_BYTES) throw new HttpError(502, "upstream_unavailable");
4285
+ if (bodies.reduce((bytes, body) => bytes + body.byteLength + MOBILE_BOOT_SEPARATOR_BYTES, 0) > MAX_MOBILE_BOOT_BATCH_BYTES) throw new HttpError(502, "upstream_unavailable");
4026
4286
  return Buffer.concat(bodies.flatMap((body) => [body, Buffer.from("\n;\n")]));
4027
4287
  }
4028
4288
  /**
@@ -4131,7 +4391,39 @@ var MobileAccessGateway = class {
4131
4391
  }
4132
4392
  };
4133
4393
  }
4134
- async proxyHttp(request$2, response, authorization) {
4394
+ /**
4395
+ * Proxy one request upstream. Pass-through client bundles (`GET /plugins`)
4396
+ * receive bounded transient retries — the upstream resets a fraction of fresh
4397
+ * connections — matching the resilience the merged-batch assembly already has.
4398
+ * A request is only retried before any byte reached the client.
4399
+ */
4400
+ async proxyHttp(request, response, authorization) {
4401
+ if (!(request.method === "GET" && request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true)) {
4402
+ try {
4403
+ await this.proxyHttpOnce(request, response, authorization);
4404
+ } catch (error) {
4405
+ if (error instanceof HttpError) throw error;
4406
+ if (response.headersSent) response.destroy();
4407
+ else throw new HttpError(502, "upstream_unavailable");
4408
+ }
4409
+ return;
4410
+ }
4411
+ const delay = (attempt) => new Promise((resolve) => setTimeout(resolve, MOBILE_BOOT_RETRY_DELAY_MS * attempt));
4412
+ for (let attempt = 1; attempt <= MOBILE_BOOT_UPSTREAM_ATTEMPTS; attempt++) try {
4413
+ await this.proxyHttpOnce(request, response, authorization);
4414
+ return;
4415
+ } catch (error) {
4416
+ if (response.headersSent) {
4417
+ response.destroy();
4418
+ return;
4419
+ }
4420
+ if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
4421
+ if (attempt === MOBILE_BOOT_UPSTREAM_ATTEMPTS) throw new HttpError(502, "upstream_unavailable");
4422
+ await delay(attempt);
4423
+ }
4424
+ throw new HttpError(502, "upstream_unavailable");
4425
+ }
4426
+ async proxyHttpOnce(request$2, response, authorization) {
4135
4427
  const declared = request$2.headers["content-length"];
4136
4428
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
4137
4429
  const holder = {};
@@ -4185,7 +4477,7 @@ var MobileAccessGateway = class {
4185
4477
  await bodyDone?.catch(() => void 0);
4186
4478
  if (error instanceof HttpError) throw error;
4187
4479
  if (response.headersSent) response.destroy();
4188
- else throw new HttpError(502, "upstream_unavailable");
4480
+ throw error;
4189
4481
  } finally {
4190
4482
  operation.release();
4191
4483
  }
@@ -6486,10 +6778,23 @@ async function attemptDownload(request) {
6486
6778
  });
6487
6779
  const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
6488
6780
  if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
6489
- const declared = Number(response.headers.get("content-length"));
6490
- if (Number.isFinite(declared) && declared !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6491
- const bytes = new Uint8Array(await response.arrayBuffer());
6492
- if (bytes.byteLength !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6781
+ const contentLength = response.headers.get("content-length");
6782
+ if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6783
+ if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6784
+ const bytes = new Uint8Array(request.expectedBytes);
6785
+ const reader = response.body.getReader();
6786
+ let received = 0;
6787
+ while (true) {
6788
+ const chunk = await reader.read();
6789
+ if (chunk.done) break;
6790
+ if (chunk.value.byteLength > bytes.byteLength - received) {
6791
+ await reader.cancel();
6792
+ throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6793
+ }
6794
+ bytes.set(chunk.value, received);
6795
+ received += chunk.value.byteLength;
6796
+ }
6797
+ if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6493
6798
  return bytes;
6494
6799
  }
6495
6800
  /**
@@ -6512,24 +6817,45 @@ async function downloadPinnedArtifact(request) {
6512
6817
  }
6513
6818
  //#endregion
6514
6819
  //#region src/cloudflared-component.ts
6820
+ const CLOUDFLARED_VERSION = "2026.9.1";
6821
+ /** Pinned official cloudflared release metadata for supported desktop targets. */
6822
+ const CLOUDFLARED_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
6823
+ {
6824
+ version: CLOUDFLARED_VERSION,
6825
+ platform: "win32",
6826
+ arch: "x64",
6827
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-windows-amd64.exe`,
6828
+ downloadBytes: 54976432,
6829
+ downloadSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
6830
+ executableName: "cloudflared.exe"
6831
+ },
6832
+ {
6833
+ version: CLOUDFLARED_VERSION,
6834
+ platform: "linux",
6835
+ arch: "x64",
6836
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64`,
6837
+ downloadBytes: 39838488,
6838
+ downloadSha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc",
6839
+ executableName: "cloudflared"
6840
+ },
6841
+ {
6842
+ version: CLOUDFLARED_VERSION,
6843
+ platform: "linux",
6844
+ arch: "arm64",
6845
+ downloadUrl: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64`,
6846
+ downloadBytes: 37466252,
6847
+ downloadSha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3",
6848
+ executableName: "cloudflared"
6849
+ }
6850
+ ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
6515
6851
  /**
6516
- * Pinned cloudflared Windows component fetched only after an explicit user action.
6517
- *
6518
- * Unlike the cpolar archive this artifact IS the executable: there is nothing to
6519
- * unpack, so the download digest and the installed digest are the same pair.
6520
- * `--no-autoupdate` is passed at runtime as well, so the pinned bytes stay the
6521
- * bytes that were verified.
6852
+ * Canonical release metadata. New code should select from
6853
+ * {@link CLOUDFLARED_COMPONENT_RELEASES} by platform and architecture; this
6854
+ * alias preserves the original Windows x64 entry for existing callers.
6522
6855
  */
6523
- const CLOUDFLARED_COMPONENT_RELEASE = Object.freeze({
6524
- version: "2026.9.1",
6525
- platform: "win32",
6526
- arch: "x64",
6527
- downloadUrl: "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-windows-amd64.exe",
6528
- downloadBytes: 54976432,
6529
- downloadSha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
6530
- downloadPage: "https://github.com/cloudflare/cloudflared/releases",
6531
- termsUrl: "https://www.cloudflare.com/website-terms/"
6532
- });
6856
+ const CLOUDFLARED_COMPONENT_RELEASE = CLOUDFLARED_COMPONENT_RELEASES["win32-x64"];
6857
+ const DOWNLOAD_PAGE$1 = "https://github.com/cloudflare/cloudflared/releases";
6858
+ const TERMS_URL$1 = "https://www.cloudflare.com/website-terms/";
6533
6859
  function inside$1(parent, child) {
6534
6860
  const candidate = relative(parent, child);
6535
6861
  return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
@@ -6546,14 +6872,18 @@ async function regularFile$1(file, expectedBytes) {
6546
6872
  throw error;
6547
6873
  }
6548
6874
  }
6549
- async function defaultFetchArtifact$1(url, signal) {
6875
+ async function defaultFetchArtifact$1(url, signal, expectedBytes) {
6550
6876
  return downloadPinnedArtifact({
6551
6877
  url,
6552
- expectedBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
6878
+ expectedBytes,
6553
6879
  errorPrefix: "cloudflared",
6554
6880
  signal
6555
6881
  });
6556
6882
  }
6883
+ /** Select the pinned artifact for one host, or undefined where unsupported. */
6884
+ function lookupRelease(platform, arch) {
6885
+ return CLOUDFLARED_COMPONENT_RELEASES[`${platform}-${arch}`];
6886
+ }
6557
6887
  /** Owns the optional cloudflared binary inside DSH Mobile state. */
6558
6888
  var CloudflaredComponentManager = class {
6559
6889
  executable;
@@ -6564,6 +6894,7 @@ var CloudflaredComponentManager = class {
6564
6894
  stagingRoot;
6565
6895
  platform;
6566
6896
  arch;
6897
+ release;
6567
6898
  fetchArtifact;
6568
6899
  installed = false;
6569
6900
  errorCode;
@@ -6573,9 +6904,10 @@ var CloudflaredComponentManager = class {
6573
6904
  if (!isAbsolute(stateDirectory)) throw new Error("cloudflared state directory must be absolute");
6574
6905
  this.platform = options.platform ?? process.platform;
6575
6906
  this.arch = options.arch ?? process.arch;
6907
+ this.release = lookupRelease(this.platform, this.arch);
6576
6908
  this.componentRoot = join(stateDirectory, "components", "cloudflared");
6577
- this.componentStorage = join(this.componentRoot, CLOUDFLARED_COMPONENT_RELEASE.version);
6578
- this.executable = join(this.componentStorage, "cloudflared.exe");
6909
+ this.componentStorage = join(this.componentRoot, this.release?.version ?? CLOUDFLARED_VERSION);
6910
+ this.executable = join(this.componentStorage, this.release?.executableName ?? "cloudflared");
6579
6911
  this.stateRoot = join(stateDirectory, "state", "cloudflared");
6580
6912
  this.logRoot = join(stateDirectory, "logs", "cloudflared");
6581
6913
  this.stagingRoot = join(stateDirectory, "staging", "cloudflared");
@@ -6586,27 +6918,30 @@ var CloudflaredComponentManager = class {
6586
6918
  this.logRoot,
6587
6919
  this.stagingRoot
6588
6920
  ]) if (!inside$1(stateDirectory, child)) throw new Error("cloudflared component path escaped its state directory");
6589
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact$1;
6921
+ const release = this.release;
6922
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => defaultFetchArtifact$1(url, signal, release?.downloadBytes ?? 0));
6590
6923
  }
6591
6924
  /** Inspect the managed binary without using any global cloudflared state. */
6592
6925
  async initialize() {
6593
- this.installed = await regularFile$1(this.executable, CLOUDFLARED_COMPONENT_RELEASE.downloadBytes);
6594
- if (this.installed && await sha256$1(this.executable) !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) {
6926
+ const release = this.release;
6927
+ this.installed = release !== void 0 && await regularFile$1(this.executable, release.downloadBytes);
6928
+ if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.downloadSha256) {
6595
6929
  this.installed = false;
6596
6930
  this.errorCode = "cloudflared_component_invalid";
6597
6931
  }
6598
6932
  }
6599
6933
  /** Return a safe status that never includes machine-specific account data. */
6600
6934
  status() {
6935
+ const release = this.release ?? CLOUDFLARED_COMPONENT_RELEASE;
6601
6936
  return Object.freeze({
6602
- supported: this.platform === CLOUDFLARED_COMPONENT_RELEASE.platform && this.arch === CLOUDFLARED_COMPONENT_RELEASE.arch,
6937
+ supported: this.release !== void 0,
6603
6938
  installed: this.installed,
6604
- version: CLOUDFLARED_COMPONENT_RELEASE.version,
6605
- downloadBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
6606
- installedBytes: CLOUDFLARED_COMPONENT_RELEASE.downloadBytes,
6607
- sourceUrl: CLOUDFLARED_COMPONENT_RELEASE.downloadUrl,
6608
- downloadPage: CLOUDFLARED_COMPONENT_RELEASE.downloadPage,
6609
- termsUrl: CLOUDFLARED_COMPONENT_RELEASE.termsUrl,
6939
+ version: release.version,
6940
+ downloadBytes: release.downloadBytes,
6941
+ installedBytes: release.downloadBytes,
6942
+ sourceUrl: release.downloadUrl,
6943
+ downloadPage: DOWNLOAD_PAGE$1,
6944
+ termsUrl: TERMS_URL$1,
6610
6945
  storagePath: this.componentRoot,
6611
6946
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
6612
6947
  });
@@ -6614,7 +6949,8 @@ var CloudflaredComponentManager = class {
6614
6949
  /** Download, verify, and install the pinned cloudflared executable after explicit confirmation. */
6615
6950
  install() {
6616
6951
  return this.enqueue(async () => {
6617
- if (this.platform !== CLOUDFLARED_COMPONENT_RELEASE.platform || this.arch !== CLOUDFLARED_COMPONENT_RELEASE.arch) throw new Error("cloudflared_component_unsupported");
6952
+ const release = this.release;
6953
+ if (release === void 0) throw new Error("cloudflared_component_unsupported");
6618
6954
  await mkdir(this.stagingRoot, {
6619
6955
  recursive: true,
6620
6956
  mode: 448
@@ -6628,26 +6964,26 @@ var CloudflaredComponentManager = class {
6628
6964
  timeout.unref();
6629
6965
  let bytes;
6630
6966
  try {
6631
- bytes = await this.fetchArtifact(CLOUDFLARED_COMPONENT_RELEASE.downloadUrl, controller.signal);
6967
+ bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
6632
6968
  } finally {
6633
6969
  clearTimeout(timeout);
6634
6970
  }
6635
- if (bytes.byteLength !== CLOUDFLARED_COMPONENT_RELEASE.downloadBytes) throw new Error("cloudflared_download_size_mismatch");
6636
- if (createHash("sha256").update(bytes).digest("hex") !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) throw new Error("cloudflared_download_hash_mismatch");
6637
- const staged = join(staging, "cloudflared.exe");
6971
+ if (bytes.byteLength !== release.downloadBytes) throw new Error("cloudflared_download_size_mismatch");
6972
+ if (createHash("sha256").update(bytes).digest("hex") !== release.downloadSha256) throw new Error("cloudflared_download_hash_mismatch");
6973
+ const staged = join(staging, release.executableName);
6638
6974
  await writeFile(staged, bytes, {
6639
6975
  flag: "wx",
6640
6976
  mode: 384
6641
6977
  });
6642
6978
  await chmod(staged, 448);
6643
- if (!await regularFile$1(staged, CLOUDFLARED_COMPONENT_RELEASE.downloadBytes) || await sha256$1(staged) !== CLOUDFLARED_COMPONENT_RELEASE.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
6979
+ if (!await regularFile$1(staged, release.downloadBytes) || await sha256$1(staged) !== release.downloadSha256) throw new Error("cloudflared_executable_hash_mismatch");
6644
6980
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
6645
6981
  await mkdir(candidate, {
6646
6982
  recursive: true,
6647
6983
  mode: 448
6648
6984
  });
6649
- await copyFile(staged, join(candidate, "cloudflared.exe"));
6650
- await chmod(join(candidate, "cloudflared.exe"), 448);
6985
+ await copyFile(staged, join(candidate, release.executableName));
6986
+ await chmod(join(candidate, release.executableName), 448);
6651
6987
  await rm(this.componentStorage, {
6652
6988
  recursive: true,
6653
6989
  force: true
@@ -6704,6 +7040,15 @@ const MAX_LOG_BUFFER_BYTES$1 = 65536;
6704
7040
  * looking healthy for longer than a user will wait.
6705
7041
  */
6706
7042
  const START_TIMEOUT_MS$1 = 6e4;
7043
+ /**
7044
+ * How many startup-timeout rounds a named tunnel may wait through while its
7045
+ * connector process is still alive. A rebooted PC often autostarts DSH before
7046
+ * Wi-Fi / VPN / DNS is usable; cloudflared retries the edge on its own, so
7047
+ * killing it after a single 60 s window turns a slow boot into a manual
7048
+ * reconnect loop. An exited connector (bad token, bad config) still fails fast
7049
+ * on the first round.
7050
+ */
7051
+ const NAMED_STARTUP_ROUNDS = 5;
6707
7052
  const CLOUDFLARED_HOST_SUFFIX = ".trycloudflare.com";
6708
7053
  /**
6709
7054
  * Subdomains Cloudflare reserves for its own control plane.
@@ -6846,6 +7191,7 @@ var CloudflaredController = class {
6846
7191
  });
6847
7192
  queue = Promise.resolve();
6848
7193
  startupTimer;
7194
+ startupRounds = 0;
6849
7195
  constructor(options) {
6850
7196
  this.options = options;
6851
7197
  if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
@@ -6977,6 +7323,7 @@ var CloudflaredController = class {
6977
7323
  }
6978
7324
  this.reservation = reservation;
6979
7325
  this.buffer = "";
7326
+ this.startupRounds = 0;
6980
7327
  this.publish({
6981
7328
  enabled: true,
6982
7329
  state: "starting"
@@ -7038,11 +7385,32 @@ var CloudflaredController = class {
7038
7385
  this.child = void 0;
7039
7386
  if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "cloudflared_stopped" : "cloudflared_exited"));
7040
7387
  });
7388
+ this.armStartupTimer(generation);
7389
+ }
7390
+ armStartupTimer(generation) {
7391
+ if (this.startupTimer !== void 0) clearTimeout(this.startupTimer);
7041
7392
  this.startupTimer = setTimeout(() => {
7042
- this.enqueue(() => this.failGeneration(generation, "cloudflared_start_timeout"));
7393
+ this.enqueue(() => this.checkStartupTimeout(generation));
7043
7394
  }, this.options.startupTimeoutMs ?? START_TIMEOUT_MS$1);
7044
7395
  this.startupTimer.unref();
7045
7396
  }
7397
+ /**
7398
+ * Give a live named connector more time instead of killing it: it retries the
7399
+ * edge on its own and usually registers once the rebooted network is usable.
7400
+ * A dead connector, or an exhausted budget, fails the generation as before.
7401
+ */
7402
+ async checkStartupTimeout(generation) {
7403
+ if (generation !== this.generation || !this.enabled || this.disposed) return;
7404
+ if (this.latest.state === "ready") return;
7405
+ const childAlive = this.child !== void 0 && this.child.exitCode === null;
7406
+ const budget = this.options.maxStartupRounds ?? NAMED_STARTUP_ROUNDS;
7407
+ if (this.mode === "named" && childAlive && this.startupRounds + 1 < budget) {
7408
+ this.startupRounds += 1;
7409
+ this.armStartupTimer(generation);
7410
+ return;
7411
+ }
7412
+ await this.failGeneration(generation, "cloudflared_start_timeout");
7413
+ }
7046
7414
  consume(generation, chunk) {
7047
7415
  if (generation !== this.generation) return;
7048
7416
  this.buffer += chunk;
@@ -9526,21 +9894,56 @@ var CpolarController = class {
9526
9894
  };
9527
9895
  //#endregion
9528
9896
  //#region src/cpolar-component.ts
9529
- /** Pinned cpolar Windows component fetched only after an explicit user action. */
9530
- const CPOLAR_COMPONENT_RELEASE = Object.freeze({
9531
- version: "3.3.18",
9532
- platform: "win32",
9533
- arch: "x64",
9534
- downloadUrl: "https://www.cpolar.com/static/downloads/releases/3.3.18/cpolar-stable-windows-amd64-setup.zip",
9535
- downloadBytes: 7603505,
9536
- downloadSha256: "fb8cf60289058ee26079f995d2eeea0b21768a742d90c93015afe96e83428830",
9537
- executableBytes: 19637680,
9538
- executableSha256: "b2d865ee505e842d22ceca5493a872efa893a79b079a7a8ee2bd3aa5343a5c41",
9539
- downloadPage: "https://www.cpolar.com/download",
9540
- signupUrl: "https://dashboard.cpolar.com/signup",
9541
- dashboardUrl: "https://dashboard.cpolar.com/auth",
9542
- termsUrl: "https://www.cpolar.com/tos"
9543
- });
9897
+ const CPOLAR_VERSION = "3.3.18";
9898
+ /** Pinned official cpolar release metadata for supported desktop targets. */
9899
+ const CPOLAR_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
9900
+ {
9901
+ version: CPOLAR_VERSION,
9902
+ platform: "win32",
9903
+ arch: "x64",
9904
+ downloadUrl: `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}/cpolar-stable-windows-amd64-setup.zip`,
9905
+ downloadBytes: 7603505,
9906
+ downloadSha256: "fb8cf60289058ee26079f995d2eeea0b21768a742d90c93015afe96e83428830",
9907
+ executableName: "cpolar.exe",
9908
+ executableBytes: 19637680,
9909
+ executableSha256: "b2d865ee505e842d22ceca5493a872efa893a79b079a7a8ee2bd3aa5343a5c41",
9910
+ archiveKind: "msi-zip"
9911
+ },
9912
+ {
9913
+ version: CPOLAR_VERSION,
9914
+ platform: "linux",
9915
+ arch: "x64",
9916
+ downloadUrl: `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}/cpolar-stable-linux-amd64.tar.gz`,
9917
+ downloadBytes: 7404781,
9918
+ downloadSha256: "5cd3320c4369928ccb509c4f5fa2ec3b86151ead77e1b77c1694aa64c43e32e7",
9919
+ executableName: "cpolar",
9920
+ executableBytes: 19328632,
9921
+ executableSha256: "c076e1109372a3f88031841c5989030e49b6871b54aee3260c796da9122dec05",
9922
+ archiveKind: "tar.gz"
9923
+ },
9924
+ {
9925
+ version: CPOLAR_VERSION,
9926
+ platform: "linux",
9927
+ arch: "arm64",
9928
+ downloadUrl: `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}/cpolar-stable-linux-arm64.tar.gz`,
9929
+ downloadBytes: 6855666,
9930
+ downloadSha256: "8a61a97983f18ae5ffb4b8bac4c9b3d8e2399a6ee101844e7b0b30d7f326157c",
9931
+ executableName: "cpolar",
9932
+ executableBytes: 19017169,
9933
+ executableSha256: "8d76a1b7e518df45f387f107c7a428079c67ecd9b971a2915c53b947ea9b443a",
9934
+ archiveKind: "tar.gz"
9935
+ }
9936
+ ].map((release) => [`${release.platform}-${release.arch}`, Object.freeze(release)])));
9937
+ /**
9938
+ * Canonical release metadata. New code should select from
9939
+ * {@link CPOLAR_COMPONENT_RELEASES} by platform and architecture; this alias
9940
+ * preserves the original Windows x64 entry for existing callers.
9941
+ */
9942
+ const CPOLAR_COMPONENT_RELEASE = CPOLAR_COMPONENT_RELEASES["win32-x64"];
9943
+ const DOWNLOAD_PAGE = "https://www.cpolar.com/download";
9944
+ const SIGNUP_URL = "https://dashboard.cpolar.com/signup";
9945
+ const DASHBOARD_URL = "https://dashboard.cpolar.com/auth";
9946
+ const TERMS_URL = "https://www.cpolar.com/tos";
9544
9947
  function inside(parent, child) {
9545
9948
  const candidate = relative(parent, child);
9546
9949
  return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate);
@@ -9568,15 +9971,15 @@ async function run(file, args) {
9568
9971
  });
9569
9972
  });
9570
9973
  }
9571
- async function defaultFetchArtifact(url, signal) {
9974
+ async function defaultFetchArtifact(url, signal, expectedBytes) {
9572
9975
  return downloadPinnedArtifact({
9573
9976
  url,
9574
- expectedBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
9977
+ expectedBytes,
9575
9978
  errorPrefix: "cpolar",
9576
9979
  signal
9577
9980
  });
9578
9981
  }
9579
- async function defaultExtractArtifact(archive, destination) {
9982
+ async function extractWindowsMsi(archive, destination, executableName) {
9580
9983
  if (process.platform !== "win32") throw new Error("cpolar_component_unsupported");
9581
9984
  const unpacked = join(destination, "archive");
9582
9985
  const administrative = join(destination, "administrative");
@@ -9602,9 +10005,29 @@ async function defaultExtractArtifact(archive, destination) {
9602
10005
  "/qn",
9603
10006
  `TARGETDIR=${administrative}`
9604
10007
  ]);
9605
- const executableRelative = (await readdir(administrative, { recursive: true })).find((entry) => basename(entry).toLowerCase() === "cpolar.exe");
10008
+ const executableRelative = (await readdir(administrative, { recursive: true })).find((entry) => basename(entry).toLowerCase() === executableName.toLowerCase());
10009
+ if (executableRelative === void 0) throw new Error("cpolar_executable_missing");
10010
+ await copyFile(join(administrative, executableRelative), join(destination, executableName));
10011
+ }
10012
+ async function extractLinuxTarball(archive, destination, executableName) {
10013
+ const unpacked = join(destination, "archive");
10014
+ await mkdir(unpacked, {
10015
+ recursive: true,
10016
+ mode: 448
10017
+ });
10018
+ await run(process.platform === "win32" ? "tar.exe" : "tar", [
10019
+ "-xzf",
10020
+ archive,
10021
+ "-C",
10022
+ unpacked
10023
+ ]);
10024
+ const executableRelative = (await readdir(unpacked, { recursive: true })).find((entry) => basename(entry).toLowerCase() === executableName.toLowerCase());
9606
10025
  if (executableRelative === void 0) throw new Error("cpolar_executable_missing");
9607
- await copyFile(join(administrative, executableRelative), join(destination, "cpolar.exe"));
10026
+ await copyFile(join(unpacked, executableRelative), join(destination, executableName));
10027
+ }
10028
+ async function defaultExtractArtifact(archive, destination, release) {
10029
+ if (release.archiveKind === "tar.gz") return extractLinuxTarball(archive, destination, release.executableName);
10030
+ return extractWindowsMsi(archive, destination, release.executableName);
9608
10031
  }
9609
10032
  /** Validate a cpolar Authtoken before it crosses the durable-file boundary. */
9610
10033
  function validateCpolarAuthtoken(value) {
@@ -9622,6 +10045,7 @@ var CpolarComponentManager = class {
9622
10045
  stagingRoot;
9623
10046
  platform;
9624
10047
  arch;
10048
+ release;
9625
10049
  fetchArtifact;
9626
10050
  extractArtifact;
9627
10051
  installed = false;
@@ -9633,9 +10057,10 @@ var CpolarComponentManager = class {
9633
10057
  if (!isAbsolute(stateDirectory)) throw new Error("cpolar state directory must be absolute");
9634
10058
  this.platform = options.platform ?? process.platform;
9635
10059
  this.arch = options.arch ?? process.arch;
10060
+ this.release = CPOLAR_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
9636
10061
  this.componentRoot = join(stateDirectory, "components", "cpolar");
9637
- this.componentStorage = join(this.componentRoot, CPOLAR_COMPONENT_RELEASE.version);
9638
- this.executable = join(this.componentStorage, "cpolar.exe");
10062
+ this.componentStorage = join(this.componentRoot, this.release?.version ?? CPOLAR_VERSION);
10063
+ this.executable = join(this.componentStorage, this.release?.executableName ?? "cpolar");
9639
10064
  this.stateRoot = join(stateDirectory, "state", "cpolar");
9640
10065
  this.configFile = join(this.stateRoot, "cpolar.yml");
9641
10066
  this.logRoot = join(stateDirectory, "logs", "cpolar");
@@ -9647,13 +10072,18 @@ var CpolarComponentManager = class {
9647
10072
  this.logRoot,
9648
10073
  this.stagingRoot
9649
10074
  ]) if (!inside(stateDirectory, child)) throw new Error("cpolar component path escaped its state directory");
9650
- this.fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact;
9651
- this.extractArtifact = options.extractArtifact ?? defaultExtractArtifact;
10075
+ const release = this.release;
10076
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => defaultFetchArtifact(url, signal, release?.downloadBytes ?? 0));
10077
+ this.extractArtifact = options.extractArtifact ?? ((archive, destination) => {
10078
+ if (release === void 0) throw new Error("cpolar_component_unsupported");
10079
+ return defaultExtractArtifact(archive, destination, release);
10080
+ });
9652
10081
  }
9653
10082
  /** Inspect the managed binary and configuration without using global cpolar state. */
9654
10083
  async initialize() {
9655
- this.installed = await regularFile(this.executable, CPOLAR_COMPONENT_RELEASE.executableBytes);
9656
- if (this.installed && await sha256(this.executable) !== CPOLAR_COMPONENT_RELEASE.executableSha256) {
10084
+ const release = this.release;
10085
+ this.installed = release !== void 0 && await regularFile(this.executable, release.executableBytes);
10086
+ if (this.installed && release !== void 0 && await sha256(this.executable) !== release.executableSha256) {
9657
10087
  this.installed = false;
9658
10088
  this.errorCode = "cpolar_component_invalid";
9659
10089
  }
@@ -9662,18 +10092,19 @@ var CpolarComponentManager = class {
9662
10092
  }
9663
10093
  /** Return a safe status that never includes the account token. */
9664
10094
  status() {
10095
+ const release = this.release ?? CPOLAR_COMPONENT_RELEASE;
9665
10096
  return Object.freeze({
9666
- supported: this.platform === CPOLAR_COMPONENT_RELEASE.platform && this.arch === CPOLAR_COMPONENT_RELEASE.arch,
10097
+ supported: this.release !== void 0,
9667
10098
  installed: this.installed,
9668
10099
  configured: this.configured,
9669
- version: CPOLAR_COMPONENT_RELEASE.version,
9670
- downloadBytes: CPOLAR_COMPONENT_RELEASE.downloadBytes,
9671
- installedBytes: CPOLAR_COMPONENT_RELEASE.executableBytes,
9672
- sourceUrl: CPOLAR_COMPONENT_RELEASE.downloadUrl,
9673
- downloadPage: CPOLAR_COMPONENT_RELEASE.downloadPage,
9674
- signupUrl: CPOLAR_COMPONENT_RELEASE.signupUrl,
9675
- dashboardUrl: CPOLAR_COMPONENT_RELEASE.dashboardUrl,
9676
- termsUrl: CPOLAR_COMPONENT_RELEASE.termsUrl,
10100
+ version: release.version,
10101
+ downloadBytes: release.downloadBytes,
10102
+ installedBytes: release.executableBytes,
10103
+ sourceUrl: release.downloadUrl,
10104
+ downloadPage: DOWNLOAD_PAGE,
10105
+ signupUrl: SIGNUP_URL,
10106
+ dashboardUrl: DASHBOARD_URL,
10107
+ termsUrl: TERMS_URL,
9677
10108
  storagePath: this.componentRoot,
9678
10109
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
9679
10110
  });
@@ -9681,7 +10112,8 @@ var CpolarComponentManager = class {
9681
10112
  /** Download, verify, and administratively extract cpolar after explicit confirmation. */
9682
10113
  install() {
9683
10114
  return this.enqueue(async () => {
9684
- if (this.platform !== CPOLAR_COMPONENT_RELEASE.platform || this.arch !== CPOLAR_COMPONENT_RELEASE.arch) throw new Error("cpolar_component_unsupported");
10115
+ const release = this.release;
10116
+ if (release === void 0) throw new Error("cpolar_component_unsupported");
9685
10117
  await mkdir(this.stagingRoot, {
9686
10118
  recursive: true,
9687
10119
  mode: 448
@@ -9695,26 +10127,26 @@ var CpolarComponentManager = class {
9695
10127
  timeout.unref();
9696
10128
  let bytes;
9697
10129
  try {
9698
- bytes = await this.fetchArtifact(CPOLAR_COMPONENT_RELEASE.downloadUrl, controller.signal);
10130
+ bytes = await this.fetchArtifact(release.downloadUrl, controller.signal);
9699
10131
  } finally {
9700
10132
  clearTimeout(timeout);
9701
10133
  }
9702
- if (createHash("sha256").update(bytes).digest("hex") !== CPOLAR_COMPONENT_RELEASE.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
9703
- const archive = join(staging, "cpolar.zip");
10134
+ if (createHash("sha256").update(bytes).digest("hex") !== release.downloadSha256) throw new Error("cpolar_download_hash_mismatch");
10135
+ const archive = join(staging, release.archiveKind === "tar.gz" ? "cpolar.tar.gz" : "cpolar.zip");
9704
10136
  await writeFile(archive, bytes, {
9705
10137
  flag: "wx",
9706
10138
  mode: 384
9707
10139
  });
9708
10140
  await this.extractArtifact(archive, staging);
9709
- const extracted = join(staging, "cpolar.exe");
9710
- if (!await regularFile(extracted, CPOLAR_COMPONENT_RELEASE.executableBytes) || await sha256(extracted) !== CPOLAR_COMPONENT_RELEASE.executableSha256) throw new Error("cpolar_executable_hash_mismatch");
10141
+ const extracted = join(staging, release.executableName);
10142
+ if (!await regularFile(extracted, release.executableBytes) || await sha256(extracted) !== release.executableSha256) throw new Error("cpolar_executable_hash_mismatch");
9711
10143
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
9712
10144
  await mkdir(candidate, {
9713
10145
  recursive: true,
9714
10146
  mode: 448
9715
10147
  });
9716
- await copyFile(extracted, join(candidate, "cpolar.exe"));
9717
- await chmod(join(candidate, "cpolar.exe"), 448);
10148
+ await copyFile(extracted, join(candidate, release.executableName));
10149
+ await chmod(join(candidate, release.executableName), 448);
9718
10150
  await rm(this.componentStorage, {
9719
10151
  recursive: true,
9720
10152
  force: true
@@ -10404,6 +10836,16 @@ function selectLanNetwork(requestedAddress, requestedInterface, table, preferred
10404
10836
  if (physicalCandidates.length === 1) return physicalCandidates[0];
10405
10837
  throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
10406
10838
  }
10839
+ /**
10840
+ * True when the failure came from LAN selection (the configured network is
10841
+ * gone, nothing is up, or several are) rather than from config/TLS code.
10842
+ * Callers use it to degrade (warn + stay down) instead of failing boot.
10843
+ * Pinned to the selectLanNetwork message contract — keep in sync.
10844
+ */
10845
+ function isNetworkSelectionError(error) {
10846
+ const message = error instanceof Error ? error.message : String(error ?? "");
10847
+ return message.includes("is not connected") || message.includes("no active private LAN address was found") || message.includes("more than one LAN address is active") || message.includes("has more than one private IPv4 address") || message.includes("is not an active private LAN address");
10848
+ }
10407
10849
  function assertMatchingCa(certPem, keyPem) {
10408
10850
  const certificate = new X509Certificate(certPem);
10409
10851
  if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
@@ -10940,7 +11382,9 @@ async function apply(ctx, config) {
10940
11382
  ...candidateConfig,
10941
11383
  upstreamOrigin: template.upstreamOrigin.origin
10942
11384
  });
10943
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
11385
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
11386
+ logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
11387
+ });
10944
11388
  await candidate.start();
10945
11389
  lanGateway = candidate;
10946
11390
  const removeTaskSink = taskEventHub.add(candidate);
@@ -10965,7 +11409,14 @@ async function apply(ctx, config) {
10965
11409
  }, (error) => {
10966
11410
  process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
10967
11411
  });
10968
- await following.initialize(2e3);
11412
+ try {
11413
+ await following.initialize(2e3);
11414
+ } catch (error) {
11415
+ if (!isNetworkSelectionError(error)) throw error;
11416
+ const detail = error instanceof Error ? error.message : String(error);
11417
+ process.emitWarning(`DSH Mobile: ${detail}; mobile access stays dormant until the network returns or setup is re-run — DSH itself keeps running.`, { code: "DSH_MOBILE_NETWORK_UNAVAILABLE" });
11418
+ following.beginPolling(2e3);
11419
+ }
10969
11420
  return following;
10970
11421
  };
10971
11422
  const lanControlFile = parseControlFile(config.controlFile);